Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<rect>` 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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
| <a href="Sources/Examples/Gallery/CircleRosette.swift"><img src="docs/examples/circle-rosette.svg" width="230" alt="Circle Rosette"></a> | <a href="Sources/Examples/Gallery/FilledStar.swift"><img src="docs/examples/filled-star.svg" width="230" alt="Filled Star"></a> | <a href="Sources/Examples/Gallery/Waves.swift"><img src="docs/examples/waves.svg" width="230" alt="Waves"></a> |
| [Circle Rosette](Sources/Examples/Gallery/CircleRosette.swift) | [Filled Star](Sources/Examples/Gallery/FilledStar.swift) | [Waves](Sources/Examples/Gallery/Waves.swift) |
| <a href="Sources/Examples/Gallery/TaperedPetals.swift"><img src="docs/examples/tapered-petals.svg" width="230" alt="Tapered Petals"></a> | | |
| [Tapered Petals](Sources/Examples/Gallery/TaperedPetals.swift) | | |

## Showcase

Expand Down
1 change: 1 addition & 0 deletions Sources/Examples/Gallery/Gallery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ public enum Gallery {
("circle-rosette", CircleRosette.draw),
("filled-star", FilledStar.draw),
("waves", Waves.draw),
("tapered-petals", TaperedPetals.draw),
]
}
62 changes: 62 additions & 0 deletions Sources/Examples/Gallery/TaperedPetals.swift
Original file line number Diff line number Diff line change
@@ -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..<petals {
let t = Double(i) / Double(petals)

// A curved petal: thick at the hub, tapering away to nothing.
🐒.penUp()
🐒.home()
🐒.heading = t * 360
🐒.penColor = hue(t)
🐒.penWidth = 9
🐒.penDown()
🐒.circle(radius: 60, extent: 135, widthTo: 0.5)

// A straight ray bisecting the gap, tapering the other way: broad
// where it leaves the hub and sharpened to a point at the rim.
🐒.penUp()
🐒.home()
🐒.heading = (t + 0.5 / Double(petals)) * 360
🐒.forward(40)
🐒.penColor = hue(t + 0.5)
🐒.penWidth = 5
🐒.penDown()
🐒.forward(50, widthTo: 0.5)
}

🐒.penUp()
🐒.home()
🐒.penColor = .black
🐒.dot(size: 14)
}

/// A smooth trip around the color wheel, so neighbouring strokes differ.
private static func hue(_ t: Double) -> 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()
}
17 changes: 17 additions & 0 deletions Sources/TortoiseCore/Documentation.docc/TortoiseCore.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
101 changes: 101 additions & 0 deletions Sources/TortoiseCore/Tortoise.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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 `<line>` 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..<n {
// Sampled at the sub-segment's *midpoint*, so the width error is
// centered rather than accumulated on one side: half as many steps
// reach the fidelity that sampling at the leading edge would need.
let t = (Double(i) + 0.5) * fraction
record(.penWidth(startWidth + (endWidth - startWidth) * t))
segment(fraction)
}
// Midpoint sampling never emits the endpoint itself; land on it exactly
// so `penWidth` after the call is the width the caller asked for.
record(.penWidth(endWidth))
}

private static func taperSteps(from startWidth: Double, to endWidth: Double, requested: Int?)
-> 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() {
Expand Down
131 changes: 131 additions & 0 deletions Tests/TortoiseCoreTests/TortoiseCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading