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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<rect>` 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 `<line>` 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 `<path>` 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
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),
]
}
54 changes: 54 additions & 0 deletions Sources/Examples/Gallery/TaperedPetals.swift
Original file line number Diff line number Diff line change
@@ -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..<petals {
let hue = Double(i) / Double(petals)
🐒.penColor = petalColor(hue: hue)
🐒.penUp()
🐒.home()
🐒.heading = hue * 360
🐒.penDown()

🐒.penWidth = 1
🐒.circle(radius: 90, extent: 110, widthTo: 13)
🐒.circle(radius: 90, extent: 110, widthTo: 1)
}
}

/// A hue sweep around the rosette.
///
/// 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 β€” the same
/// double-blend any two overlapping translucent strokes have always had,
/// but conspicuous as a dot when it lands on a showcase drawing.
private static func petalColor(hue: Double) -> 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()
}
21 changes: 21 additions & 0 deletions Sources/TortoiseCore/ArcStroke.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
26 changes: 26 additions & 0 deletions Sources/TortoiseCore/Codable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -114,6 +115,7 @@ extension TortoiseCommand: Codable {
case backgroundColor
case clear
case arc
case taperedArc
case dot
}

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
}
Expand All @@ -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:
Expand Down Expand Up @@ -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)
}
Expand Down
18 changes: 16 additions & 2 deletions Sources/TortoiseCore/CommandPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}}` |
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions Sources/TortoiseCore/Documentation.docc/TortoiseCore.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -58,6 +81,7 @@ to produce output.
### Geometry

- ``DrawingBounds``
- ``StrokeOutline``

### Value Types

Expand Down
27 changes: 26 additions & 1 deletion Sources/TortoiseCore/Stroke.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading