diff --git a/src/pixie/paths.nim b/src/pixie/paths.nim index 9407cdf..f060909 100644 --- a/src/pixie/paths.nim +++ b/src/pixie/paths.nim @@ -188,98 +188,6 @@ proc `$`*(path: Path): string = if i != path.commands.len - 1 or j != command.numbers.len - 1: result.add " " -## See https://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes - -type ArcParams = object - s: float32 - rx, ry: float32 - rotation: float32 - cx, cy: float32 - theta, delta: float32 - -proc svgAngle (ux, uy, vx, vy: float32): float32 = - var u = vec2(ux, uy) - var v = vec2(vx, vy) - # (F.6.5.4) - var dot = dot(u,v) - var len = length(u) * length(v) - var ang = arccos( clamp(dot / len,-1,1) ) # floating point precision, slightly over values appear - if (u.x*v.y - u.y*v.x) < 0: - ang = -ang - return ang - -proc endpointToCenterArcParams( - ax, ay, rx, ry, rotation, large, sweep, bx, by: float32 -): ArcParams = - - var r = vec2(rx, ry) - var p1 = vec2(ax, ay) - var p2 = vec2(bx, by) - var xAngle = rotation/180*PI - var flagA = large == 1.0 - var flagS = sweep == 1.0 - var rX = abs(r.x) - var rY = abs(r.y) - - # (F.6.5.1) - var dx2 = (p1.x - p2.x) / 2.0 - var dy2 = (p1.y - p2.y) / 2.0 - var x1p = cos(xAngle)*dx2 + sin(xAngle)*dy2 - var y1p = -sin(xAngle)*dx2 + cos(xAngle)*dy2 - - # (F.6.5.2) - var rxs = rX * rX - var rys = rY * rY - var x1ps = x1p * x1p - var y1ps = y1p * y1p - # check if the radius is too small `pq < 0`, when `dq > rxs * rys` (see below) - # cr is the ratio (dq : rxs * rys) - var cr = x1ps/rxs + y1ps/rys - var s = 1.0 - if cr > 1: - # scale up rX,rY equally so cr == 1 - s = sqrt(cr) - rX = s * rX - rY = s * rY - rxs = rX * rX - rys = rY * rY - - var dq = (rxs * y1ps + rys * x1ps) - var pq = (rxs*rys - dq) / dq - var q = sqrt(max(0,pq)) # use Max to account for float precision - if flagA == flagS: - q = -q - var cxp = q * rX * y1p / rY - var cyp = - q * rY * x1p / rX - - # (F.6.5.3) - var cx = cos(xAngle)*cxp - sin(xAngle)*cyp + (p1.x + p2.x)/2 - var cy = sin(xAngle)*cxp + cos(xAngle)*cyp + (p1.y + p2.y)/2 - - # (F.6.5.5) - var theta = svgAngle( 1,0, (x1p-cxp) / rX, (y1p - cyp)/rY ) - # (F.6.5.6) - var delta = svgAngle( - (x1p - cxp)/rX, (y1p - cyp)/rY, - (-x1p - cxp)/rX, (-y1p-cyp)/rY) - delta = delta mod (PI * 2) - - if not flagS: - delta -= 2 * PI - - # normalize the delta - while delta > PI*2: - delta -= PI*2 - while delta < -PI*2: - delta += PI*2 - - r = vec2(rX, rY) - - return ArcParams( - s: s, rx: rX, rY: ry, rotation: xAngle, cx: cx, cy: cy, - theta: theta, delta: delta - ) - proc commandsToPolygons*(commands: seq[PathCommand]): seq[seq[Vec2]] = ## Converts SVG-like commands to simpler polygon @@ -325,25 +233,141 @@ proc commandsToPolygons*(commands: seq[PathCommand]): seq[seq[Vec2]] = discretize(1, 1) - proc drawQuad(p0, p1, p2: Vec2) = - let devx = p0.x - 2.0 * p1.x + p2.x - let devy = p0.y - 2.0 * p1.y + p2.y - let devsq = devx * devx + devy * devy - if devsq < 0.333: - drawLine(p0, p2) - return - let tol = 3.0 - let n = 1 + (tol * (devsq)).sqrt().sqrt().floor() - var p = p0 - let nrecip = 1 / n - var t = 0.0 - for i in 0 ..< int(n): - t += nrecip - let pn = lerp(lerp(p0, p1, t), lerp(p1, p2, t), t) - drawLine(p, pn) - p = pn + proc drawQuad(at, ctrl, to: Vec2) = - drawLine(p, p2) + proc compute(at, ctrl, to: Vec2, t: float32): Vec2 {.inline.} = + pow(1 - t, 2) * at + + 2 * (1 - t) * t * ctrl + + pow(t, 2) * to + + var prev = at + + proc discretize(i, steps: int) = + # Closure captures at, ctrl, to and prev + let + tPrev = (i - 1).float32 / steps.float32 + t = i.float32 / steps.float32 + next = compute(at, ctrl, to, t) + halfway = compute(at, ctrl, to, tPrev + (t - tPrev) / 2) + midpoint = (prev + next) / 2 + error = (midpoint - halfway).length + + if error >= 0.25: + # Error too large, double precision for this step + discretize(i * 2 - 1, steps * 2) + discretize(i * 2, steps * 2) + else: + drawLine(prev, next) + prev = next + + discretize(1, 1) + + proc drawArc( + at, radii: Vec2, + rotation: float32, + large, sweep: bool, + to: Vec2 + ) = + type ArcParams = object + radii: Vec2 + rotMat: Mat3 + center: Vec2 + theta, delta: float32 + + proc endpointToCenterArcParams( + at, radii: Vec2, rotation: float32, large, sweep: bool, to: Vec2 + ): ArcParams = + var + radii = vec2(abs(radii.x), abs(radii.y)) + radiiSq = vec2(radii.x * radii.x, radii.y * radii.y) + + let + radians = rotation / 180 * PI + d = vec2((at.x - to.x) / 2.0, (at.y - to.y) / 2.0) + p = vec2( + cos(radians) * d.x + sin(radians) * d.y, + -sin(radians) * d.x + cos(radians) * d.y + ) + pSq = vec2(p.x * p.x, p.y * p.y) + + let cr = pSq.x / radiiSq.x + pSq.y / radiiSq.y + if cr > 1: + radii *= sqrt(cr) + radiiSq = vec2(radii.x * radii.x, radii.y * radii.y) + + let + dq = radiiSq.x * pSq.y + radiiSq.y * pSq.x + pq = (radiiSq.x * radiiSq.y - dq) / dq + + var q = sqrt(max(0, pq)) + if large == sweep: + q = -q + + proc svgAngle(u, v: Vec2): float32 = + let + dot = dot(u,v) + len = length(u) * length(v) + result = arccos(clamp(dot / len, -1, 1)) + if (u.x * v.y - u.y * v.x) < 0: + result = -result + + let + cp = vec2(q * radii.x * p.y / radii.y, -q * radii.y * p.x / radii.x) + center = vec2( + cos(radians) * cp.x - sin(radians) * cp.y + (at.x + to.x) / 2, + sin(radians) * cp.x + cos(radians) * cp.y + (at.y + to.y) / 2 + ) + theta = svgAngle(vec2(1, 0), vec2((p.x-cp.x) / radii.x, (p.y - cp.y) / radii.y)) + + var delta = svgAngle( + vec2((p.x - cp.x) / radii.x, (p.y - cp.y) / radii.y), + vec2((-p.x - cp.x) / radii.x, (-p.y - cp.y) / radii.y) + ) + delta = delta mod (PI * 2) + + if not sweep: + delta -= 2 * PI + + # Normalize the delta + while delta > PI * 2: + delta -= PI * 2 + while delta < -PI * 2: + delta += PI * 2 + + ArcParams( + radii: radii, + rotMat: rotationMat3(-radians), + center: center, + theta: theta, + delta: delta + ) + + proc compute(arc: ArcParams, a: float32): Vec2 = + result = vec2(cos(a) * arc.radii.x, sin(a) * arc.radii.y) + result = arc.rotMat * result + arc.center + + var prev = at + + proc discretize(arc: ArcParams, i, steps: int) = + let + step = arc.delta / steps.float32 + aPrev = arc.theta + step * (i - 1).float32 + a = arc.theta + step * i.float32 + next = arc.compute(a) + halfway = arc.compute(aPrev + (a - aPrev) / 2) + midpoint = (prev + next) / 2 + error = (midpoint - halfway).length + + if error >= 0.25: + # Error too large, try again with doubled precision + discretize(arc, i * 2 - 1, steps * 2) + discretize(arc, i * 2, steps * 2) + else: + drawLine(prev, next) + prev = next + + let arc = endpointToCenterArcParams(at, radii, rotation, large, sweep, to) + discretize(arc, 1, 1) for command in commands: case command.kind @@ -409,34 +433,20 @@ proc commandsToPolygons*(commands: seq[PathCommand]): seq[seq[Vec2]] = at = to of Arc: - var arc = endpointToCenterArcParams( - at.x, - at.y, - command.numbers[0], - command.numbers[1], - command.numbers[2], - command.numbers[3], - command.numbers[4], - command.numbers[5], - command.numbers[6], - ) - let steps = int(abs(arc.delta)/PI*180/5) - let step = arc.delta / steps.float32 - var a = arc.theta - var rotMat = rotationMat3(-arc.rotation) - for i in 0 .. steps: - polygon.add(rotMat * vec2( - cos(a)*arc.rx, - sin(a)*arc.ry) + vec2(arc.cx, arc.cy) - ) - a += step - at = polygon[^1] + let + radii = vec2(command.numbers[0], command.numbers[1]) + rotation = command.numbers[2] + large = command.numbers[3] == 1 + sweep = command.numbers[4] == 1 + to = vec2(command.numbers[5], command.numbers[6]) + drawArc(at, radii, rotation, large, sweep, to) + at = to of Close: assert command.numbers.len == 0 if at != start: if prevCommand == Quad or prevCommand == TQuad: - drawQuad(at, ctr, start) + drawQuad(at, ctr, start) else: drawLine(at, start) if polygon.len > 0: @@ -865,56 +875,52 @@ proc arc*(path: Path) = proc arcTo*(path: Path, x1, y1, x2, y2, r: float32) = ## Adds a circular arc to the path with the given control points and radius, connected to the previous point by a straight line. - var - x0 = path.at.x - y0 = path.at.y - x21 = x2 - x1 - y21 = y2 - y1 - x01 = x0 - x1 - y01 = y0 - y1 - l01_2 = x01 * x01 + y01 * y01 + const epsilon = 1e-6.float32 - const epsilon: float32 = 1e-6 + if path.commands.len == 0: + # Is this path empty? Move to (x1,y1). + path.moveTo(x1, y1) var r = r if r < 0: # Is the radius negative? Flip it. r = -r - if path.commands.len == 0: - # Is this path empty? Move to (x1,y1). - path.commands.add(PathCommand(kind: Move, numbers: @[x1,y1])) + var + x21 = x2 - x1 + y21 = y2 - y1 + x01 = path.at.x - x1 + y01 = path.at.y - y1 + l01_2 = x01 * x01 + y01 * y01 - elif not (l01_2 > epsilon): - # Or, is (x1,y1) coincident with (x0,y0)? Do nothing. + if not (l01_2 > epsilon): + # Is (x1,y1) coincident with (x0,y0)? Do nothing. discard elif not (abs(y01 * x21 - y21 * x01) > epsilon) or r == 0: - # // Or, are (x0,y0), (x1,y1) and (x2,y2) collinear? + # // Or, are (x0,y0), (x1,y1) and (x2,y2) colinear? # // Equivalently, is (x1,y1) coincident with (x2,y2)? # // Or, is the radius zero? Line to (x1,y1). - path.commands.add(PathCommand(kind: Line, numbers: @[x1, y1])) - path.at.x = x1 - path.at.y = y1 + path.lineTo(x1, y1) else: - # Otherwise, draw an arc! + # Draw an arc var - x20 = x2 - x0 - y20 = y2 - y0 + x20 = x2 - path.at.x + y20 = y2 - path.at.y l21_2 = x21 * x21 + y21 * y21 l20_2 = x20 * x20 + y20 * y20 l21 = sqrt(l21_2) l01 = sqrt(l01_2) - l:float32 = r * tan((PI - arccos((l21_2 + l01_2 - l20_2) / (2 * l21 * l01))) / 2) + l = r * tan((PI - arccos((l21_2 + l01_2 - l20_2) / (2 * l21 * l01))) / 2).float32 t01 = l / l01 t21 = l / l21 - # If the start tangent is not coincident with (x0,y0), line to. + # If the start tangent is not coincident with path.at, line to. if abs(t01 - 1) > epsilon: - path.commands.add(PathCommand(kind: Line, numbers: @[x1 + t01 * x01, y1 + t01 * y01])) - discard + path.lineTo(x1 + t01 * x01, y1 + t01 * y01) + path.at.x = x1 + t21 * x21 path.at.y = y1 + t21 * y21 path.commands.add(PathCommand( diff --git a/tests/images/paths/pathBlackRectangleZ.png b/tests/images/paths/pathBlackRectangleZ.png new file mode 100644 index 0000000..fb9d80e Binary files /dev/null and b/tests/images/paths/pathBlackRectangleZ.png differ diff --git a/tests/images/paths/pathBottomArc.png b/tests/images/paths/pathBottomArc.png index f535a0e..7b96411 100644 Binary files a/tests/images/paths/pathBottomArc.png and b/tests/images/paths/pathBottomArc.png differ diff --git a/tests/images/paths/pathCornerArc.png b/tests/images/paths/pathCornerArc.png index 26f348f..9e0a56a 100644 Binary files a/tests/images/paths/pathCornerArc.png and b/tests/images/paths/pathCornerArc.png differ diff --git a/tests/images/paths/pathHeart.png b/tests/images/paths/pathHeart.png index 6c4c9ad..60bcb67 100644 Binary files a/tests/images/paths/pathHeart.png and b/tests/images/paths/pathHeart.png differ diff --git a/tests/images/paths/pathInvertedCornerArc.png b/tests/images/paths/pathInvertedCornerArc.png index 50691b0..bad9372 100644 Binary files a/tests/images/paths/pathInvertedCornerArc.png and b/tests/images/paths/pathInvertedCornerArc.png differ diff --git a/tests/images/paths/pathRotatedArc.png b/tests/images/paths/pathRotatedArc.png index a4d3a32..f197060 100644 Binary files a/tests/images/paths/pathRotatedArc.png and b/tests/images/paths/pathRotatedArc.png differ diff --git a/tests/images/paths/pathRoundRect.png b/tests/images/paths/pathRoundRect.png index 360e0fc..1dc2a87 100644 Binary files a/tests/images/paths/pathRoundRect.png and b/tests/images/paths/pathRoundRect.png differ diff --git a/tests/images/svg/quad01.png b/tests/images/svg/quad01.png index 452a9e2..63e9998 100644 Binary files a/tests/images/svg/quad01.png and b/tests/images/svg/quad01.png differ diff --git a/tests/test_paths.nim b/tests/test_paths.nim index 51d86a6..1cb9e78 100644 --- a/tests/test_paths.nim +++ b/tests/test_paths.nim @@ -37,10 +37,10 @@ block: let path = parsePath(pathStr) doAssert $path == "M1 2 L3 4 H5 V6 C0 0 0 0 0 0 Q1 1 1 1 T2 2 A7 7 7 7 7 7 7 Z" - block: - let pathStr = "M 0.1E-10 0.1e10 L2+2 L3-3 L0.1E+10-1" - let path = parsePath(pathStr) + let + pathStr = "M 0.1E-10 0.1e10 L2+2 L3-3 L0.1E+10-1" + path = parsePath(pathStr) block: let image = newImage(100, 100) @@ -72,6 +72,14 @@ block: image.fillPath(pathStr, color) image.writeFile("tests/images/paths/pathBlackRectangle.png") +block: + let + image = newImage(100, 100) + pathStr = "M 10 10 H 90 V 90 H 10 Z" + color = rgba(0, 0, 0, 255) + image.fillPath(parsePath(pathStr), color) + image.writeFile("tests/images/paths/pathBlackRectangleZ.png") + block: let image = newImage(100, 100) image.fillPath(