diff options
author | bptato <nincsnevem662@gmail.com> | 2023-06-02 00:36:54 +0200 |
---|---|---|
committer | bptato <nincsnevem662@gmail.com> | 2023-06-05 03:58:21 +0200 |
commit | 8027e52cb221c432bed64517015ebf3182e6166d (patch) | |
tree | 18991f9e74c8dcfc0ed7439f3bc78a0cfec9b2d6 /src/img | |
parent | b3b97465805b7367df461a4b7b830fabaccf3a89 (diff) | |
download | chawan-8027e52cb221c432bed64517015ebf3182e6166d.tar.gz |
Add support for canvas and multipart
Quite incomplete canvas implementation. Crucially, the layout engine can't do much with whatever is drawn because it doesn't support images yet. I've re-introduced multipart as well, with the FormData API. For the append function I've also introduced a hack to the JS binding generator that allows requesting the JSContext pointer in nim procs. Really I should just fix the union generator thing and add support for overloading. In conclusion, for now the only thing canvas can be used for is exporting it as PNG and uploading it somewhere. Also, we now have PNG encoding and decoding too. (Now if only we had sixels as well...)
Diffstat (limited to 'src/img')
-rw-r--r-- | src/img/bitmap.nim | 620 | ||||
-rw-r--r-- | src/img/path.nim | 430 |
2 files changed, 1050 insertions, 0 deletions
diff --git a/src/img/bitmap.nim b/src/img/bitmap.nim new file mode 100644 index 00000000..03048f4d --- /dev/null +++ b/src/img/bitmap.nim @@ -0,0 +1,620 @@ +import algorithm +import math +import unicode + +import bindings/zlib +import css/values +import img/path +import types/color +import types/line +import types/vector + +import lib/endians2 + +type + CanvasFillRule* = enum + NON_ZERO = "nonzero" + EVEN_ODD = "evenodd" + + Bitmap* = ref object of RootObj + px: seq[RGBAColor] + width*: uint64 + height*: uint64 + + ImageBitmap* = ref object of Bitmap + +proc newBitmap*(width, height: uint64): Bitmap = + return ImageBitmap( + px: newSeq[RGBAColor](width * height), + width: width, + height: height + ) + +proc setpx(bmp: Bitmap, x, y: uint64, color: RGBAColor) {.inline.} = + bmp.px[bmp.width * y + x] = color + +proc getpx*(bmp: Bitmap, x, y: uint64): RGBAColor {.inline.} = + return bmp.px[bmp.width * y + x] + +proc setpxb(bmp: Bitmap, x, y: uint64, color: RGBAColor) {.inline.} = + if color.a == 255: + bmp.setpx(x, y, color) + else: + bmp.setpx(x, y, bmp.getpx(x, y).blend(color)) + +# https://en.wikipedia.org/wiki/Bresenham's_line_algorithm#All_cases +proc plotLineLow(bmp: Bitmap, x0, y0, x1, y1: int64, color: RGBAColor) = + var dx = x1 - x0 + var dy = y1 - y0 + var yi = 1 + if dy < 0: + yi = -1 + dy = -dy + var D = 2 * dy - dx; + var y = y0; + for x in x0 ..< x1: + if x < 0 or y < 0 or uint64(x) >= bmp.width or uint64(y) >= bmp.height: + break + bmp.setpxb(uint64(x), uint64(y), color) + if D > 0: + y = y + yi; + D = D - 2 * dx; + D = D + 2 * dy; + +proc plotLineHigh(bmp: Bitmap, x0, y0, x1, y1: int64, color: RGBAColor) = + var dx = x1 - x0 + var dy = y1 - y0 + var xi = 1 + if dx < 0: + xi = -1 + dx = -dx + var D = 2 * dx - dy + var x = x0 + for y in y0 ..< y1: + if x < 0 or y < 0 or uint64(x) >= bmp.width or uint64(y) >= bmp.height: + break + bmp.setpxb(uint64(x), uint64(y), color) + if D > 0: + x = x + xi + D = D - 2 * dy + D = D + 2 * dx + +#TODO should be uint64... +proc plotLine(bmp: Bitmap, x0, y0, x1, y1: int64, color: RGBAColor) = + if abs(y1 - y0) < abs(x1 - x0): + if x0 > x1: + bmp.plotLineLow(x1, y1, x0, y0, color) + else: + bmp.plotLineLow(x0, y0, x1, y1, color) + else: + if y0 > y1: + bmp.plotLineHigh(x1, y1, x0, y0, color) + else: + bmp.plotLineHigh(x0, y0, x1, y1, color) + +proc plotLine(bmp: Bitmap, a, b: Vector2D, color: RGBAColor) = + bmp.plotLine(int64(a.x), int64(a.y), int64(b.x), int64(b.y), color) + +proc plotLine(bmp: Bitmap, line: Line, color: RGBAColor) = + bmp.plotLine(line.p0, line.p1, color) + +proc strokePath*(bmp: Bitmap, path: Path, color: RGBAColor) = + for line in path.lines: + bmp.plotLine(line, color) + +func isInside(windingNumber: int, fillRule: CanvasFillRule): bool = + return case fillRule + of NON_ZERO: windingNumber != 0 + of EVEN_ODD: windingNumber mod 2 == 0 + +# Mainly adapted from SerenityOS. +proc fillPath*(bmp: Bitmap, path: Path, color: RGBAColor, + fillRule: CanvasFillRule) = + let lines = path.getLineSegments() + var i = 0 + var ylines: seq[LineSegment] + for y in int64(lines.miny) .. int64(lines.maxy): + for k in countdown(ylines.high, 0): + if ylines[k].maxy < float64(y): + ylines.del(k) # we'll sort anyways, so del is fine + for j in i ..< lines.len: + if lines[j].miny > float64(y): + break + if lines[j].maxy > float64(y): + ylines.add(lines[j]) + inc i + ylines.sort(cmpLineSegmentX) + var w = if fillRule == NON_ZERO: 1 else: 0 + for k in 0 ..< ylines.high: + let a = ylines[k] + let b = ylines[k + 1] + let sx = int64(a.minyx) + let ex = int64(b.minyx) + if isInside(w, fillRule) and y > 0: + for x in sx .. ex: + if x > 0: + bmp.setpxb(uint64(x), uint64(y), color) + if int64(a.p0.y) != y and int64(a.p1.y) != y and int64(b.p0.y) != y and + int64(b.p1.y) != y and sx != ex or a.islope * b.islope < 0: + case fillRule + of EVEN_ODD: inc w + of NON_ZERO: + if a.p0.y < a.p1.y: + inc w + else: + dec w + ylines[k].minyx += ylines[k].islope + if ylines.len > 0: + ylines[^1].minyx += ylines[^1].islope + +proc fillRect*(bmp: Bitmap, x0, x1, y0, y1: uint64, color: RGBAColor) = + for y in y0 ..< y1: + for x in x0 ..< x1: + bmp.setpxb(x, y, color) + +proc strokeRect*(bmp: Bitmap, x0, x1, y0, y1: uint64, color: RGBAColor) = + for x in x0 ..< x1: + bmp.setpxb(x, y0, color) + bmp.setpxb(x, y1, color) + for y in y0 ..< y1: + bmp.setpxb(x0, y, color) + bmp.setpxb(x1, y, color) + +proc clearRect*(bmp: Bitmap, x0, x1, y0, y1: uint64) = + for y in y0 ..< y1: + for x in x0 ..< x1: + bmp.setpx(x, y, rgba(0, 0, 0, 0)) + +proc clear*(bmp: Bitmap) = + bmp.clearRect(0, bmp.width, 0, bmp.height) + +#TODO clean up templates, also move png encoder to a different file +type PNGWriter = object + buf: pointer + i: int + outlen: int + +func pngInt(i: uint32): auto = + doAssert i < uint32(2 ^ 31) + return i.toBytesBE() + +func oq(writer: PNGWriter): ptr UncheckedArray[uint8] = + cast[ptr UncheckedArray[uint8]](writer.buf) + +proc writeStr[T](writer: var PNGWriter, s: T) = + if writer.outlen < writer.i + s.len: + writer.outlen = writer.i + s.len + writer.buf = realloc(writer.buf, writer.outlen) + copyMem(addr writer.oq[writer.i], unsafeAddr s[0], s.len) + writer.i += s.len + +proc writeInt(writer: var PNGWriter, i: uint32) = + writer.writeStr(i.toBytesBE()) + +proc writePngInt(writer: var PNGWriter, i: uint32) = + doAssert i < uint32(2 ^ 31) + writer.writeInt(i) + +proc writeChunk[T](writer: var PNGWriter, t: string, data: T) = + var crc = uint32(crc32(0, cast[ptr uint8](unsafeAddr t[0]), cuint(t.len))) + if data.len > 0: + crc = uint32(crc32(crc, cast[ptr uint8](unsafeAddr data[0]), + cuint(data.len))) + writer.writePngInt(uint32(data.len)) + writer.writeStr(t) + if data.len > 0: + writer.writeStr(data) + writer.writeInt(uint32(crc)) + +type PNGColorType {.size: sizeof(uint8).} = enum + GRAYSCALE = 0 + TRUECOLOR = 2 + INDEXED_COLOR = 3 + GRAYSCALE_WITH_ALPHA = 4 + TRUECOLOR_WITH_ALPHA = 6 + +func u8toc(x: openArray[uint8]): string = + #TODO ew + var s = newString(x.len) + copyMem(addr s[0], unsafeAddr x[0], x.len) + return s + +const PNGSignature = "\x89PNG\r\n\x1A\n" +proc writeIHDR(writer: var PNGWriter, width, height: uint32, + bitDepth: uint8, colorType: PNGColorType, + compressionMethod, filterMethod, interlaceMethod: uint8) = + writer.writeStr(PNGSignature) + let ihdr = u8toc(pngInt(width)) & + u8toc(pngInt(height)) & + char(bitDepth) & + char(uint8(colorType)) & + char(compressionMethod) & + char(filterMethod) & + char(interlaceMethod) + writer.writeChunk("IHDR", ihdr) + +proc writeIDAT(writer: var PNGWriter, bmp: Bitmap) = + #TODO smaller idat chunks + # +1 height for filter + var idat = newSeq[uint8]((bmp.width + 1) * bmp.height * 4) + var j = 0 # idat pointer + for k in 0 ..< bmp.px.len: + if k mod int(bmp.width) == 0: + # begin row + # For now, filter is always 0. TODO implement other filters + inc j + let p = bmp.px[k] + idat[j] = uint8(p.r) + idat[j + 1] = uint8(p.g) + idat[j + 2] = uint8(p.b) + idat[j + 3] = uint8(p.a) + j += 4 + var hlen = compressBound(culong(idat.len)) + var oidat = newSeq[uint8](int(hlen)) + let res = compress(addr oidat[0], addr hlen, addr idat[0], culong(idat.len)) + doAssert res == Z_OK #TODO error handling... + oidat.setLen(int(hlen)) + writer.writeChunk("IDAT", oidat) + +proc toPNG*(bmp: Bitmap, outlen: var int): pointer = + var writer = PNGWriter( + buf: alloc(PNGSignature.len), + outlen: PNGSignature.len + ) + writer.writeIHDR(uint32(bmp.width), uint32(bmp.height), 8, + TRUECOLOR_WITH_ALPHA, 0, 0, 0) + writer.writeIDAT(bmp) + writer.writeChunk("IEND", "") + outlen = writer.outlen + return writer.buf + +type PNGReader = object + bmp: Bitmap + iq: ptr UncheckedArray[uint8] + limit: int + i: int + bitDepth: uint8 + colorType: PNGColorType + background: RGBColor + isend: bool + idatBuf: seq[uint8] + uprow: seq[uint8] + idatAt: int + hasstrm: bool + strm: z_stream + strmend: bool + atline: int + +func width(reader: PNGReader): int {.inline.} = int(reader.bmp.width) + +func height(reader: PNGReader): int {.inline.} = int(reader.bmp.height) + +func spp(reader: PNGReader): int = + case reader.colorType + of TRUECOLOR: return 3 + of GRAYSCALE: return 1 + of INDEXED_COLOR: return 1 + of GRAYSCALE_WITH_ALPHA: return 2 + of TRUECOLOR_WITH_ALPHA: return 4 + +func scanlen(reader: PNGReader): int {.inline.} = + let w = reader.width + 1 + return (w * reader.spp * int(reader.bitDepth) + 7) div 8 + +proc handleError(reader: var PNGReader, msg: string) = + reader.bmp = nil + if reader.hasstrm: + discard inflateEnd(addr reader.strm) + +template err(reader: var PNGReader, msg: string) = + reader.handleError(msg) + return + +template readStr(reader: var PNGReader, L: int): string = + if reader.i + L > reader.limit: + reader.err "too short" + var s = newString(L) + copyMem(addr s[0], addr reader.iq[reader.i], L) + reader.i += L + s + +template readU8(reader: var PNGReader): uint8 = + if reader.i > reader.limit: + reader.err "too short" + let x = reader.iq[reader.i] + inc reader.i + x + +template readU32(reader: var PNGReader): uint32 = + if reader.i + 4 > reader.limit: + reader.err "too short" + let x = fromBytesBE(uint32, toOpenArray(reader.iq, reader.i, reader.i + 3)) + reader.i += 4 + x + +template readPNGInt(reader: var PNGReader): uint32 = + let x = reader.readU32() + if x >= uint32(2 ^ 31): + reader.err "int too large" + x + +template readColorType(reader: var PNGReader): PNGColorType = + case reader.readU8() + of 0u8: GRAYSCALE + of 2u8: TRUECOLOR + of 3u8: INDEXED_COLOR + of 4u8: GRAYSCALE_WITH_ALPHA + of 6u8: TRUECOLOR_WITH_ALPHA + else: reader.err "unknown color type" + +func bitDepthValid(colorType: PNGColorType, bitDepth: uint8): bool = + case colorType + of GRAYSCALE: + return int(bitDepth) in [1, 2, 4, 8, 16] + of INDEXED_COLOR: + return int(bitDepth) in [1, 2, 4, 8] + of TRUECOLOR, GRAYSCALE_WITH_ALPHA, TRUECOLOR_WITH_ALPHA: + return int(bitDepth) in [8, 16] + +proc readIHDR(reader: var PNGReader) = + if reader.readStr(PNGSignature.len) != PNGSignature: + reader.err "wrong signature" + if reader.readPNGInt() != 13: + reader.err "invalid header length" + if reader.readStr(4) != "IHDR": + reader.err "invalid header chunk" + let width = reader.readPNGInt() + let height = reader.readPNGInt() + reader.bitDepth = reader.readU8() #TODO check? + reader.colorType = reader.readColorType() + if not bitDepthValid(reader.colorType, reader.bitDepth): + reader.err "invalid bit depth" + let compressionMethod = reader.readU8() + if compressionMethod != 0: + reader.err "unknown compression method" + let filterMethod = reader.readU8() + if filterMethod != 0: + reader.err "unknown filter method" + let interlaceMethod = reader.readU8() + if interlaceMethod != 0: + reader.err "unknown interlace method" + let crc = crc32(0, addr reader.iq[reader.i - 17], 17) + if uint32(crc) != reader.readU32(): reader.err "wrong crc" + reader.bmp = newBitmap(width, height) + +proc readbKGD(reader: var PNGReader) = + case reader.colorType + of GRAYSCALE, GRAYSCALE_WITH_ALPHA: + discard reader.readU8() #TODO bit depth > 8 + reader.background = gray(reader.readU8()) + of TRUECOLOR, TRUECOLOR_WITH_ALPHA: + discard reader.readU8() #TODO bit depth > 8 + let r = reader.readU8() + discard reader.readU8() + let g = reader.readU8() + discard reader.readU8() + let b = reader.readU8() + reader.background = rgb(r, g, b) + of INDEXED_COLOR: + discard #TODO + +proc unfilter(reader: var PNGReader, irow: openArray[uint8], bpp: int) = + # none, sub, up -> replace uprow directly + # average, paeth -> copy to temp array, then replace uprow + let fil = irow[0] + let w = reader.width + case fil + of 0u8: # none + copyMem(addr reader.uprow[0], unsafeAddr irow[1], w) + of 1u8: # sub + for i in 1 ..< irow.len: + let j = i - 1 # skip filter byte + reader.uprow[j] = irow[i] + if j - bpp >= 0: + reader.uprow[j] += irow[j - bpp] + of 2u8: # up + for i in 1 ..< irow.len: + let j = i - 1 # skip filter byte + reader.uprow[j] += irow[i] + of 3u8: # average + reader.err "average not implemented yet" + of 4u8: # paeth + reader.err "paeth not implemented yet" + else: + eprint fil + reader.err "got invalid filter" + +proc writepxs(reader: var PNGReader, crow: var openArray[RGBAColor]) = + case reader.colorType + of GRAYSCALE: + var i = 0 + var j = 0 + for x in 0 ..< crow.len: + let u = reader.uprow[i] + let n = case reader.bitDepth + of 1: ((u shr (7 - j)) and 1) * 255 + of 2: ((u shr (6 - j)) and 3) * 85 + of 4: ((u shr (6 - j)) and 15) * 17 + of 8: u + of 16: u div 2 + else: 0 + j += int(reader.bitDepth) + i += j div 8 + j = j mod 8 + let nn = int(n) + crow[x] = rgba(nn, nn, nn, 255) + else: discard + +proc readIDAT(reader: var PNGReader) = + if reader.idatAt == reader.idatBuf.len: + reader.err "idat buffer already filled" + if reader.strmend: + reader.err "stream already ended" + reader.strm.avail_in = cuint(reader.limit - reader.i) + reader.strm.next_in = addr reader.iq[reader.i] + let olen = reader.idatBuf.len - reader.idatAt + reader.strm.avail_out = cuint(olen) + reader.strm.next_out = addr reader.idatBuf[reader.idatAt] + let res = inflate(addr reader.strm, Z_NO_FLUSH) + doAssert res != Z_STREAM_ERROR + case res + of Z_NEED_DICT, Z_DATA_ERROR, Z_MEM_ERROR, Z_BUF_ERROR: + # Z_BUF_ERROR is fatal here, as outlen is at least as large as idat. + reader.err "error decompressing idat stream" + of Z_STREAM_END: + reader.strmend = true + of Z_OK: + if reader.strm.avail_out == 0: + reader.err "not enough space for output; is width or height wrong?" + else: doAssert false + reader.idatAt = int(reader.strm.total_out) + reader.i = reader.limit + let maxline = reader.idatAt div int(reader.scanlen) + let bmp = reader.bmp + let bps = if reader.bitDepth <= 8: 1 else: 2 # else 16 bit + let bpp = bps * reader.spp + let sl = int(reader.scanlen) + for y in reader.atline ..< maxline: + let yi = y * sl + assert yi + sl - 1 < reader.idatAt + reader.unfilter(toOpenArray(reader.idatBuf, yi, yi + sl - 1), bpp) + if unlikely(reader.bmp == nil): return + let yj = y * reader.width + reader.writepxs(toOpenArray(bmp.px, yj, yj + reader.width - 1)) + +proc readIEND(reader: var PNGReader) = + if reader.i < reader.limit: + reader.err "IEND too long" + reader.isend = true + +proc readUnknown(reader: var PNGReader, s: string) = + if (int(s[0]) and 0x20) == 0: + reader.err "unrecognized critical chunk " & s + #else: eprint "warning: unknown chunk " & s #debug + reader.i = reader.limit + +proc zlibAlloc(opaque: pointer, items: cuint, size: cuint): pointer {.cdecl.} = + return alloc(items * size) + +proc zlibFree(opaque: pointer, address: pointer) {.cdecl.} = + dealloc(address) + +proc initZStream(reader: var PNGReader) = + let bps = max(int(reader.bitDepth) div 8, 1) + reader.idatBuf = newSeq[uint8](reader.scanlen * reader.height * bps) + reader.uprow = newSeq[uint8](reader.width * bps) + reader.strm = z_stream( + zalloc: zlibAlloc, + zfree: zlibFree + ) + let ret = inflateInit(addr reader.strm) + if ret != Z_OK: + reader.err "failed to init inflate: " & $ret + reader.hasstrm = true + +proc fromPNG*(iq: openArray[uint8]): Bitmap = + if iq.len == 0: return + var reader = PNGReader( + iq: cast[ptr UncheckedArray[uint8]](unsafeAddr iq[0]), + limit: iq.len + ) + reader.readIHDR() + if reader.bmp == nil: return + if reader.width == 0 or reader.height == 0: + reader.err "invalid zero sized png" + if reader.colorType != GRAYSCALE: + reader.err "only grayscale is implemented" + reader.initZStream() + while reader.i < iq.len and not reader.isend: + let len = int(reader.readPNGInt()) + if reader.i + len > iq.len: + reader.err "chunk too long" + let j = reader.i + let t = reader.readStr(4) + reader.limit = reader.i + len + case t + of "IHDR": reader.err "IHDR expected to be first chunk" + of "IDAT": reader.readIDAT() + of "IEND": reader.readIEND() + of "bKGD": reader.readbKGD() + else: reader.readUnknown(t) + if reader.bmp == nil: return + let crc = crc32(0, unsafeAddr iq[j], cuint(len + 4)) + reader.limit = iq.len + let y = reader.readU32() + if uint32(crc) != y: + reader.err "wrong crc" + if not reader.isend: + reader.err "IEND not found" + return reader.bmp + +const unifont = readFile"res/unifont_jp-15.0.05.png" +var unifontBitmap: Bitmap +var glyphCache: seq[tuple[u: uint32, bmp: Bitmap]] +var glyphCacheI = 0 +proc getCharBmp(u: uint32): Bitmap = + # We only have the BMP. + let u = if u <= 0xFFFF: u else: 0xFFFD + if unifontBitmap == nil: + unifontBitmap = fromPNG(toOpenArrayByte(unifont, 0, unifont.high)) + for (cu, bmp) in glyphCache: + if cu == u: + return bmp + # Unifont glyphs start at x: 32, y: 64, and are of 8x16/16x16 size + let gx = uint64(32 + 16 * (u mod 0xFF)) + let gy = uint64(64 + 16 * (u div 0xFF)) + var fullwidth = false + const white = rgba(255, 255, 255, 255) + block loop: + # hack to recognize full width characters + for y in 0 ..< 16u64: + for x in 8 ..< 16u64: + if unifontBitmap.getpx(gx + x, gy + y) != white: + fullwidth = true + break loop + let bmp = newBitmap(if fullwidth: 16 else: 8, 16) + for y in 0 ..< bmp.height: + for x in 0 ..< bmp.width: + let c = unifontBitmap.getpx(gx + x, gy + y) + if c != white: + bmp.setpx(x, y, c) + if glyphCache.len < 256: + glyphCache.add((u, bmp)) + else: + glyphCache[glyphCacheI] = (u, bmp) + inc glyphCacheI + if glyphCacheI >= glyphCache.len: + glyphCacheI = 0 + return bmp + +proc drawBitmap(a, b: Bitmap, p: Vector2D) = + for y in 0 ..< b.height: + for x in 0 ..< b.width: + let ax = uint64(p.x) + x + let ay = uint64(p.y) + y + if ax >= 0 and ay >= y and ax < a.width and ay < a.height: + a.setpxb(ax, ay, b.getpx(x, y)) + +proc fillText*(bmp: Bitmap, text: string, x, y: float64, color: RGBAColor, + textAlign: CSSTextAlign) = + var w = 0f64 + var glyphs: seq[Bitmap] + for r in text.runes: + let glyph = getCharBmp(uint32(r)) + glyphs.add(glyph) + w += float64(glyph.width) + var x = x + #TODO rtl + case textAlign + of TEXT_ALIGN_LEFT, TEXT_ALIGN_START: discard + of TEXT_ALIGN_RIGHT, TEXT_ALIGN_END: x -= w + of TEXT_ALIGN_CENTER: x -= w / 2 + else: doAssert false + for glyph in glyphs: + bmp.drawBitmap(glyph, Vector2D(x: x, y: y - 8)) + x += float64(glyph.width) + +proc strokeText*(bmp: Bitmap, text: string, x, y: float64, color: RGBAColor, + textAlign: CSSTextAlign) = + #TODO + bmp.fillText(text, x, y, color, textAlign) diff --git a/src/img/path.nim b/src/img/path.nim new file mode 100644 index 00000000..4c112f28 --- /dev/null +++ b/src/img/path.nim @@ -0,0 +1,430 @@ +import algorithm +import deques +import math + +import types/line +import types/vector + +type + Path* = ref object + subpaths: seq[Subpath] + needsNewSubpath: bool + tempClosed: bool + + PathLines* = object + lines*: seq[LineSegment] + miny*: float64 + maxy*: float64 + + PathSegmentType = enum + SEGMENT_STRAIGHT, SEGMENT_QUADRATIC, SEGMENT_BEZIER, SEGMENT_ARC, + SEGMENT_ELLIPSE + + PathSegment = object + case t: PathSegmentType + of SEGMENT_QUADRATIC: + cp: Vector2D + of SEGMENT_BEZIER: + cp0: Vector2D + cp1: Vector2D + of SEGMENT_ARC: + oa: Vector2D + r: float64 + ia: bool + of SEGMENT_ELLIPSE: + oe: Vector2D + rx: float64 + ry: float64 + else: discard + + Subpath* = object + points: seq[Vector2D] + segments: seq[PathSegment] + closed: bool + +proc newPath*(): Path = + return Path( + needsNewSubpath: true + ) + +proc addSubpathAt(path: Path, p: Vector2D) = + path.subpaths.add(Subpath(points: @[p])) + +proc addSegment(path: Path, segment: PathSegment, p: Vector2D) = + path.subpaths[^1].segments.add(segment) + path.subpaths[^1].points.add(p) + +proc addStraightSegment(path: Path, p: Vector2D) = + let segment = PathSegment(t: SEGMENT_STRAIGHT) + path.addSegment(segment, p) + +proc addQuadraticSegment(path: Path, cp, p: Vector2D) = + let segment = PathSegment( + t: SEGMENT_QUADRATIC, + cp: cp + ) + path.addSegment(segment, p) + +proc addBezierSegment(path: Path, cp0, cp1, p: Vector2D) = + let segment = PathSegment( + t: SEGMENT_BEZIER, + cp0: cp0, + cp1: cp1 + ) + path.addSegment(segment, p) + +# Goes from start tangent point to end tangent point +proc addArcSegment(path: Path, o, etan: Vector2D, r: float64, ia: bool) = + let segment = PathSegment( + t: SEGMENT_ARC, + oa: o, + r: r, + ia: ia + ) + path.addSegment(segment, etan) + +proc addEllipseSegment(path: Path, o, etan: Vector2D, rx, ry: float64) = + #TODO simplify to bezier? + let segment = PathSegment( + t: SEGMENT_ELLIPSE, + oe: o, + rx: rx, + ry: ry + ) + path.addSegment(segment, etan) + +# https://hcklbrrfnn.files.wordpress.com/2012/08/bez.pdf +func flatEnough(a, b, c: Vector2D): bool = + let ux = 3 * c.x - 2 * a.x - b.x + let uy = 3 * c.y - 2 * a.y - b.y + let vx = 3 * c.x - 2 * b.x - b.x + let vy = 3 * c.y - 2 * b.y - b.y + return max(ux * ux, vx * vx) + max(uy * uy, vy * vy) <= 0.02 + +func flatEnough(a, b, c0, c1: Vector2D): bool = + let ux = 3 * c0.x - 2 * a.x - b.x + let uy = 3 * c0.y - 2 * a.y - b.y + let vx = 3 * c1.x - a.x - 2 * b.x + let vy = 3 * c1.y - a.y - 2 * b.y + return max(ux * ux, vx * vx) + max(uy * uy, vy * vy) <= 0.02 + +iterator items*(pl: PathLines): LineSegment {.inline.} = + for line in pl.lines: + yield line + +func `[]`*(pl: PathLines, i: int): LineSegment = pl.lines[i] +func `[]`*(pl: PathLines, i: BackwardsIndex): LineSegment = pl.lines[i] +func `[]`*(pl: PathLines, s: Slice[int]): seq[LineSegment] = pl.lines[s] +func len*(pl: PathLines): int = pl.lines.len + +iterator quadraticLines(a, b, c: Vector2D): Line {.inline.} = + var points: Deque[tuple[a, b, c: Vector2D]] + let tup = (a, b, c) + points.addFirst(tup) + while points.len > 2: + let (a, b, c) = points.popFirst() + if flatEnough(a, b, c): + yield Line(p0: a, p1: b) + else: + let mid1 = (c + a) / 2 + let mid2 = (c + b) / 2 + let s = (mid1 + mid2) / 2 + points.addFirst((a, s, mid1)) + points.addFirst((s, b, mid2)) + +iterator bezierLines(p0, p1, c0, c1: Vector2D): Line {.inline.} = + var points: Deque[tuple[p0, p1, c0, c1: Vector2D]] + let tup = (p0, p1, c0, c1) + points.addLast(tup) + while points.len > 0: + let (p0, p1, c0, c1) = points.popFirst() + if flatEnough(p0, p1, c0, c1): + yield Line(p0: p0, p1: p1) + else: + let mida1 = (p0 + c0) / 2 + let mida2 = (c0 + c1) / 2 + let mida3 = (c1 + p1) / 2 + let midb1 = (mida1 + mida2) / 2 + let midb2 = (mida2 + mida3) / 2 + let midc = (midb1 + midb2) / 2 + points.addLast((p0, midc, mida1, midb1)) + points.addLast((midc, p1, midb2, mida3)) + +# https://stackoverflow.com/a/44829356 +func arcControlPoints(p1, p4, o: Vector2D): tuple[c0, c1: Vector2D] = + let a = p1 - o + let b = p4 - o + let q1 = a.x * a.x + a.y * a.y + let q2 = q1 + a.x * b.x + a.y * b.y + let k2 = (4 / 3) * (sqrt(2 * q1 * q2) - q2) / a.cross(b) + let c0 = o + a + Vector2D(x: -k2 * a.y, y: k2 * a.x) + let c1 = o + b + Vector2D(x: k2 * b.y, y: -k2 * b.x) + return (c0, c1) + +iterator arcLines(p0, p1, o: Vector2D, r: float64, i: bool): Line {.inline.} = + var p0 = p0 + let pp0 = p0 - o + let pp1 = p1 - o + var theta = pp0.innerAngle(pp1) + if not i: + theta = PI * 2 - theta + while theta > 0: + let step = if theta > PI / 2: PI / 2 else: theta + var p1 = p0 - o + p1 = p1.rotate(step) + p1 += o + let (c0, c1) = arcControlPoints(p0, p1, o) + for line in bezierLines(p0, p1, c0, c1): + yield line + p0 = p1 + theta -= step + +# From SerenityOS +iterator ellipseLines(p0, p1, o: Vector2D, rx, ry, theta_1, rotx, + theta_delta: float64): Line {.inline.} = + if rx > 0 and ry > 0: + var s = p0 + var e = p1 + var theta_1 = theta_1 + var theta_delta = theta_delta + if theta_delta < 0: + swap(s, e) + theta_1 += theta_delta + theta_delta = abs(theta_delta) + # The segments are at most 1 long + let step = arctan2(1f64, max(rx, ry)) + var current_point = s - o + var next_point = Vector2D() + var theta = theta_1 + while theta <= theta_1 + theta_delta: + next_point.x = rx * cos(theta) + next_point.y = ry * sin(theta) + next_point = next_point.rotate(rotx) + yield Line(p0: current_point + o, p1: next_point + o) + current_point = next_point + theta += step + yield Line(p0: current_point + o, p1: e) + +iterator lines(subpath: Subpath, i: int): Line {.inline.} = + let p0 = subpath.points[i] + let p1 = subpath.points[i + 1] + case subpath.segments[i].t + of SEGMENT_STRAIGHT: + yield Line(p0: p0, p1: p1) + of SEGMENT_QUADRATIC: + let c = subpath.segments[i].cp + for line in quadraticLines(p0, p1, c): + yield line + of SEGMENT_BEZIER: + let c0 = subpath.segments[i].cp0 + let c1 = subpath.segments[i].cp1 + for line in bezierLines(p0, p1, c0, c1): + yield line + of SEGMENT_ARC: + let o = subpath.segments[i].oa + let r = subpath.segments[i].r + let i = subpath.segments[i].ia + for line in arcLines(p0, p1, o, r, i): + yield line + of SEGMENT_ELLIPSE: + discard #TODO + +iterator lines*(path: Path): Line {.inline.} = + for subpath in path.subpaths: + assert subpath.points.len == subpath.segments.len + 1 + for i in 0 ..< subpath.segments.len: + for line in subpath.lines(i): + if line.p0 == line.p1: + continue + yield line + +proc getLineSegments*(path: Path): PathLines = + if path.subpaths.len == 0: + return + var miny = Inf + var maxy = -Inf + var segments: seq[LineSegment] + for line in path.lines: + let ls = LineSegment(line) + miny = min(miny, ls.miny) + maxy = max(maxy, ls.maxy) + segments.add(ls) + segments.sort(cmpLineSegmentY) + return PathLines( + miny: miny, + maxy: maxy, + lines: segments + ) + +proc moveTo(path: Path, v: Vector2D) = + path.addSubpathAt(v) + path.needsNewSubpath = false #TODO TODO TODO ???? why here + +proc beginPath*(path: Path) = + path.subpaths.setLen(0) + +proc moveTo*(path: Path, x, y: float64) = + for v in [x, y]: + if classify(v) in {fcInf, fcNegInf, fcNan}: + return + path.moveTo(Vector2D(x: x, y: y)) + +proc ensureSubpath(path: Path, x, y: float64) = + if path.needsNewSubpath: + path.moveTo(x, y) + path.needsNewSubpath = false + +proc closePath*(path: Path) = + let lsp = path.subpaths[^1] + if path.subpaths.len > 0 and (lsp.points.len > 0 or lsp.closed): + path.subpaths[^1].closed = true + path.addSubpathAt(path.subpaths[^1].points[0]) + +#TODO this is a hack, and breaks as soon as any draw command is issued +# between tempClosePath and tempOpenPath +proc tempClosePath*(path: Path) = + if path.subpaths.len > 0 and not path.subpaths[^1].closed: + path.subpaths[^1].closed = true + let lsp = path.subpaths[^1] + path.addSubpathAt(lsp.points[^1]) + path.addStraightSegment(lsp.points[0]) + path.tempClosed = true + +proc tempOpenPath*(path: Path) = + if path.tempClosed: + path.subpaths.setLen(path.subpaths.len - 1) + path.subpaths[^1].closed = false + path.tempClosed = false + +proc lineTo*(path: Path, x, y: float64) = + for v in [x, y]: + if classify(v) in {fcInf, fcNegInf, fcNan}: + return + if path.subpaths.len == 0: + path.ensureSubpath(x, y) + else: + path.addStraightSegment(Vector2D(x: x, y: y)) + +proc quadraticCurveTo*(path: Path, cpx, cpy, x, y: float64) = + for v in [cpx, cpy, x, y]: + if classify(v) in {fcInf, fcNegInf, fcNan}: + return + path.ensureSubpath(cpx, cpy) + let cp = Vector2D(x: cpx, y: cpy) + let p = Vector2D(x: x, y: y) + path.addQuadraticSegment(cp, p) + +proc bezierCurveTo*(path: Path, cp0x, cp0y, cp1x, cp1y, x, y: float64) = + for v in [cp0x, cp0y, cp1x, cp1y, x, y]: + if classify(v) in {fcInf, fcNegInf, fcNan}: + return + path.ensureSubpath(cp0x, cp0y) + let cp0 = Vector2D(x: cp0x, y: cp0y) + let cp1 = Vector2D(x: cp1x, y: cp1y) + let p = Vector2D(x: x, y: y) + path.addBezierSegment(cp0, cp1, p) + +proc arcTo*(path: Path, x1, y1, x2, y2, radius: float64): bool = + for v in [x1, y1, x2, y2, radius]: + if classify(v) in {fcInf, fcNegInf, fcNan}: + return + if radius < 0: + return false + path.ensureSubpath(x1, y1) + #TODO this should be transformed by the inverse of the transformation matrix + let v0 = path.subpaths[^1].points[^1] + let v1 = Vector2D(x: x1, y: y1) + let v2 = Vector2D(x: x2, y: y2) + if v0.x == x1 and v0.y == y1 or x1 == x2 and y1 == y2 or radius == 0: + path.addStraightSegment(v1) + elif collinear(v0, v1, v2): + path.addStraightSegment(v1) + else: + let pv0 = v0 - v1 + let pv2 = v2 - v1 + let tv0 = v1 + pv0 * radius * 2 / pv0.norm() + let tv2 = v1 + pv2 * radius * 2 / pv2.norm() + let q = -(pv0.x * tv0.x + pv0.y * tv0.y) + let p = -(pv2.x * tv2.x + pv2.y * tv2.y) + let cr = pv0.cross(pv2) + let origin = Vector2D( + x: (pv0.y * p - pv2.y * q) / cr, + y: (pv2.x * q - pv0.x * p) / cr + ) + path.addStraightSegment(tv0) + path.addArcSegment(origin, tv2, radius, true) #TODO always inner? + return true + +func resolveEllipsePoint(o: Vector2D, angle, radiusX, radiusY, + rotation: float64): Vector2D = + # Stolen from SerenityOS + let tanrel = tan(angle) + let tan2 = tanrel * tanrel + let ab = radiusX * radiusY + let a2 = radiusX * radiusX + let b2 = radiusY * radiusY + let sq = sqrt(b2 + a2 * tan2) + let sn = if cos(angle) >= 0: 1f64 else: -1f64 + let relx = ab / sq * sn + let rely = ab * tanrel / sq * sn + return Vector2D(x: relx, y: rely).rotate(rotation) + o + +proc arc*(path: Path, x, y, radius, startAngle, endAngle: float64, + counterclockwise: bool): bool = + for v in [x, y, radius, startAngle, endAngle]: + if classify(v) in {fcInf, fcNegInf, fcNan}: + return + if radius < 0: + return false + let o = Vector2D(x: x, y: y) + var s = resolveEllipsePoint(o, startAngle, radius, radius, 0) + var e = resolveEllipsePoint(o, endAngle, radius, radius, 0) + if counterclockwise: + let tmp = s + e = s + s = tmp + if path.subpaths.len > 0: + path.addStraightSegment(s) + else: + path.moveTo(s) + path.addArcSegment(o, e, radius, abs(startAngle - endAngle) < PI) + return true + +proc ellipse*(path: Path, x, y, radiusX, radiusY, rotation, startAngle, + endAngle: float64, counterclockwise: bool): bool = + for v in [x, y, radiusX, radiusY, rotation, startAngle, endAngle]: + if classify(v) in {fcInf, fcNegInf, fcNan}: + return + if radiusX < 0 or radiusY < 0: + return false + let o = Vector2D(x: x, y: y) + var s = resolveEllipsePoint(o, startAngle, radiusX, radiusY, rotation) + var e = resolveEllipsePoint(o, endAngle, radiusX, radiusY, rotation) + if counterclockwise: + let tmp = s + e = s + s = tmp + if path.subpaths.len > 0: + path.addStraightSegment(s) + else: + path.moveTo(s) + path.addEllipseSegment(o, e, radiusX, radiusY) + return true + +proc rect*(path: Path, x, y, w, h: float64) = + for v in [x, y, w, h]: + if classify(v) in {fcInf, fcNegInf, fcNan}: + return + path.addSubpathAt(Vector2D(x: x, y: y)) + path.addStraightSegment(Vector2D(x: x + w, y: y)) + path.addStraightSegment(Vector2D(x: x + w, y: y + h)) + path.addStraightSegment(Vector2D(x: x, y: y + h)) + path.addStraightSegment(Vector2D(x: x, y: y)) + path.addSubpathAt(Vector2D(x: x, y: y)) + +proc roundRect*(path: Path, x, y, w, h, radii: float64) = + for v in [x, y, w, h]: + if classify(v) in {fcInf, fcNegInf, fcNan}: + return + #TODO implement + path.rect(x, y, w, h) # :P |