From f57176cb70c4851cb707ad5a6b79e6b435e2ee70 Mon Sep 17 00:00:00 2001 From: Jason Jobe Date: Mon, 21 Sep 2026 13:36:59 -0400 Subject: [PATCH 1/5] Add tapered-stroke commands (stage 1: model + wire format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces pen-width taper as a *primitive* rather than as sugar over many .penWidth + .forward pairs: two additive command cases, .taperedForward(distance:widthTo:) and .taperedArc(radius:extent:widthTo:), each recording one command and so occupying one playback frame. This is the property the design exists for. An expansion-based taper buys width fidelity with animation time, because CommandPlayer emits a frame per command and stepDuration is distance-independent — so forward(280, widthTo: 12) from width 1 costs 89 commands, 44 SVG elements and 8.9 s of animation. As a single command it costs one frame and one element, and has no interior round caps to darken at alpha < 1. - Stroke and ArcStroke gain `endWidth` (defaulting to `width`) plus `isTapered`. CommandPlayer reads endWidth back from the post-command state, so the clamp in TortoiseState.applying is the only place a negative width is handled. - The state reducer moves the tortoise exactly as the untapered command would and sets penWidth to the requested end width. - Public API is forward(_:widthTo:), backward(_:widthTo:) and circle(radius:extent:widthTo:) — no `steps:` parameter and no tuning constants, since there is nothing to subdivide. Renderers do not yet honour `endWidth`: a tapered stroke currently draws at its start width, with correct geometry. Filling the outline is stage 2 (shared geometry in Core) and stages 3-4 (SVG, canvas). Wire format: both cases are additive, so pre-taper streams still decode unchanged; tests pin the new JSON and cover payload-field tolerance. CommandSerialization.md updated per the checklist in Codable.swift. Co-Authored-By: Claude Opus 5 --- Sources/TortoiseCore/ArcStroke.swift | 21 +++ Sources/TortoiseCore/Codable.swift | 26 +++ Sources/TortoiseCore/CommandPlayer.swift | 18 +- .../CommandSerialization.md | 2 + Sources/TortoiseCore/Stroke.swift | 27 ++- Sources/TortoiseCore/Tortoise.swift | 35 ++++ Sources/TortoiseCore/TortoiseCommand.swift | 15 ++ Sources/TortoiseCore/TortoiseState.swift | 9 + Tests/TortoiseCoreTests/CodableTests.swift | 45 +++++ .../TortoiseCoreTests/TortoiseCoreTests.swift | 168 +++++++++++++++++- 10 files changed, 362 insertions(+), 4 deletions(-) diff --git a/Sources/TortoiseCore/ArcStroke.swift b/Sources/TortoiseCore/ArcStroke.swift index 8d265bb..62b2c55 100644 --- a/Sources/TortoiseCore/ArcStroke.swift +++ b/Sources/TortoiseCore/ArcStroke.swift @@ -11,5 +11,26 @@ public struct ArcStroke: Sendable, Equatable { /// Sweep angle in degrees (positive = CCW in tortoise space). public let sweep: Double public let color: Color + /// Pen width at the start of the sweep. public let width: Double + /// Pen width at the end of the sweep. + public let endWidth: Double + + /// Creates an arc stroke. `endWidth` defaults to `width`, giving the + /// constant-width arc that ``TortoiseCommand/arc(radius:extent:)`` produces. + public init( + center: Point, radius: Double, startAngle: Double, sweep: Double, + color: Color, width: Double, endWidth: Double? = nil + ) { + self.center = center + self.radius = radius + self.startAngle = startAngle + self.sweep = sweep + self.color = color + self.width = width + self.endWidth = endWidth ?? width + } + + /// Whether the pen width changes across this arc. + public var isTapered: Bool { endWidth != width } } diff --git a/Sources/TortoiseCore/Codable.swift b/Sources/TortoiseCore/Codable.swift index b988549..f9635ed 100644 --- a/Sources/TortoiseCore/Codable.swift +++ b/Sources/TortoiseCore/Codable.swift @@ -97,6 +97,7 @@ extension TortoiseCommand: Codable { /// are the frozen-format contract. private enum CodingKeys: String, CodingKey { case forward + case taperedForward case rotate case home case setPosition @@ -114,6 +115,7 @@ extension TortoiseCommand: Codable { case backgroundColor case clear case arc + case taperedArc case dot } @@ -126,6 +128,7 @@ extension TortoiseCommand: Codable { case radius case extent case size + case widthTo } /// Accepts any key. Used to count the raw keys of a command object: @@ -160,6 +163,12 @@ extension TortoiseCommand: Codable { switch key { case .forward: self = .forward(try payload().decode(Double.self, forKey: .distance)) + case .taperedForward: + let taperPayload = try payload() + self = .taperedForward( + distance: try taperPayload.decode(Double.self, forKey: .distance), + widthTo: try taperPayload.decode(Double.self, forKey: .widthTo) + ) case .rotate: self = .rotate(try payload().decode(Double.self, forKey: .degrees)) case .home: @@ -198,6 +207,13 @@ extension TortoiseCommand: Codable { radius: try arcPayload.decode(Double.self, forKey: .radius), extent: try arcPayload.decode(Double.self, forKey: .extent) ) + case .taperedArc: + let taperPayload = try payload() + self = .taperedArc( + radius: try taperPayload.decode(Double.self, forKey: .radius), + extent: try taperPayload.decode(Double.self, forKey: .extent), + widthTo: try taperPayload.decode(Double.self, forKey: .widthTo) + ) case .dot: self = .dot(try payload().decode(Double.self, forKey: .size)) } @@ -217,6 +233,11 @@ extension TortoiseCommand: Codable { switch self { case .forward(let distance): try encodeScalar(distance, .distance, forKey: .forward) + case .taperedForward(let distance, let widthTo): + var payload = container.nestedContainer( + keyedBy: PayloadKeys.self, forKey: .taperedForward) + try payload.encode(distance, forKey: .distance) + try payload.encode(widthTo, forKey: .widthTo) case .rotate(let degrees): try encodeScalar(degrees, .degrees, forKey: .rotate) case .home: @@ -253,6 +274,11 @@ extension TortoiseCommand: Codable { var payload = container.nestedContainer(keyedBy: PayloadKeys.self, forKey: .arc) try payload.encode(radius, forKey: .radius) try payload.encode(extent, forKey: .extent) + case .taperedArc(let radius, let extent, let widthTo): + var payload = container.nestedContainer(keyedBy: PayloadKeys.self, forKey: .taperedArc) + try payload.encode(radius, forKey: .radius) + try payload.encode(extent, forKey: .extent) + try payload.encode(widthTo, forKey: .widthTo) case .dot(let size): try encodeScalar(size, .size, forKey: .dot) } diff --git a/Sources/TortoiseCore/CommandPlayer.swift b/Sources/TortoiseCore/CommandPlayer.swift index b50a552..d7a46e9 100644 --- a/Sources/TortoiseCore/CommandPlayer.swift +++ b/Sources/TortoiseCore/CommandPlayer.swift @@ -40,7 +40,20 @@ public enum CommandPlayer { } fillPoints?.append(tortoise.position) - case .arc(let radius, let extent): + case .taperedForward: + if before.isPenDown { + // `endWidth` is read back from the post-command state rather + // than from the payload, so the clamp in `applying(_:)` is + // the only place a negative width is handled. + newStroke = Stroke( + from: before.position, to: tortoise.position, + color: before.penColor, width: before.penWidth, + endWidth: tortoise.penWidth + ) + } + fillPoints?.append(tortoise.position) + + case .arc(let radius, let extent), .taperedArc(let radius, let extent, _): if before.isPenDown { let center = Tortoise.arcCenter( position: before.position, heading: before.heading, radius: radius) @@ -54,7 +67,8 @@ public enum CommandPlayer { startAngle: atan2(dy, dx) * (180 / .pi), sweep: radius < 0 ? -extent : extent, color: before.penColor, - width: before.penWidth + width: before.penWidth, + endWidth: tortoise.penWidth ) } fillPoints?.append(tortoise.position) diff --git a/Sources/TortoiseCore/Documentation.docc/CommandSerialization.md b/Sources/TortoiseCore/Documentation.docc/CommandSerialization.md index 98db910..584e86e 100644 --- a/Sources/TortoiseCore/Documentation.docc/CommandSerialization.md +++ b/Sources/TortoiseCore/Documentation.docc/CommandSerialization.md @@ -33,6 +33,7 @@ A command encodes as a JSON object with exactly one key, the command name: | Command | JSON | | --- | --- | | `forward(100)` | `{"forward":{"distance":100}}` | +| `forward(200, widthTo: 12)` | `{"taperedForward":{"distance":200,"widthTo":12}}` | | `rotate(-45.5)` | `{"rotate":{"degrees":-45.5}}` | | `home` | `{"home":{}}` | | `setPosition(Point(x: 10, y: 20))` | `{"setPosition":{"x":10,"y":20}}` | @@ -47,6 +48,7 @@ A command encodes as a JSON object with exactly one key, the command name: | `backgroundColor(.black)` | `{"backgroundColor":{"red":0,"green":0,"blue":0,"alpha":1}}` | | `clear` | `{"clear":{}}` | | `circle(radius: -50, extent: 180)` | `{"arc":{"radius":-50,"extent":180}}` | +| `circle(radius: 70, extent: 270, widthTo: 10)` | `{"taperedArc":{"radius":70,"extent":270,"widthTo":10}}` | | `dot(8)` | `{"dot":{"size":8}}` | On decode, a command object must contain **exactly one key, and it must be diff --git a/Sources/TortoiseCore/Stroke.swift b/Sources/TortoiseCore/Stroke.swift index 7a909c1..ef8ff33 100644 --- a/Sources/TortoiseCore/Stroke.swift +++ b/Sources/TortoiseCore/Stroke.swift @@ -1,7 +1,32 @@ -/// A line segment drawn by the tortoise. +/// A straight pen stroke between two points. +/// +/// ``width`` is the pen width at ``from`` and ``endWidth`` the width at ``to``. +/// They are equal for an ordinary stroke; when they differ the stroke is +/// *tapered* and renderers fill its outline rather than stroking a line at a +/// single width. public struct Stroke: Sendable, Equatable { public let from: Point public let to: Point public let color: Color + /// Pen width at ``from``. public let width: Double + /// Pen width at ``to``. + public let endWidth: Double + + /// Creates a stroke. `endWidth` defaults to `width`, giving the + /// constant-width stroke that every non-tapered command produces. + public init(from: Point, to: Point, color: Color, width: Double, endWidth: Double? = nil) { + self.from = from + self.to = to + self.color = color + self.width = width + self.endWidth = endWidth ?? width + } + + /// Whether the pen width changes across this stroke. + /// + /// Renderers use this to choose between stroking a line and filling an + /// outline; it is also why a tapered stroke can never join a same-width + /// batch. + public var isTapered: Bool { endWidth != width } } diff --git a/Sources/TortoiseCore/Tortoise.swift b/Sources/TortoiseCore/Tortoise.swift index 3b49e80..ecc42dc 100644 --- a/Sources/TortoiseCore/Tortoise.swift +++ b/Sources/TortoiseCore/Tortoise.swift @@ -115,6 +115,30 @@ public final class Tortoise { forward(-distance) } + /// Move forward by `distance` pixels while ramping the pen width from its + /// current value to `endWidth`. + /// + /// The stroke thickens or thins as the tortoise lays it down. This is a + /// single command, so it animates in the same time as a plain + /// ``forward(_:)`` of the same distance and serializes as one entry; + /// renderers fill the stroke's outline instead of stroking it at one width. + /// + /// After the call ``penWidth`` is `endWidth` (clamped to `>= 0`). + /// + /// - Parameters: + /// - distance: Distance to travel (negative = backward). + /// - endWidth: Pen width at the end of the move. + public func forward(_ distance: Double, widthTo endWidth: Double) { + record(.taperedForward(distance: distance, widthTo: endWidth)) + } + + /// Move backward by `distance` pixels while ramping the pen width to `endWidth`. + /// + /// See ``forward(_:widthTo:)``. + public func backward(_ distance: Double, widthTo endWidth: Double) { + forward(-distance, widthTo: endWidth) + } + /// Rotate clockwise by `degrees`. public func right(_ degrees: Double) { record(.rotate(degrees)) @@ -198,6 +222,17 @@ public final class Tortoise { record(.arc(radius: radius, extent: extent)) } + /// Draw a circular arc while ramping the pen width from its current value + /// to `endWidth`. + /// + /// Geometry is identical to ``circle(radius:extent:)``; only the pen width + /// differs. Like ``forward(_:widthTo:)`` this is a single command. + /// + /// After the call ``penWidth`` is `endWidth` (clamped to `>= 0`). + public func circle(radius: Double, extent: Double = 360, widthTo endWidth: Double) { + record(.taperedArc(radius: radius, extent: extent, widthTo: endWidth)) + } + // MARK: - Pen public func penDown() { diff --git a/Sources/TortoiseCore/TortoiseCommand.swift b/Sources/TortoiseCore/TortoiseCommand.swift index a1e72f4..5c0a4be 100644 --- a/Sources/TortoiseCore/TortoiseCommand.swift +++ b/Sources/TortoiseCore/TortoiseCommand.swift @@ -10,6 +10,15 @@ public enum TortoiseCommand: Sendable, Equatable { // MARK: Movement /// Move forward (positive) or backward (negative) by `distance` pixels. case forward(Double) + /// Move forward by `distance` pixels while ramping the pen width from its + /// current value to `widthTo`. + /// + /// One command, so it occupies a single playback frame and animates in the + /// same time as a plain ``forward(_:)`` of the same distance. The ramp is a + /// property of the resulting ``Stroke``, which renderers fill as an outline + /// rather than stroking at a single width. After this command ``penWidth`` + /// is `widthTo`. + case taperedForward(distance: Double, widthTo: Double) /// Rotate clockwise (positive) or counterclockwise (negative) by `degrees`. case rotate(Double) /// Move to the origin (0, 0) and reset heading to 0 (north). @@ -50,6 +59,12 @@ public enum TortoiseCommand: Sendable, Equatable { /// A negative `radius` mirrors the arc (center on the tortoise's right, /// sweep directions flipped), matching Python turtle. case arc(radius: Double, extent: Double) + /// Draw a circular arc while ramping the pen width to `widthTo`. + /// + /// Geometry matches ``arc(radius:extent:)`` exactly; only the pen width + /// differs. Like ``taperedForward(distance:widthTo:)`` this is a single + /// command and a single frame. After it ``penWidth`` is `widthTo`. + case taperedArc(radius: Double, extent: Double, widthTo: Double) // MARK: Dot /// Draw a filled circle at the current position without moving the tortoise. diff --git a/Sources/TortoiseCore/TortoiseState.swift b/Sources/TortoiseCore/TortoiseState.swift index 8257de6..c4a233f 100644 --- a/Sources/TortoiseCore/TortoiseState.swift +++ b/Sources/TortoiseCore/TortoiseState.swift @@ -78,6 +78,9 @@ extension TortoiseState { switch command { case .forward(let distance): state.position = position.moved(distance: distance, heading: heading) + case .taperedForward(let distance, let widthTo): + state.position = position.moved(distance: distance, heading: heading) + state.penWidth = max(0, widthTo) case .rotate(let degrees): state.heading = Self.normalizedHeading(heading + degrees) case .home: @@ -108,6 +111,12 @@ extension TortoiseState { position: position, heading: heading, radius: radius, extent: extent) state.position = end.position state.heading = Self.normalizedHeading(end.heading) + case .taperedArc(let radius, let extent, let widthTo): + let end = Tortoise.arcEndState( + position: position, heading: heading, radius: radius, extent: extent) + state.position = end.position + state.heading = Self.normalizedHeading(end.heading) + state.penWidth = max(0, widthTo) case .beginFill, .endFill, .backgroundColor, .clear, .dot: break } diff --git a/Tests/TortoiseCoreTests/CodableTests.swift b/Tests/TortoiseCoreTests/CodableTests.swift index 30f9b72..00af3bd 100644 --- a/Tests/TortoiseCoreTests/CodableTests.swift +++ b/Tests/TortoiseCoreTests/CodableTests.swift @@ -38,6 +38,14 @@ struct CodableTests { ), (.clear, #"{"clear":{}}"#), (.arc(radius: -50, extent: 180), #"{"arc":{"extent":180,"radius":-50}}"#), + ( + .taperedForward(distance: 200, widthTo: 12), + #"{"taperedForward":{"distance":200,"widthTo":12}}"# + ), + ( + .taperedArc(radius: 70, extent: 270, widthTo: 10), + #"{"taperedArc":{"extent":270,"radius":70,"widthTo":10}}"# + ), (.dot(8), #"{"dot":{"size":8}}"#), ] @@ -144,3 +152,40 @@ struct CodableTests { #expect(color == Color(red: 0.2, green: 0.4, blue: 0.6, alpha: 1)) } } + +// MARK: - Backward compatibility + +@Suite("Wire-format compatibility") +struct WireFormatCompatibilityTests { + /// Adding a command case must not disturb existing data: a stream recorded + /// before the tapered cases existed still decodes unchanged. + @Test("a pre-taper stream still decodes") + func preTaperStreamDecodes() throws { + let json = """ + [{"penWidth":{"width":1}},{"forward":{"distance":100}},\ + {"arc":{"radius":50,"extent":90}},{"dot":{"size":8}}] + """ + let decoded = try JSONDecoder().decode([TortoiseCommand].self, from: Data(json.utf8)) + #expect( + decoded == [ + .penWidth(1), .forward(100), .arc(radius: 50, extent: 90), .dot(8), + ]) + } + + /// Payloads may grow compatibly, so a reader that predates a future field + /// must ignore it rather than fail. + @Test("an unknown field inside a tapered payload is ignored") + func unknownPayloadFieldIgnored() throws { + let json = #"{"taperedForward":{"distance":200,"widthTo":12,"easing":"linear"}}"# + let decoded = try JSONDecoder().decode(TortoiseCommand.self, from: Data(json.utf8)) + #expect(decoded == .taperedForward(distance: 200, widthTo: 12)) + } + + @Test("a tapered payload missing widthTo fails to decode") + func missingWidthToFails() { + let json = #"{"taperedForward":{"distance":200}}"# + #expect(throws: (any Error).self) { + try JSONDecoder().decode(TortoiseCommand.self, from: Data(json.utf8)) + } + } +} diff --git a/Tests/TortoiseCoreTests/TortoiseCoreTests.swift b/Tests/TortoiseCoreTests/TortoiseCoreTests.swift index ab9d052..508380e 100644 --- a/Tests/TortoiseCoreTests/TortoiseCoreTests.swift +++ b/Tests/TortoiseCoreTests/TortoiseCoreTests.swift @@ -566,7 +566,7 @@ struct StateConsistencyTests { let t = Tortoise() for step in 0..<300 { - switch step % 10 { + switch step % 12 { case 0: t.forward(nextDouble(-150...150)) case 1: t.right(nextDouble(-720...720)) case 2: t.left(nextDouble(0...360)) @@ -582,6 +582,11 @@ struct StateConsistencyTests { t.penDown() } case 8: t.speed = nextDouble(-1...10) + case 9: t.forward(nextDouble(-150...150), widthTo: nextDouble(-2...9)) + case 10: + t.circle( + radius: nextDouble(-120...120), extent: nextDouble(-400...400), + widthTo: nextDouble(-2...9)) default: t.home() } } @@ -696,3 +701,164 @@ struct PointTests { #expect(isClose(Point(x: 0, y: 0).distance(to: Point(x: 3, y: 4)), 5)) } } + +// MARK: - Pen-width taper + +@Suite("Pen-width taper") +@MainActor +struct PenWidthTaperTests { + /// The property the whole design exists for: a taper costs one command, + /// so it occupies one playback frame and animates in the same time as the + /// equivalent untapered move. + @Test("a taper records exactly one command and one frame") + func singleCommandAndFrame() { + let t = Tortoise() + t.penWidth = 1 + t.forward(200, widthTo: 12) + + // penWidth setter + the taper itself. + #expect(t.commands.count == 2) + #expect(t.commands.last == .taperedForward(distance: 200, widthTo: 12)) + + let plain = Tortoise() + plain.penWidth = 1 + plain.forward(200) + #expect( + CommandPlayer.play(commands: t.commands).count + == CommandPlayer.play(commands: plain.commands).count) + } + + @Test("tapered arc records exactly one command") + func taperedArcIsOneCommand() { + let t = Tortoise() + t.circle(radius: 70, extent: 270, widthTo: 10) + #expect(t.commands == [.taperedArc(radius: 70, extent: 270, widthTo: 10)]) + } + + @Test("penWidth after a taper is the requested end width") + func penWidthLandsOnEndWidth() { + let t = Tortoise() + t.penWidth = 1 + t.forward(100, widthTo: 7.5) + #expect(t.penWidth == 7.5) + + t.circle(radius: 40, extent: 90, widthTo: 2) + #expect(t.penWidth == 2) + } + + @Test("a negative end width clamps to zero") + func negativeEndWidthClamps() { + let t = Tortoise() + t.forward(50, widthTo: -4) + #expect(t.penWidth == 0) + #expect(CommandPlayer.play(commands: t.commands).last?.newStroke?.endWidth == 0) + } + + @Test("a taper moves exactly as far as the untapered move") + func geometryMatchesUntapered() { + let tapered = Tortoise() + tapered.right(37) + tapered.forward(123, widthTo: 9) + + let plain = Tortoise() + plain.right(37) + plain.forward(123) + + #expect(isClose(tapered.position, plain.position)) + #expect(isClose(tapered.heading, plain.heading)) + } + + @Test("a tapered arc lands where an untapered arc would") + func arcGeometryMatchesUntapered() { + let tapered = Tortoise() + tapered.circle(radius: -60, extent: 215, widthTo: 11) + + let plain = Tortoise() + plain.circle(radius: -60, extent: 215) + + #expect(isClose(tapered.position, plain.position)) + #expect(isClose(tapered.heading, plain.heading)) + } + + @Test("backward taper mirrors forward taper") + func backwardMirrorsForward() { + let back = Tortoise() + back.backward(80, widthTo: 5) + #expect(back.commands == [.taperedForward(distance: -80, widthTo: 5)]) + #expect(back.penWidth == 5) + } + + @Test("the stroke carries start and end width") + func strokeCarriesBothWidths() { + let t = Tortoise() + t.penWidth = 2 + t.forward(100, widthTo: 8) + + let stroke = try! #require(CommandPlayer.play(commands: t.commands).last?.newStroke) + #expect(stroke.width == 2) + #expect(stroke.endWidth == 8) + #expect(stroke.isTapered) + } + + @Test("the arc stroke carries start and end width") + func arcStrokeCarriesBothWidths() { + let t = Tortoise() + t.penWidth = 3 + t.circle(radius: 50, extent: 120, widthTo: 9) + + let arc = try! #require(CommandPlayer.play(commands: t.commands).last?.newArcStroke) + #expect(arc.width == 3) + #expect(arc.endWidth == 9) + #expect(arc.isTapered) + } + + /// A caller passing its current width gets an ordinary stroke, so it stays + /// eligible for the same-width batching in the canvas renderer. + @Test("a taper to the current width produces an untapered stroke") + func noWidthChangeIsNotTapered() { + let t = Tortoise() + t.penWidth = 4 + t.forward(100, widthTo: 4) + + let stroke = try! #require(CommandPlayer.play(commands: t.commands).last?.newStroke) + #expect(!stroke.isTapered) + #expect(stroke.width == 4 && stroke.endWidth == 4) + } + + @Test("an ordinary move is never tapered") + func ordinaryMoveIsNotTapered() { + let t = Tortoise() + t.penWidth = 5 + t.forward(50) + let stroke = try! #require(CommandPlayer.play(commands: t.commands).last?.newStroke) + #expect(!stroke.isTapered) + #expect(stroke.endWidth == 5) + } + + @Test("a taper with the pen up draws nothing but still sets the width") + func penUpDrawsNothingButSetsWidth() { + let t = Tortoise() + t.penUp() + t.forward(100, widthTo: 9) + + let frame = try! #require(CommandPlayer.play(commands: t.commands).last) + #expect(frame.newStroke == nil) + #expect(t.penWidth == 9) + #expect(frame.tortoiseState.penWidth == 9) + } + + @Test("tapered vertices participate in fills like ordinary moves") + func taperContributesFillVertices() { + let t = Tortoise() + t.beginFill() + t.forward(50, widthTo: 6) + t.right(120) + t.forward(50, widthTo: 1) + t.right(120) + t.forward(50) + t.endFill() + + let fill = CommandPlayer.play(commands: t.commands).compactMap(\.completedFill).last + #expect(try! #require(fill).points.count == 4) + } +} From 8b1b3aba4c513d8a187daafad47a56061a26e684 Mon Sep 17 00:00:00 2001 From: Jason Jobe Date: Mon, 21 Sep 2026 13:43:33 -0400 Subject: [PATCH 2/5] Add tapered-stroke outline geometry (stage 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Computes the region a tapered pen sweeps, as a closed counter-clockwise polygon, in TortoiseCore — so the SVG and canvas renderers cannot disagree about the shape of a taper, and so the hard part is testable by property rather than by comparing pictures. Straight strokes are exact. The outline of a round-cap stroke is the convex hull of its two end discs, whose sides are the discs' external tangents: on a taper those sides lean, and the tangent points sit at r*(sinA, ±cosA) with sinA = (r0 - r1)/d rather than perpendicular to the spine. Offsetting the endpoints perpendicular instead — the obvious cheap approximation — leaves a notch where each side meets its cap. The degenerate cases fall out of the same construction: no length gives a disc, and |sinA| >= 1 means one disc swallows the other, so the outline is just the larger one. Arc strokes are flattened. The pen sweeps an annulus whose edges are at radius ∓ width(t)/2; with a ramping width those are spirals, so they are chorded to a sagitta tolerance (0.1 units, a twentieth of a pixel at the 2x the canvas goldens render at) and bounded at 512 segments. A pen wider than twice the arc radius clamps its inner edge at the centre rather than inverting through it. A clockwise arc is traced outer-edge-backwards, so the polygon is flipped to keep one winding convention for callers. Nothing consumes this yet; wiring it into the renderers is stages 3-4. Co-Authored-By: Claude Opus 5 --- Sources/TortoiseCore/StrokeOutline.swift | 201 ++++++++++++++ .../StrokeOutlineTests.swift | 245 ++++++++++++++++++ 2 files changed, 446 insertions(+) create mode 100644 Sources/TortoiseCore/StrokeOutline.swift create mode 100644 Tests/TortoiseCoreTests/StrokeOutlineTests.swift diff --git a/Sources/TortoiseCore/StrokeOutline.swift b/Sources/TortoiseCore/StrokeOutline.swift new file mode 100644 index 0000000..7feedf5 --- /dev/null +++ b/Sources/TortoiseCore/StrokeOutline.swift @@ -0,0 +1,201 @@ +import Foundation + +/// Outline polygons for tapered strokes. +/// +/// A stroke whose pen width changes along its length cannot be drawn by +/// stroking a path at one width, so renderers fill the region the pen sweeps +/// instead. That region is computed here — once, in `TortoiseCore` — so the +/// SVG and canvas renderers cannot disagree about the shape of a taper. +/// +/// Polygons come back closed (the first point is not repeated at the end) and +/// wound counter-clockwise in tortoise space. An empty array means there is +/// nothing to fill. +public enum StrokeOutline { + /// Maximum distance a flattened chord may deviate from the true curve. + /// + /// A tenth of a logical unit is a twentieth of a pixel at the 2× scale the + /// canvas goldens render at, so flattening is invisible while keeping the + /// point counts small enough to stay cheap per frame. + public static let defaultTolerance = 0.1 + + /// Upper bound on the chords used for any single curved run, so a huge + /// radius or a pathological tolerance cannot produce an unbounded polygon. + public static let maxSegments = 512 + + // MARK: - Straight strokes + + /// The outline of a straight stroke with round caps. + /// + /// The exact result is the convex hull of the two end discs: the sides are + /// the discs' external tangents, which for a taper are *not* parallel to + /// the stroke and do not meet the caps at the halfway point. Offsetting + /// each endpoint along the perpendicular instead would leave a visible + /// notch where side meets cap, which is why this does the tangent + /// construction rather than the cheaper approximation. + public static func polygon( + for stroke: Stroke, tolerance: Double = defaultTolerance + ) -> [Point] { + hull( + from: stroke.from, radius0: max(0, stroke.width) / 2, + to: stroke.to, radius1: max(0, stroke.endWidth) / 2, + tolerance: tolerance) + } + + /// The convex hull of two discs, as a closed polygon. + static func hull( + from p0: Point, radius0 r0: Double, to p1: Point, radius1 r1: Double, tolerance: Double + ) -> [Point] { + guard r0 > 0 || r1 > 0 else { return [] } + + let delta = p1 - p0 + let d = delta.magnitude + + // No length: the pen never moved, so the mark is a single disc. + guard d > .ulpOfOne else { + return circle(center: p0, radius: max(r0, r1), tolerance: tolerance) + } + + // One disc swallows the other; there are no external tangents to draw. + let sinA = (r0 - r1) / d + guard abs(sinA) < 1 else { + return r0 > r1 + ? circle(center: p0, radius: r0, tolerance: tolerance) + : circle(center: p1, radius: r1, tolerance: tolerance) + } + let cosA = (1 - sinA * sinA).squareRoot() + + // Tangent directions, as the axis angle rotated by ±(90° - A). Both + // tangent points on a given side share this direction, which is what + // makes the sides touch each disc exactly. + let axis = atan2(delta.y, delta.x) + let halfOpen = atan2(cosA, sinA) // = 90° - A, in radians + let anglePlus = axis + halfOpen + let angleMinus = axis - halfOpen + + var points: [Point] = [] + // Side one, then the far cap wrapping forward past `p1` … + points.append(pointOn(center: p0, radius: r0, angle: angleMinus)) + points += arcPoints( + center: p1, radius: r1, from: angleMinus, sweep: 2 * halfOpen, + tolerance: tolerance, includingLast: true) + // … then side two, and the near cap wrapping back past `p0`. + points += arcPoints( + center: p0, radius: r0, from: anglePlus, sweep: 2 * (.pi - halfOpen), + tolerance: tolerance, includingLast: false) + return points + } + + // MARK: - Arcs + + /// The outline of a circular arc stroke with round caps. + /// + /// The pen sweeps an annulus whose inner and outer edges are at + /// `radius ∓ penWidth(t)/2`. With a varying width those edges are spirals + /// rather than circles, so they are flattened to chords; the true envelope + /// additionally leans by `d(width)/d(angle)`, which is below `tolerance` + /// for any width ramp a pen plausibly makes over an arc. + /// + /// A pen wider than twice the arc radius would invert the inner edge + /// through the center; it is clamped at the center instead, so the shape + /// degenerates to a filled disc sector rather than turning inside out. + public static func polygon( + for arc: ArcStroke, tolerance: Double = defaultTolerance + ) -> [Point] { + let r0 = max(0, arc.width) / 2 + let r1 = max(0, arc.endWidth) / 2 + guard r0 > 0 || r1 > 0 else { return [] } + + let sweep = arc.sweep * .pi / 180 + guard abs(sweep) > .ulpOfOne else { + // No sweep: the pen sat still, leaving its cap behind. + return circle( + center: pointOn( + center: arc.center, radius: arc.radius, + angle: arc.startAngle * .pi / 180), + radius: r0, tolerance: tolerance) + } + + let start = arc.startAngle * .pi / 180 + let end = start + sweep + let steps = segmentCount( + radius: arc.radius + max(r0, r1), sweep: sweep, tolerance: tolerance) + + func width(at t: Double) -> Double { r0 + (r1 - r0) * t } + func angle(at t: Double) -> Double { start + sweep * t } + + var points: [Point] = [] + // Outer edge, start → end. + for i in 0...steps { + let t = Double(i) / Double(steps) + points.append( + pointOn(center: arc.center, radius: arc.radius + width(at: t), angle: angle(at: t))) + } + // Cap at the far end, bulging along the direction of travel. + let capSweep: Double = sweep > 0 ? .pi : -.pi + points += arcPoints( + center: pointOn(center: arc.center, radius: arc.radius, angle: end), + radius: r1, from: end, sweep: capSweep, tolerance: tolerance, includingLast: false) + // Inner edge, end → start. + for i in 0...steps { + let t = 1 - Double(i) / Double(steps) + let inner = max(0, arc.radius - width(at: t)) + points.append(pointOn(center: arc.center, radius: inner, angle: angle(at: t))) + } + // Cap at the near end, bulging against the direction of travel. + points += arcPoints( + center: pointOn(center: arc.center, radius: arc.radius, angle: start), + radius: r0, from: start + .pi, sweep: capSweep, tolerance: tolerance, + includingLast: false) + // A clockwise arc is traced outer-edge-backwards, which winds the + // polygon the other way; flip it so callers get one convention. + return sweep > 0 ? points : points.reversed() + } + + // MARK: - Primitives + + static func circle(center: Point, radius: Double, tolerance: Double) -> [Point] { + guard radius > 0 else { return [] } + return arcPoints( + center: center, radius: radius, from: 0, sweep: 2 * .pi, tolerance: tolerance, + includingLast: false) + } + + /// Flattens a circular arc to chords. `includingLast` controls whether the + /// final point is emitted, so runs can be concatenated without duplicates. + static func arcPoints( + center: Point, radius: Double, from startAngle: Double, sweep: Double, + tolerance: Double, includingLast: Bool + ) -> [Point] { + guard radius > 0 else { return includingLast ? [center] : [] } + let steps = segmentCount(radius: radius, sweep: sweep, tolerance: tolerance) + let upper = includingLast ? steps : steps - 1 + guard upper >= 0 else { return [] } + return (0...upper).map { i in + pointOn( + center: center, radius: radius, + angle: startAngle + sweep * (Double(i) / Double(steps))) + } + } + + /// Chord count for a given sagitta tolerance: the error of a chord + /// subtending `Δ` on radius `r` is `r(1 - cos(Δ/2))`. + static func segmentCount(radius: Double, sweep: Double, tolerance: Double) -> Int { + let absSweep = abs(sweep) + guard absSweep > .ulpOfOne else { return 1 } + let tol = max(tolerance, 1e-6) + guard radius > tol else { return 1 } + let maxStep = 2 * acos((1 - tol / radius).clamped(to: -1...1)) + guard maxStep > .ulpOfOne else { return maxSegments } + return min(maxSegments, max(1, Int((absSweep / maxStep).rounded(.up)))) + } + + static func pointOn(center: Point, radius: Double, angle: Double) -> Point { + Point(x: center.x + radius * cos(angle), y: center.y + radius * sin(angle)) + } +} + +extension Double { + fileprivate func clamped(to range: ClosedRange) -> Double { + min(max(self, range.lowerBound), range.upperBound) + } +} diff --git a/Tests/TortoiseCoreTests/StrokeOutlineTests.swift b/Tests/TortoiseCoreTests/StrokeOutlineTests.swift new file mode 100644 index 0000000..aced7e6 --- /dev/null +++ b/Tests/TortoiseCoreTests/StrokeOutlineTests.swift @@ -0,0 +1,245 @@ +import Foundation +import Testing + +@testable import TortoiseCore + +/// Signed area; positive means counter-clockwise in a y-up space. +private func signedArea(_ polygon: [Point]) -> Double { + guard polygon.count >= 3 else { return 0 } + var total = 0.0 + for i in polygon.indices { + let a = polygon[i] + let b = polygon[(i + 1) % polygon.count] + total += a.x * b.y - b.x * a.y + } + return total / 2 +} + +/// Distance from `p` to the segment `a`–`b`. +private func distance(_ p: Point, toSegment a: Point, _ b: Point) -> Double { + let ab = b - a + let lengthSquared = ab.x * ab.x + ab.y * ab.y + guard lengthSquared > 0 else { return p.distance(to: a) } + var t = ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / lengthSquared + t = min(max(t, 0), 1) + return p.distance(to: Point(x: a.x + t * ab.x, y: a.y + t * ab.y)) +} + +@Suite("Stroke outline geometry") +struct StrokeOutlineTests { + private let tolerance = StrokeOutline.defaultTolerance + + // MARK: Straight strokes + + @Test("an untapered stroke outlines to a stadium of the right width") + func untaperedIsStadium() { + let stroke = Stroke( + from: Point(x: -50, y: 0), to: Point(x: 50, y: 0), color: .black, width: 8) + let polygon = StrokeOutline.polygon(for: stroke) + + // Every vertex sits on the boundary: exactly half a pen width from the + // spine, within flattening tolerance. + for p in polygon { + let d = distance(p, toSegment: stroke.from, stroke.to) + #expect(abs(d - 4) <= tolerance) + } + // And the extremes are the caps, half a width beyond each end. + #expect(abs(polygon.map(\.x).min()! - -54) <= tolerance) + #expect(abs(polygon.map(\.x).max()! - 54) <= tolerance) + } + + @Test("a tapered stroke is half its start width at one end and its end width at the other") + func taperedWidthsAtEnds() { + let from = Point(x: 0, y: 0) + let to = Point(x: 100, y: 0) + let stroke = Stroke(from: from, to: to, color: .black, width: 2, endWidth: 20) + let polygon = StrokeOutline.polygon(for: stroke) + + // Widest extent across the spine, near each endpoint. + let nearStart = polygon.filter { $0.x < 1 }.map { abs($0.y) }.max() ?? 0 + let nearEnd = polygon.filter { $0.x > 99 }.map { abs($0.y) }.max() ?? 0 + #expect(abs(nearStart - 1) <= tolerance) + #expect(abs(nearEnd - 10) <= tolerance) + } + + /// The tangent construction is what distinguishes this from offsetting the + /// endpoints perpendicular to the spine; on a taper the sides must lean. + @Test("the sides of a taper are the discs' external tangents") + func sidesAreExternalTangents() { + let from = Point(x: 0, y: 0) + let to = Point(x: 100, y: 0) + let r0 = 1.0 + let r1 = 10.0 + let polygon = StrokeOutline.polygon( + for: Stroke(from: from, to: to, color: .black, width: r0 * 2, endWidth: r1 * 2)) + + // The four tangent points are exact, so assert them outright. Note the + // sign of sinA: when the pen widens, the small end's tangent leans + // *backwards* past `from` — a perpendicular offset would have put it at + // (0, r0) and left a notch where the side meets the cap. + let sinA = (r0 - r1) / 100 + let cosA = (1 - sinA * sinA).squareRoot() + let expected = [ + Point(x: r0 * sinA, y: r0 * cosA), + Point(x: r0 * sinA, y: -r0 * cosA), + Point(x: 100 + r1 * sinA, y: r1 * cosA), + Point(x: 100 + r1 * sinA, y: -r1 * cosA), + ] + for point in expected { + #expect( + polygon.contains { $0.distance(to: point) <= 1e-9 }, + "missing tangent point \(point)") + } + #expect(sinA < 0) + + // No vertex may fall inside either end disc — that is what "hull" means. + for p in polygon { + #expect(p.distance(to: from) >= r0 - tolerance) + #expect(p.distance(to: to) >= r1 - tolerance) + } + } + + @Test("a zero-length stroke outlines to a disc") + func zeroLengthIsDisc() { + let p = Point(x: 5, y: -3) + let polygon = StrokeOutline.polygon( + for: Stroke(from: p, to: p, color: .black, width: 6)) + #expect(!polygon.isEmpty) + for q in polygon { #expect(abs(q.distance(to: p) - 3) <= tolerance) } + } + + @Test("when one end disc swallows the other the outline is that disc") + func containedDiscCollapses() { + // A 40-wide pen moving 3 units to a 2-wide pen: the start disc contains + // everything the pen sweeps. + let from = Point(x: 0, y: 0) + let polygon = StrokeOutline.polygon( + for: Stroke(from: from, to: Point(x: 3, y: 0), color: .black, width: 40, endWidth: 2)) + for q in polygon { #expect(abs(q.distance(to: from) - 20) <= tolerance) } + } + + @Test("a zero-width stroke outlines to nothing") + func zeroWidthIsEmpty() { + let polygon = StrokeOutline.polygon( + for: Stroke(from: .zero, to: Point(x: 10, y: 0), color: .black, width: 0)) + #expect(polygon.isEmpty) + } + + @Test("outlines are closed, non-degenerate and counter-clockwise") + func windingAndClosure() { + let cases = [ + Stroke(from: .zero, to: Point(x: 100, y: 0), color: .black, width: 6), + Stroke(from: .zero, to: Point(x: 100, y: 0), color: .black, width: 2, endWidth: 16), + Stroke(from: .zero, to: Point(x: -40, y: 70), color: .black, width: 12, endWidth: 3), + ] + for stroke in cases { + let polygon = StrokeOutline.polygon(for: stroke) + #expect(polygon.count >= 3) + // Closed means "not explicitly repeated" — renderers close it. + #expect(polygon.first != polygon.last) + #expect(signedArea(polygon) > 0) + } + } + + @Test("a taper and its reverse enclose the same area") + func reversalIsSymmetric() { + let a = StrokeOutline.polygon( + for: Stroke(from: .zero, to: Point(x: 80, y: 0), color: .black, width: 3, endWidth: 14)) + let b = StrokeOutline.polygon( + for: Stroke(from: Point(x: 80, y: 0), to: .zero, color: .black, width: 14, endWidth: 3)) + #expect(abs(signedArea(a) - signedArea(b)) < 0.01) + } + + // MARK: Arcs + + @Test("an untapered arc outlines to an annulus sector") + func untaperedArcIsAnnulus() { + let arc = ArcStroke( + center: .zero, radius: 50, startAngle: 0, sweep: 90, color: .black, width: 10) + let polygon = StrokeOutline.polygon(for: arc) + + // Every vertex is either on an edge (45 or 55 from center) or on a cap. + let capCenters = [Point(x: 50, y: 0), Point(x: 0, y: 50)] + for p in polygon { + let radial = p.distance(to: .zero) + let onEdge = abs(radial - 45) <= tolerance || abs(radial - 55) <= tolerance + let onCap = capCenters.contains { abs(p.distance(to: $0) - 5) <= tolerance } + #expect(onEdge || onCap) + } + } + + @Test("a tapered arc's edges follow the ramping width") + func taperedArcEdges() { + let arc = ArcStroke( + center: .zero, radius: 60, startAngle: 0, sweep: 180, color: .black, width: 4, + endWidth: 16) + let polygon = StrokeOutline.polygon(for: arc) + + // Outer edge never exceeds radius + half the widest width, and the + // whole shape stays within the widest cap. + for p in polygon { #expect(p.distance(to: .zero) <= 60 + 8 + tolerance) } + // At the start the band is 4 wide; at the end, 16. + let atStart = polygon.filter { abs($0.y) < 0.5 && $0.x > 0 }.map { $0.distance(to: .zero) } + #expect(abs((atStart.max() ?? 0) - (atStart.min() ?? 0) - 4) <= 2 * tolerance) + } + + @Test("a wide pen clamps the inner edge at the centre instead of inverting") + func widePenClampsInnerEdge() { + let arc = ArcStroke( + center: .zero, radius: 10, startAngle: 0, sweep: 120, color: .black, width: 60) + let polygon = StrokeOutline.polygon(for: arc) + // Nothing crosses to a negative radius, which is what inversion would do. + for p in polygon { #expect(p.distance(to: .zero) <= 10 + 30 + tolerance) } + #expect(polygon.contains { $0.distance(to: .zero) <= tolerance }) + } + + @Test("a zero-sweep arc outlines to the cap left behind") + func zeroSweepArcIsDisc() { + let arc = ArcStroke( + center: .zero, radius: 40, startAngle: 0, sweep: 0, color: .black, width: 8) + let polygon = StrokeOutline.polygon(for: arc) + for p in polygon { #expect(abs(p.distance(to: Point(x: 40, y: 0)) - 4) <= tolerance) } + } + + @Test("arc outlines are counter-clockwise for either sweep direction") + func arcWinding() { + for sweep in [90.0, -90.0, 270.0, -270.0] { + let polygon = StrokeOutline.polygon( + for: ArcStroke( + center: .zero, radius: 50, startAngle: 30, sweep: sweep, color: .black, + width: 6, endWidth: 12)) + #expect(polygon.count >= 3) + #expect(signedArea(polygon) > 0, "sweep \(sweep) wound backwards") + } + } + + // MARK: Flattening + + @Test("segment count follows the sagitta tolerance") + func segmentCountFollowsTolerance() { + let coarse = StrokeOutline.segmentCount(radius: 100, sweep: .pi, tolerance: 1) + let fine = StrokeOutline.segmentCount(radius: 100, sweep: .pi, tolerance: 0.01) + #expect(fine > coarse) + + // The realised sagitta must actually respect the tolerance. + let steps = StrokeOutline.segmentCount(radius: 100, sweep: .pi, tolerance: 0.1) + let sagitta = 100 * (1 - cos(.pi / Double(steps) / 2)) + #expect(sagitta <= 0.1 + 1e-9) + } + + @Test("flattening is bounded however absurd the inputs") + func flatteningIsBounded() { + #expect( + StrokeOutline.segmentCount(radius: 1e9, sweep: 2 * .pi, tolerance: 1e-9) + == StrokeOutline.maxSegments) + #expect(StrokeOutline.segmentCount(radius: 0, sweep: .pi, tolerance: 0.1) == 1) + #expect(StrokeOutline.segmentCount(radius: 10, sweep: 0, tolerance: 0.1) == 1) + } + + @Test("outlines are deterministic") + func deterministic() { + let stroke = Stroke( + from: .zero, to: Point(x: 70, y: 20), color: .black, width: 3, endWidth: 11) + #expect(StrokeOutline.polygon(for: stroke) == StrokeOutline.polygon(for: stroke)) + } +} From 2ab26ef6d9b62e032d195bcd500a3045f71d6a65 Mon Sep 17 00:00:00 2001 From: Jason Jobe Date: Mon, 21 Sep 2026 14:05:34 -0400 Subject: [PATCH 3/5] Render tapered strokes in SVG (stage 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tapered stroke has no single stroke-width, so TortoiseSVG fills the region the pen sweeps instead of stroking a line: one per tapered stroke or arc, built from the shared outline geometry in TortoiseCore. Untapered marks are untouched and still emit and stroked , so nothing about existing output moves — the goldens did not need re-recording. Both renderers will fill the same polygon rather than each deriving its own, which is why the flattened points are used here in preference to SVG's own arc commands: exact caps would look marginally better in isolation but would let the two renderers disagree about the shape of a taper, and agreement is the property worth keeping. This closes the last two of the three objections to the expansion-based version. A taper is one element rather than 44, and being a single filled region it has no interior round caps to blend twice — so a translucent taper no longer darkens at the seams, because it no longer has any. A zero-extent arc still draws nothing, tapered or not. Co-Authored-By: Claude Opus 5 --- Sources/TortoiseSVG/TortoiseSVG.swift | 21 ++++ Tests/TortoiseSVGTests/TortoiseSVGTests.swift | 108 ++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/Sources/TortoiseSVG/TortoiseSVG.swift b/Sources/TortoiseSVG/TortoiseSVG.swift index 22584bb..92dc1b9 100644 --- a/Sources/TortoiseSVG/TortoiseSVG.swift +++ b/Sources/TortoiseSVG/TortoiseSVG.swift @@ -165,6 +165,12 @@ private struct SVGBuilder { } private func svgStroke(_ stroke: Stroke) -> String { + // A tapered stroke has no single stroke-width, so the region the pen + // sweeps is filled instead. The polygon comes from TortoiseCore, which + // is also what the canvas renderer fills — the two cannot drift. + if stroke.isTapered { + return svgOutline(StrokeOutline.polygon(for: stroke), color: stroke.color) + } let x1 = n(x(stroke.from.x)) let y1 = n(y(stroke.from.y)) let x2 = n(x(stroke.to.x)) @@ -173,10 +179,25 @@ private struct SVGBuilder { #" "# } + /// Emits a filled outline polygon, used wherever a pen width varies along + /// the mark and a stroked path cannot express it. + private func svgOutline(_ polygon: [Point], color penColor: Color) -> String { + guard polygon.count >= 3 else { return "" } + let pts = + polygon + .map { "\(n(x($0.x))),\(n(y($0.y)))" } + .joined(separator: " ") + return #" "# + } + private func svgArc(_ arc: ArcStroke) -> String { let absSwep = abs(arc.sweep) guard absSwep > 0 else { return "" } + if arc.isTapered { + return svgOutline(StrokeOutline.polygon(for: arc), color: arc.color) + } + let cx = x(arc.center.x) let cy = y(arc.center.y) let r = arc.radius diff --git a/Tests/TortoiseSVGTests/TortoiseSVGTests.swift b/Tests/TortoiseSVGTests/TortoiseSVGTests.swift index 6bd430e..48e1798 100644 --- a/Tests/TortoiseSVGTests/TortoiseSVGTests.swift +++ b/Tests/TortoiseSVGTests/TortoiseSVGTests.swift @@ -216,3 +216,111 @@ struct TortoiseSVGTests { #expect(out.contains("rgba(255,0,0,0.5)")) } } + +// MARK: - Tapered strokes + +@Suite("Tapered stroke SVG") +@MainActor +struct TaperedStrokeSVGTests { + @Test("a tapered stroke becomes a single filled polygon") + func taperedStrokeIsOnePolygon() { + let t = Tortoise() + t.penWidth = 1 + t.forward(150, widthTo: 14) + let svg = t.svg() + + #expect(svg.components(separatedBy: " Date: Mon, 21 Sep 2026 14:10:04 -0400 Subject: [PATCH 4/5] Render tapered strokes on the canvas (stage 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TortoiseUI fills the same outline polygon TortoiseSVG fills, from the shared geometry in TortoiseCore, so the two renderers cannot disagree about the shape of a taper. Three things were needed: - Tapered strokes and arcs fill an outline instead of stroking a path. Because the polygon is in tortoise units, the transform supplies the scale; unlike a stroked path there is no width to scale by hand. - Tapered strokes are excluded from the same-width batching added in #37. This is not only a correctness nicety: `Stroke.width` is the *start* width, so a tapered stroke can compare equal to an untapered run and would otherwise be silently drawn as a plain line. - The in-progress stroke truncates its width ramp along with its spine, so the pen is as thick where the tortoise stands as it will be once the stroke commits. Without this the mark would change width behind the tortoise as the frame finished. Adds a `taperedStrokes` drawing scenario covering both sweep directions and the two cases the renderers special-case — a translucent taper, which stays one blended region, and a taper whose end width equals its start, which stays an ordinary batchable stroke. Both goldens recorded and inspected: 5 polygons and 1 line, the line being the equal-width case. Only the new goldens are added. Re-recording the existing sixteen reproduced them to within a few bytes of rasteriser noise, and they still pass unchanged, so untapered output is byte-compatible and the churn was reverted. Co-Authored-By: Claude Opus 5 --- Sources/TortoiseUI/CanvasRenderer.swift | 86 +++++++++++++++--- .../scenario.taperedStrokes.svg | 10 ++ .../DrawingScenarios.swift | 55 +++++++++++ .../scenario.taperedStrokes.png | Bin 0 -> 37038 bytes 4 files changed, 136 insertions(+), 15 deletions(-) create mode 100644 Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.taperedStrokes.svg create mode 100644 Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.taperedStrokes.png diff --git a/Sources/TortoiseUI/CanvasRenderer.swift b/Sources/TortoiseUI/CanvasRenderer.swift index bda398d..c67f6e4 100644 --- a/Sources/TortoiseUI/CanvasRenderer.swift +++ b/Sources/TortoiseUI/CanvasRenderer.swift @@ -34,6 +34,14 @@ enum CanvasRenderer { path.closeSubpath() ctx.fill(path, with: .color(SwiftUI.Color(fill.color))) + case .stroke(let first) where first.isTapered: + // No single stroke-width can express a taper, so fill the + // region the pen swept — the same polygon TortoiseSVG fills. + i += 1 + ctx.fill( + outlinePath(StrokeOutline.polygon(for: first), transform: t), + with: .color(SwiftUI.Color(first.color))) + case .stroke(let first): // Merge the maximal run of same-color, same-width strokes into // one multi-subpath `Path` and stroke it once. Round caps are @@ -47,7 +55,7 @@ enum CanvasRenderer { var path = Path() var j = i while j < elements.endIndex, case .stroke(let next) = elements[j], - next.color == first.color, next.width == first.width + !next.isTapered, next.color == first.color, next.width == first.width { path.move(to: CGPoint(x: next.from.x, y: next.from.y).applying(t)) path.addLine(to: CGPoint(x: next.to.x, y: next.to.y).applying(t)) @@ -59,6 +67,12 @@ enum CanvasRenderer { style: strokeStyle(width: first.width * s)) i = j + case .arcStroke(let arc) where arc.isTapered: + i += 1 + ctx.fill( + outlinePath(StrokeOutline.polygon(for: arc), transform: t), + with: .color(SwiftUI.Color(arc.color))) + case .arcStroke(let arc): i += 1 ctx.stroke( @@ -83,23 +97,51 @@ enum CanvasRenderer { transform t: CGAffineTransform, scale s: Double ) { if let stroke = frame.newStroke { - var path = Path() - let from = CGPoint(x: stroke.from.x, y: stroke.from.y).applying(t) - let partialTo = CGPoint( + let partial = Point( x: stroke.from.x + p * (stroke.to.x - stroke.from.x), - y: stroke.from.y + p * (stroke.to.y - stroke.from.y) - ).applying(t) - path.move(to: from) - path.addLine(to: partialTo) - ctx.stroke( - path, with: .color(SwiftUI.Color(stroke.color)), - style: strokeStyle(width: stroke.width * s)) + y: stroke.from.y + p * (stroke.to.y - stroke.from.y)) + if stroke.isTapered { + // Truncate the width ramp along with the spine, so the pen is + // as thick where the tortoise stands as it will be when the + // stroke commits — the mark never changes width behind it. + ctx.fill( + outlinePath( + StrokeOutline.polygon( + for: Stroke( + from: stroke.from, to: partial, color: stroke.color, + width: stroke.width, + endWidth: stroke.width + p * (stroke.endWidth - stroke.width))), + transform: t), + with: .color(SwiftUI.Color(stroke.color))) + } + else { + var path = Path() + path.move(to: CGPoint(x: stroke.from.x, y: stroke.from.y).applying(t)) + path.addLine(to: CGPoint(x: partial.x, y: partial.y).applying(t)) + ctx.stroke( + path, with: .color(SwiftUI.Color(stroke.color)), + style: strokeStyle(width: stroke.width * s)) + } } if let arc = frame.newArcStroke { - ctx.stroke( - arcPath(arc, sweep: arc.sweep * p, transform: t), - with: .color(SwiftUI.Color(arc.color)), - style: strokeStyle(width: arc.width * s)) + if arc.isTapered { + ctx.fill( + outlinePath( + StrokeOutline.polygon( + for: ArcStroke( + center: arc.center, radius: arc.radius, + startAngle: arc.startAngle, sweep: arc.sweep * p, + color: arc.color, width: arc.width, + endWidth: arc.width + p * (arc.endWidth - arc.width))), + transform: t), + with: .color(SwiftUI.Color(arc.color))) + } + else { + ctx.stroke( + arcPath(arc, sweep: arc.sweep * p, transform: t), + with: .color(SwiftUI.Color(arc.color)), + style: strokeStyle(width: arc.width * s)) + } } } @@ -136,6 +178,20 @@ enum CanvasRenderer { // MARK: - Private helpers + /// Converts an outline polygon from ``StrokeOutline`` into a closed path in + /// screen space. The polygon is in tortoise units, so `transform` supplies + /// the scale — unlike a stroked path, whose width has to be scaled by hand. + static func outlinePath(_ polygon: [Point], transform t: CGAffineTransform) -> Path { + var path = Path() + guard let first = polygon.first else { return path } + path.move(to: CGPoint(x: first.x, y: first.y).applying(t)) + for pt in polygon.dropFirst() { + path.addLine(to: CGPoint(x: pt.x, y: pt.y).applying(t)) + } + path.closeSubpath() + return path + } + /// Draws the built-in triangle, centered on the (already translated and /// rotated) context's origin and pointing north — tip at -Y in screen /// space, which is up on screen. diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.taperedStrokes.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.taperedStrokes.svg new file mode 100644 index 0000000..d2a9da6 --- /dev/null +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.taperedStrokes.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Tests/TortoiseTestSupport/DrawingScenarios.swift b/Tests/TortoiseTestSupport/DrawingScenarios.swift index 5292052..d7bc1ae 100644 --- a/Tests/TortoiseTestSupport/DrawingScenarios.swift +++ b/Tests/TortoiseTestSupport/DrawingScenarios.swift @@ -18,9 +18,64 @@ extension DrawingScenario { showAfterHide, speedChanges, translucentOverlaps, + taperedStrokes, showcase, ] + /// Covers `taperedForward` and `taperedArc`, including the two cases the + /// renderers special-case: a translucent taper (which must stay a single + /// blended region rather than seaming) and a taper whose end width equals + /// its start width (which must stay an ordinary, batchable stroke). + public static let taperedStrokes = DrawingScenario("taperedStrokes") { t in + t.penUp() + t.setPosition(x: -170, y: 150) + t.penDown() + t.heading = 90 + t.penColor = .blue + t.penWidth = 1 + t.forward(320, widthTo: 22) + + t.penUp() + t.setPosition(x: -170, y: 90) + t.penDown() + t.penColor = .red + t.penWidth = 22 + t.forward(320, widthTo: 1) + + // Translucent: one filled region, so no seams to darken. + t.penUp() + t.setPosition(x: -170, y: 30) + t.penDown() + t.penColor = Color(red: 0, green: 0.5, blue: 0.25, alpha: 0.4) + t.penWidth = 2 + t.forward(320, widthTo: 26) + + // Equal widths: must remain an ordinary stroke. + t.penUp() + t.setPosition(x: -170, y: -20) + t.penDown() + t.penColor = .black + t.penWidth = 5 + t.forward(320, widthTo: 5) + + // Tapered arcs, both sweep directions. + t.penUp() + t.setPosition(x: -90, y: -80) + t.penDown() + t.penColor = .purple + t.penWidth = 2 + t.heading = 90 + t.circle(radius: 55, extent: 300, widthTo: 18) + + t.penUp() + t.setPosition(x: 90, y: -80) + t.penDown() + t.penColor = .orange + t.penWidth = 16 + t.heading = 90 + t.circle(radius: -55, extent: 300, widthTo: 2) + } + /// Covers `forward` and `rotate` (via forward/backward/right/left). public static let linesAndTurns = DrawingScenario("linesAndTurns") { t in for _ in 0..<4 { diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.taperedStrokes.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.taperedStrokes.png new file mode 100644 index 0000000000000000000000000000000000000000..f0b749cdf19910a44cd756eeb6e27614b379ed17 GIT binary patch literal 37038 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 bbjNsHN6^D$++@;o;Gc|yym+pdzSsW(w_1!b literal 0 HcmV?d00001 From 545dfef6ab8021dd3f04ccce7531f9dab767a783 Mon Sep 17 00:00:00 2001 From: Jason Jobe Date: Mon, 21 Sep 2026 14:15:13 -0400 Subject: [PATCH 5/5] Document the taper and add a gallery example (stage 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `TaperedPetals`, a rosette whose petals are two tapered arcs each, added to `Gallery.drawings` and the README in README order so the runner emits its SVG. Opaque on purpose: the two arcs of a petal meet cap-to-cap at its tip, and two translucent marks sharing a cap blend twice there. That is the same double-blend any two overlapping translucent strokes have always had — one blend per command, not per sub-segment — but it lands as a conspicuous dot on a showcase drawing. - A DocC section on tapered strokes, and `StrokeOutline` in Topics. - Two CLAUDE.md design notes: why the taper is a primitive rather than sugar (the unit of time in this library is the command), and why the outline geometry lives in Core with the two invariants a future change could quietly break — tapered strokes must never join the same-width batch, and the in-progress stroke must truncate its width ramp along with its spine. The six existing gallery SVGs regenerate byte-identically. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 4 ++ README.md | 2 + Sources/Examples/Gallery/Gallery.swift | 1 + Sources/Examples/Gallery/TaperedPetals.swift | 54 +++++++++++++++++++ .../Documentation.docc/TortoiseCore.md | 24 +++++++++ Sources/TortoiseCore/TortoiseCommand.swift | 6 +-- docs/examples/tapered-petals.svg | 22 ++++++++ 7 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 Sources/Examples/Gallery/TaperedPetals.swift create mode 100644 docs/examples/tapered-petals.svg diff --git a/CLAUDE.md b/CLAUDE.md index e04e660..aeb5741 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,10 @@ Tortoise API → [TortoiseCommand] → CommandPlayer.play() → [PlaybackFrame] **`Color.defaultBackground` is the single source of truth for the initial background (#44).** `Tortoise` starts at it, `CommandPlayer.play`'s `initialBackgroundColor` defaults to it, and `CanvasModel` / `SVGBuilder` use it as their empty-stream fallback. It is white. Renderers must never substitute a fallback of their own: they previously started from `.clear` while `Tortoise.backgroundColor` reported white, so a program that issued no `.backgroundColor` command painted nothing — the tortoise and its own drawing disagreed about the color of the paper. Note that `Tortoise.backgroundColor` is the *current* value, not the initial one, so it must not be threaded in as `initialBackgroundColor`; that would back-date a later background change to frame 0. Transparency is still available, but must be asked for: `tortoise.backgroundColor = .clear`. Both renderers still skip the fill / omit the `` when `alpha == 0`, which is what makes SwiftUI's `.background()` modifier work. +**Pen-width taper is a primitive, not sugar (`taperedForward` / `taperedArc`).** `forward(_:widthTo:)` and `circle(radius:extent:widthTo:)` each record *one* command. The tempting alternative — subdividing the move into short constant-width segments emitting ordinary `.penWidth` + `.forward` pairs — needs no renderer changes at all, and is wrong. In this library the unit of *time* is the command: `CommandPlayer` emits a frame per command including state-only ones, and `stepDuration` is distance-independent, so an expansion-based taper buys width fidelity with animation time. `forward(280, widthTo: 12)` from width 1 costs 89 commands, 44 SVG `` elements and 8.9 s at the default speed, and its sub-segments' round caps blend twice at every seam when `alpha < 1`. As one command it is one frame, one element, and one blended region. Do not "optimize" this back into an expansion, and do not add a `steps:` parameter — there is nothing to subdivide. + +**`StrokeOutline` is the single source of taper geometry.** A varying width cannot be stroked, so both renderers *fill* the region the pen sweeps, and both fill the identical polygon from Core — that is the whole reason the math lives there rather than in either renderer. Straight strokes are exact: the outline is the convex hull of the two end discs, whose sides are the discs' external tangents (`sinA = (r0 - r1)/d`), *not* the endpoints offset along the perpendicular — the cheap version leaves a notch where each side meets its cap. Arc edges (`radius ∓ width(t)/2`) are spirals and get flattened to a sagitta tolerance, bounded at `maxSegments`. SVG deliberately uses these flattened points rather than its own arc commands: exact `` caps would look marginally better in isolation and would let the two renderers drift apart. Two consequences to preserve: **tapered strokes must never join the same-width batching** in `CanvasRenderer.drawElements` (`Stroke.width` is only the *start* width, so a taper can compare equal to an untapered run and be silently drawn as a plain line), and **the in-progress stroke truncates its width ramp along with its spine**, or the mark changes width behind the tortoise as the frame finishes. + **`TortoiseSprite` is a TortoiseUI-only concept.** The sprite (built-in triangle or a user `Image`) is chosen through the `\.tortoiseSprite` environment value, like `\.tortoiseViewport` — it is *not* a `TortoiseCommand`, so it never enters the serialized stream and `TortoiseSVG` is unaffected (SVG output has never drawn the tortoise). Both canvas layers read the environment value even though only `AnimationLayer` draws the sprite: `ViewportMode.autoFit`'s edge inset is `TortoiseSprite.halfExtent * tortoiseScaleMax`, and the two layers must derive the identical transform. `halfExtent` is the sprite's half-*diagonal* so the inset holds at every heading. Image sprites are aspect-fitted into `size` (`ctx.resolve` gives the intrinsic size; a `ResolvedImage` is bound to its context, so this cannot be hoisted out of the per-frame draw). ## Coordinate System diff --git a/README.md b/README.md index 60ad5c2..daac2cd 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ itself. `swift run ExamplesRunner` regenerates the images below. | [Square Spiral](Sources/Examples/Gallery/SquareSpiral.swift) | [Fractal Tree](Sources/Examples/Gallery/FractalTree.swift) | [Koch Snowflake](Sources/Examples/Gallery/KochSnowflake.swift) | | Circle Rosette | Filled Star | Waves | | [Circle Rosette](Sources/Examples/Gallery/CircleRosette.swift) | [Filled Star](Sources/Examples/Gallery/FilledStar.swift) | [Waves](Sources/Examples/Gallery/Waves.swift) | +| Tapered Petals | | | +| [Tapered Petals](Sources/Examples/Gallery/TaperedPetals.swift) | | | ## Showcase diff --git a/Sources/Examples/Gallery/Gallery.swift b/Sources/Examples/Gallery/Gallery.swift index 08c5114..de6d623 100644 --- a/Sources/Examples/Gallery/Gallery.swift +++ b/Sources/Examples/Gallery/Gallery.swift @@ -14,5 +14,6 @@ public enum Gallery { ("circle-rosette", CircleRosette.draw), ("filled-star", FilledStar.draw), ("waves", Waves.draw), + ("tapered-petals", TaperedPetals.draw), ] } diff --git a/Sources/Examples/Gallery/TaperedPetals.swift b/Sources/Examples/Gallery/TaperedPetals.swift new file mode 100644 index 0000000..fc40957 --- /dev/null +++ b/Sources/Examples/Gallery/TaperedPetals.swift @@ -0,0 +1,54 @@ +import SwiftUI +import TortoiseCore +import TortoiseUI + +/// A rosette of petals drawn with a pen that thickens and thins as it goes. +/// +/// Each petal is two tapered arcs: out from the centre with the pen swelling, +/// back again with it closing to a point. Because a taper is a single command +/// rather than a run of stepped-width segments, each arc here is one playback +/// frame and one filled region — the petals animate at the same pace as any +/// other arc, and the translucent fills blend once rather than darkening +/// wherever sub-segments would have overlapped. +enum TaperedPetals { + @MainActor + static func draw(_ 🐢: Tortoise) { + 🐢.backgroundColor = TortoiseCore.Color(red: 0.07, green: 0.07, blue: 0.12) + 🐢.speed = 10 + + let petals = 9 + for i in 0.. TortoiseCore.Color { + let angle = hue * 2 * .pi + return TortoiseCore.Color( + red: 0.55 + 0.45 * cos(angle), + green: 0.45 + 0.4 * cos(angle - 2.1), + blue: 0.6 + 0.4 * cos(angle - 4.2), + alpha: 1 + ) + } +} + +#Preview("Tapered Petals") { + TortoiseCanvas(TaperedPetals.draw) + .padding() +} diff --git a/Sources/TortoiseCore/Documentation.docc/TortoiseCore.md b/Sources/TortoiseCore/Documentation.docc/TortoiseCore.md index eecfa14..26fdcf5 100644 --- a/Sources/TortoiseCore/Documentation.docc/TortoiseCore.md +++ b/Sources/TortoiseCore/Documentation.docc/TortoiseCore.md @@ -26,6 +26,29 @@ This means animation, SVG export, and unit tests all share a single source of tr a snapshot of tortoise state after each command — which renderers step through to produce output. +### Tapered strokes + +A pen can change width across a single move, so a stroke thickens or thins as +the tortoise lays it down: + +```swift +🐢.penWidth = 1 +🐢.forward(200, widthTo: 12) +🐢.circle(radius: 70, extent: 270, widthTo: 10) +``` + +Each is one ``TortoiseCommand`` — ``TortoiseCommand/taperedForward(distance:widthTo:)`` +and ``TortoiseCommand/taperedArc(radius:extent:widthTo:)`` — so a taper occupies +a single ``PlaybackFrame`` and animates in the same time as the untapered move +it replaces. After the call ``Tortoise/penWidth`` is the width you asked for. + +Renderers cannot express a varying width by stroking a path at one width, so +they fill the region the pen sweeps instead. ``StrokeOutline`` computes that +region, and both bundled renderers fill the same polygon, so they cannot +disagree about the shape of a taper. A stroke whose end width equals its start +width is not tapered at all (``Stroke/isTapered``) and is drawn the ordinary +way. + ### Coordinate system - **Origin** — center of the logical canvas. @@ -58,6 +81,7 @@ to produce output. ### Geometry - ``DrawingBounds`` +- ``StrokeOutline`` ### Value Types diff --git a/Sources/TortoiseCore/TortoiseCommand.swift b/Sources/TortoiseCore/TortoiseCommand.swift index 5c0a4be..fa67236 100644 --- a/Sources/TortoiseCore/TortoiseCommand.swift +++ b/Sources/TortoiseCore/TortoiseCommand.swift @@ -16,8 +16,8 @@ public enum TortoiseCommand: Sendable, Equatable { /// One command, so it occupies a single playback frame and animates in the /// same time as a plain ``forward(_:)`` of the same distance. The ramp is a /// property of the resulting ``Stroke``, which renderers fill as an outline - /// rather than stroking at a single width. After this command ``penWidth`` - /// is `widthTo`. + /// rather than stroking at a single width. After this command + /// ``Tortoise/penWidth`` is `widthTo`. case taperedForward(distance: Double, widthTo: Double) /// Rotate clockwise (positive) or counterclockwise (negative) by `degrees`. case rotate(Double) @@ -63,7 +63,7 @@ public enum TortoiseCommand: Sendable, Equatable { /// /// Geometry matches ``arc(radius:extent:)`` exactly; only the pen width /// differs. Like ``taperedForward(distance:widthTo:)`` this is a single - /// command and a single frame. After it ``penWidth`` is `widthTo`. + /// command and a single frame. After it ``Tortoise/penWidth`` is `widthTo`. case taperedArc(radius: Double, extent: Double, widthTo: Double) // MARK: Dot diff --git a/docs/examples/tapered-petals.svg b/docs/examples/tapered-petals.svg new file mode 100644 index 0000000..83dfcce --- /dev/null +++ b/docs/examples/tapered-petals.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file