diff --git a/CLAUDE.md b/CLAUDE.md index d023721..6b2bb56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,8 @@ 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 sugar, not a primitive (`Tortoise.taper`).** `forward(_:widthTo:steps:)` and `circle(radius:extent:widthTo:steps:)` subdivide the move and emit ordinary `.penWidth` + `.forward`/`.arc` pairs. Nothing downstream changed: no new `TortoiseCommand` case, no wire-format change, no renderer change — a variable-width `Stroke` would have forced both renderers to fill outline polygons instead of stroking lines, which is the expensive half of the feature. Three properties are load-bearing. **The automatic step count keys off the width delta, not the distance** (`taperWidthQuantum`, `maxTaperSteps`), so a gentle ramp over a long move stays cheap. **A taper whose end width equals its start width emits exactly one command** — a caller that passes its current width pays nothing, and the stroke stays eligible for the same-width batching in `CanvasRenderer.drawElements`, which every *real* taper defeats by construction. **Widths are sampled at each sub-segment's midpoint**, which centers the error (half the steps for the same fidelity) but never emits the endpoint — hence the trailing `.penWidth(endWidth)` so `penWidth` after the call is what the caller asked for. Don't "simplify" that trailing command away. + **`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 5fd18af..8969d1c 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..285fcbe --- /dev/null +++ b/Sources/Examples/Gallery/TaperedPetals.swift @@ -0,0 +1,62 @@ +import SwiftUI +import TortoiseUI + +/// A pinwheel of brush strokes, each one thick at the hub and vanishing at the +/// tip — drawn with the pen-width taper (see `forward(_:widthTo:)` and +/// `circle(radius:extent:widthTo:)`). +/// +/// A taper is not a stroke primitive: the move is subdivided into short +/// constant-width segments, so this is an ordinary command stream. The step +/// count comes from the *width* change rather than the distance, which is why +/// the long curved petals here cost about as much as the short straight rays. +enum TaperedPetals { + @MainActor + static func draw(_ 🐢: Tortoise) { + 🐢.backgroundColor = .white + 🐢.speed = 10 + + let petals = 12 + for i in 0.. TortoiseCore.Color { + func channel(_ phase: Double) -> Double { + (sin((t + phase) * 2 * .pi) + 1) / 2 + } + return TortoiseCore.Color( + red: channel(0), green: channel(1.0 / 3), blue: channel(2.0 / 3)) + } +} + +#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..5351de9 100644 --- a/Sources/TortoiseCore/Documentation.docc/TortoiseCore.md +++ b/Sources/TortoiseCore/Documentation.docc/TortoiseCore.md @@ -26,6 +26,23 @@ 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. +### Pen-width taper + +``Tortoise/forward(_:widthTo:steps:)`` and +``Tortoise/circle(radius:extent:widthTo:steps:)`` ramp the pen width across a +move: + +```swift +🐢.penWidth = 1 +🐢.forward(200, widthTo: 12) // a stroke that thickens as it is drawn +``` + +There is no variable-width stroke primitive — the move is subdivided into +sub-segments of constant width, so the result is an ordinary command stream that +every renderer already understands and that serializes unchanged. The step count +is chosen from the *width* change, not the distance, and a taper whose end width +equals its start width records a single plain move. + ### Coordinate system - **Origin** — center of the logical canvas. diff --git a/Sources/TortoiseCore/Tortoise.swift b/Sources/TortoiseCore/Tortoise.swift index 3b49e80..503b8e7 100644 --- a/Sources/TortoiseCore/Tortoise.swift +++ b/Sources/TortoiseCore/Tortoise.swift @@ -115,6 +115,32 @@ public final class Tortoise { forward(-distance) } + /// Move forward by `distance` pixels while ramping the pen width from its + /// current value to `endWidth`. + /// + /// The taper is approximated by subdividing the move into sub-segments, + /// each drawn at a constant width — there is no variable-width stroke + /// primitive, so this is sugar over ``forward(_:)`` and + /// ``penWidth``. After the call ``penWidth`` is exactly `endWidth`. + /// + /// - Parameters: + /// - distance: Distance to travel (negative = backward). + /// - endWidth: Pen width at the end of the move. Clamped to `>= 0`. + /// - steps: Number of sub-segments. Defaults to `nil`, which picks the + /// fewest steps that keep the width change per sub-segment at or below + /// ``taperWidthQuantum`` (capped at ``maxTaperSteps``). When the width + /// does not change at all, a single plain ``forward(_:)`` is recorded. + public func forward(_ distance: Double, widthTo endWidth: Double, steps: Int? = nil) { + taper(to: endWidth, steps: steps) { self.record(.forward(distance * $0)) } + } + + /// Move backward by `distance` pixels while ramping the pen width to `endWidth`. + /// + /// See ``forward(_:widthTo:steps:)`` for how the taper is approximated. + public func backward(_ distance: Double, widthTo endWidth: Double, steps: Int? = nil) { + forward(-distance, widthTo: endWidth, steps: steps) + } + /// Rotate clockwise by `degrees`. public func right(_ degrees: Double) { record(.rotate(degrees)) @@ -198,6 +224,81 @@ 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`. + /// + /// The arc is subdivided into sub-arcs of equal extent, each drawn at a + /// constant width; sub-arcs compose exactly, so the tortoise lands where an + /// undivided ``circle(radius:extent:)`` would. After the call ``penWidth`` + /// is exactly `endWidth`. + /// + /// See ``forward(_:widthTo:steps:)`` for how `steps` is chosen. + public func circle( + radius: Double, extent: Double = 360, widthTo endWidth: Double, steps: Int? = nil + ) { + taper(to: endWidth, steps: steps) { self.record(.arc(radius: radius, extent: extent * $0)) } + } + + // MARK: - Pen-width taper + + /// Largest pen-width change (in logical units) allowed between consecutive + /// sub-segments when a taper picks its own step count. + /// + /// A quarter of a unit is below the visible threshold at normal scales, and + /// tying the step count to the *width* delta rather than to the distance is + /// what keeps a gentle taper cheap: `forward(500, widthTo: penWidth + 1)` + /// costs four sub-segments, not five hundred. + public static let taperWidthQuantum = 0.25 + + /// Upper bound on the sub-segments a taper will pick for itself. + /// + /// Each sub-segment is a distinct `Stroke` width, which defeats the + /// same-width stroke batching in `TortoiseUI` and emits its own `` in + /// SVG, so an extreme width change buys fidelity with draw calls rather + /// than unbounded ones. Pass `steps:` explicitly to go past this. + public static let maxTaperSteps = 64 + + /// Records `segment` `n` times — each call drawing a `1/n` fraction of the + /// whole move — with the pen width stepped along the way. + /// + /// `segment` receives the fraction of the move to draw, so the same helper + /// serves straight moves and arcs. + private func taper(to rawEndWidth: Double, steps requested: Int?, segment: (Double) -> Void) { + let endWidth = max(0, rawEndWidth) + let startWidth = state.penWidth + let n = Self.taperSteps(from: startWidth, to: endWidth, requested: requested) + + // No width change: a taper is then exactly a plain move, so a caller + // that happens to pass its current width pays nothing for the call — + // one command, one full-width stroke, still eligible for batching. + guard startWidth != endWidth else { + segment(1) + return + } + + let fraction = 1.0 / Double(n) + for i in 0.. Int + { + if let requested { return max(1, requested) } + let delta = abs(endWidth - startWidth) + guard delta > 0 else { return 1 } + return min(maxTaperSteps, max(1, Int((delta / taperWidthQuantum).rounded(.up)))) + } + // MARK: - Pen public func penDown() { diff --git a/Tests/TortoiseCoreTests/TortoiseCoreTests.swift b/Tests/TortoiseCoreTests/TortoiseCoreTests.swift index ab9d052..2170bb2 100644 --- a/Tests/TortoiseCoreTests/TortoiseCoreTests.swift +++ b/Tests/TortoiseCoreTests/TortoiseCoreTests.swift @@ -290,6 +290,137 @@ struct TortoiseAPITests { } } +// MARK: - Pen-width taper + +@Suite("Pen-width taper") +@MainActor +struct PenWidthTaperTests { + /// The widths of the strokes a command stream produces, in order. + private func strokeWidths(_ t: Tortoise) -> [Double] { + CommandPlayer.play(commands: t.commands).compactMap { $0.newStroke?.width } + } + + @Test("no width change records a single plain forward") + func noWidthChangeIsFree() { + let t = Tortoise() + t.penWidth = 3 + t.forward(100, widthTo: 3) + #expect(t.commands == [.penWidth(3), .forward(100)]) + } + + @Test("taper ends at exactly the requested width") + func endsAtRequestedWidth() { + let t = Tortoise() + t.forward(100, widthTo: 5) + #expect(t.penWidth == 5) + #expect(t.commands.last == .penWidth(5)) + } + + @Test("taper travels the full distance") + func travelsFullDistance() { + let t = Tortoise() + t.forward(100, widthTo: 9) + #expect(isClose(t.position, Point(x: 0, y: 100))) + } + + @Test("step count scales with the width delta, not the distance") + func stepCountScalesWithWidthDelta() { + let gentle = Tortoise() + gentle.forward(500, widthTo: 2) // delta 1 -> ceil(1 / 0.25) = 4 + #expect(gentle.commands.filter { $0 == .forward(125) }.count == 4) + + let steep = Tortoise() + steep.forward(10, widthTo: 5) // delta 4 -> ceil(4 / 0.25) = 16 + #expect(strokeWidths(steep).count == 16) + } + + @Test("automatic step count is capped") + func stepCountIsCapped() { + let t = Tortoise() + t.forward(100, widthTo: 1000) + #expect(strokeWidths(t).count == Tortoise.maxTaperSteps) + } + + @Test("explicit steps override the automatic count") + func explicitSteps() { + let t = Tortoise() + t.forward(100, widthTo: 5, steps: 4) + let widths = strokeWidths(t) + #expect(widths.count == 4) + // Midpoint sampling across 1 -> 5: 1.5, 2.5, 3.5, 4.5. + #expect(zip(widths, [1.5, 2.5, 3.5, 4.5]).allSatisfy(isClose)) + } + + @Test("widths increase monotonically and stay inside the requested range") + func widthsAreMonotonicAndBounded() { + let t = Tortoise() + t.penWidth = 2 + t.forward(100, widthTo: 8) + let widths = strokeWidths(t) + #expect(widths == widths.sorted()) + #expect(widths.allSatisfy { $0 > 2 && $0 < 8 }) + } + + @Test("a shrinking taper is monotonically decreasing") + func shrinkingTaper() { + let t = Tortoise() + t.penWidth = 8 + t.forward(100, widthTo: 1) + let widths = strokeWidths(t) + #expect(widths == widths.sorted(by: >)) + #expect(t.penWidth == 1) + } + + @Test("negative end width is clamped to zero") + func negativeEndWidthClamped() { + let t = Tortoise() + t.forward(100, widthTo: -4) + #expect(t.penWidth == 0) + #expect(strokeWidths(t).allSatisfy { $0 >= 0 }) + } + + @Test("backward taper mirrors forward") + func backwardTaper() { + let t = Tortoise() + t.backward(100, widthTo: 4) + #expect(isClose(t.position, Point(x: 0, y: -100))) + #expect(t.penWidth == 4) + } + + @Test("tapered arc lands where an undivided arc would") + func taperedArcEndsInTheSamePlace() { + let plain = Tortoise() + plain.circle(radius: 50, extent: 135) + + let tapered = Tortoise() + tapered.circle(radius: 50, extent: 135, widthTo: 6) + + #expect(isClose(tapered.position, plain.position)) + #expect(isClose(tapered.heading, plain.heading)) + #expect(tapered.penWidth == 6) + } + + @Test("tapered arc emits one arc stroke per step") + func taperedArcStepCount() { + let t = Tortoise() + t.circle(radius: 50, extent: 180, widthTo: 3, steps: 6) + let frames = CommandPlayer.play(commands: t.commands) + let arcs = frames.compactMap(\.newArcStroke) + #expect(arcs.count == 6) + #expect(arcs.allSatisfy { isClose($0.sweep, 30) }) + } + + @Test("taper with the pen up draws nothing but still moves and sets width") + func taperWithPenUp() { + let t = Tortoise() + t.penUp() + t.forward(100, widthTo: 6) + #expect(strokeWidths(t).isEmpty) + #expect(isClose(t.position, Point(x: 0, y: 100))) + #expect(t.penWidth == 6) + } +} + // MARK: - CommandPlayer @Suite("CommandPlayer") diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.taperedStrokes.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.taperedStrokes.svg new file mode 100644 index 0000000..020f2c8 --- /dev/null +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.taperedStrokes.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Tests/TortoiseTestSupport/DrawingScenarios.swift b/Tests/TortoiseTestSupport/DrawingScenarios.swift index 5292052..2ba13d3 100644 --- a/Tests/TortoiseTestSupport/DrawingScenarios.swift +++ b/Tests/TortoiseTestSupport/DrawingScenarios.swift @@ -5,6 +5,7 @@ extension DrawingScenario { public static let all: [DrawingScenario] = [ linesAndTurns, penStyles, + taperedStrokes, arcs, negativeRadiusArcs, filledShapes, @@ -59,6 +60,41 @@ extension DrawingScenario { } } + /// Covers the pen-width taper sugar: growing and shrinking straight tapers, + /// a tapered arc, and an explicit low `steps:` count (visibly stepped by + /// design — it pins that the caller's override is honored). + public static let taperedStrokes = DrawingScenario("taperedStrokes") { t in + t.penUp() + t.setPosition(x: -150, y: 120) + t.penDown() + + t.penColor = .blue + t.penWidth = 1 + t.heading = 90 + t.forward(280, widthTo: 12) + + t.penUp() + t.setPosition(x: -150, y: 40) + t.penDown() + t.penColor = .red + t.forward(280, widthTo: 1) + + t.penUp() + t.setPosition(x: -150, y: -30) + t.penDown() + t.penColor = .green + t.penWidth = 1 + t.forward(280, widthTo: 12, steps: 5) + + t.penUp() + t.setPosition(x: 0, y: -140) + t.penDown() + t.penColor = .purple + t.penWidth = 1 + t.heading = 90 + t.circle(radius: 70, extent: 270, widthTo: 10) + } + /// Covers `arc`: full circle, half circle (CCW), and negative extent (CW). public static let arcs = DrawingScenario("arcs") { t in t.circle(radius: 60) diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.taperedStrokes.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.taperedStrokes.png new file mode 100644 index 0000000..8898d4b Binary files /dev/null and b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.taperedStrokes.png differ diff --git a/docs/examples/tapered-petals.svg b/docs/examples/tapered-petals.svg new file mode 100644 index 0000000..33ae622 --- /dev/null +++ b/docs/examples/tapered-petals.svg @@ -0,0 +1,629 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file