Skip to content
Merged
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
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,46 @@
# Changelog

## 2.1.0

### Added
- **Draw the tortoise yourself.** Three additions that only make sense
together, for renderers that have to put the tortoise somewhere a `Canvas`
cannot reach — a SwiftUI overlay, a sprite in another engine, a 3-D model
standing on a real table in an immersive space:
- `TortoiseSprite.hidden` (TortoiseUI) — the canvas animates the drawing
exactly as before and draws no cursor. It is a property of one *view*,
unlike `hideTortoise()`, which records a command and so travels with the
drawing into every renderer and every serialized stream. With it,
`ViewportMode.autoFit` insets by nothing — there is no sprite to keep
clear of the edge — so the drawing runs edge to edge and any margin is the
caller's to add
- `TortoisePlayer.currentTortoiseState` (TortoiseUI) — where the tortoise
is *right now*, position and heading interpolated between commands, so a
cursor of your own walks with the line instead of jumping a command at a
time. `nil` until the player is attached to a canvas. It changes on every
display frame, so read it from somewhere already running once per frame
rather than observing it from a SwiftUI `body`
- `ViewportMode.transform(canvasSize:viewSize:drawingBounds:spriteHalfExtent:)`
and `TortoiseSprite.halfExtent` are now public — the mapping from tortoise
coordinates onto the view, so a custom cursor lands exactly where the
built-in sprite would have. Reimplementing that mapping is the failure
worth avoiding: it agrees on the day it is written and drifts silently
afterwards
- `TortoiseState.interpolated(toward:progress:)` (TortoiseCore) — the state
part-way through a command. Only position and heading move; pen state,
colors, visibility and speed come from the state being interpolated *from*,
because those change at a command rather than across one. The heading takes
the short way round (350° → 10° sweeps 20° forward), and `progress` is
clamped to 0...1. This is now the single implementation of that blend:
`CanvasRenderer` and `TortoisePlayer.currentTortoiseState` both go through
it, so a custom cursor cannot drift a fraction of a command away from the
built-in sprite

### Changed
- No behavior changes. `TortoiseSprite` gains a case, so an exhaustive
`switch` over it in your own code needs one more arm — the only source-level
effect of this release

## 2.0.0

First stable release of the 2.x series — a from-scratch rewrite of
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ Tortoise API → [TortoiseCommand] → CommandPlayer.play() → [PlaybackFrame]

**Sub-frame animation via `animationProgress`.** `CanvasModel` exposes `animationProgress: Double` (0→1) and `inProgressFrame: PlaybackFrame?`. `TortoiseCanvas` uses these to interpolate tortoise position/heading and draw partial strokes, so the tortoise visibly walks as it draws. Do not collapse this back to per-frame snapping.

**`CanvasModel.liveTortoiseState` is the only place that blend happens.** `CanvasRenderer.drawTortoise` takes an already-interpolated state rather than a pair to blend, and `TortoisePlayer.currentTortoiseState` hands back the same value — so a caller drawing its own tortoise is drawing where the built-in sprite would be, and cannot drift a fraction of a command away from it. The blend itself is `TortoiseState.interpolated(toward:progress:)`, in Core beside `applying(_:)` and for the same reason. **Only position and heading move**: a pen goes down *at* a command, not across one, so interpolating `isPenDown` or a color would invent a state the program never had. Don't reintroduce a second interpolation at a draw site.

**`TortoiseSprite.hidden` is not `hideTortoise()`,** and that is the distinction a user will get wrong. The command is part of the *drawing* — it serializes, and every renderer honors it. The sprite case is a property of one *view*, and changing it cannot change the drawing. `TortoiseState.isVisible` still reflects the command, so a custom cursor must honor it itself; the canvas checks both. `halfExtent` is 0 for `.hidden`, so `autoFit` insets by nothing and the drawing runs edge to edge — intended (there is no sprite to keep clear of the edge), and the answer when a caller asks where its margin went.

**Two-layer rendering in `TortoiseCanvas` (#35).** Committed elements draw in `CommittedLayer`, a `Canvas` *outside* the `TimelineView`; only the in-progress stroke/arc and the tortoise sprite (`AnimationLayer`) render at display refresh rate. Do not move committed-element drawing back inside the `TimelineView` — that is O(elements) Path-building per display frame and stutters at a few hundred commands. `CommittedLayer` reads `model.elements` / `backgroundColor` during *body* evaluation (snapshotted into the Canvas closure) so Observation invalidates it exactly on frame commits and step/seek/clear — keep those reads at body level rather than relying on tracking inside the Canvas rendering closure. Drawing primitives shared by both layers live in `CanvasRenderer`.

**Stroke batching in `CanvasRenderer.drawElements` (#37).** A maximal run of consecutive `.stroke` elements sharing `color` and `width` is merged into one multi-subpath `Path` and drawn with a single `ctx.stroke` — the per-element `Path` + draw-call overhead (~0.37µs) dominates committed-layer redraw, not rasterization, so this is ~6× at 10,000 strokes. Round caps apply per subpath, so the output is unchanged. Two invariants to preserve: **translucent strokes (`color.alpha < 1`) must not be batched** — overlapping segments have to blend once per stroke to match the SVG renderer's one `<line>` per stroke (`translucentOverlaps` scenario guards this) — and **`.fill` / `.arcStroke` / `.dot` are never batched**, so element order and z-order (and therefore `fillInsertionIndex`) are untouched. Batching does change antialiasing where strokes overlap (one rasterization instead of two blends), which is why the canvas goldens were re-recorded.
Expand Down
32 changes: 32 additions & 0 deletions Sources/TortoiseCore/TortoiseState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,38 @@ extension TortoiseState {
return remainder < 0 ? remainder + 360 : remainder
}

/// This state with its position and heading moved `progress` of the way
/// toward `next` — the tortoise part-way through a command.
///
/// A command stream is discrete, but the tortoise is *drawn* walking:
/// this is the state between two frames, and it is what ``TortoiseUI``'s
/// canvas draws its sprite at. Renderers that draw the tortoise
/// themselves want it for the same reason — without it a custom cursor
/// jumps a whole command at a time while the line it is supposedly
/// drawing grows smoothly underneath it.
///
/// **Only position and heading move.** Everything else — pen state,
/// colors, visibility, speed — is taken from `self`, because those change
/// *at* a command rather than across one: a pen goes down at a moment,
/// not gradually. The heading takes the short way round, so a turn from
/// 350° to 10° sweeps 20° forward rather than 340° back.
///
/// `progress` is clamped to 0...1, so a value from a timer that has
/// overrun cannot carry the tortoise past its destination.
public func interpolated(toward next: TortoiseState, progress: Double) -> TortoiseState {
let t = min(max(progress, 0), 1)
var state = self
state.position = Point(
x: position.x + t * (next.position.x - position.x),
y: position.y + t * (next.position.y - position.y)
)
var delta = next.heading - heading
while delta > 180 { delta -= 360 }
while delta < -180 { delta += 360 }
state.heading = Self.normalizedHeading(heading + t * delta)
return state
}

/// Returns the state after applying a single command.
///
/// This is the single source of truth for tortoise state transitions:
Expand Down
12 changes: 12 additions & 0 deletions Sources/TortoiseUI/CanvasModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ final class CanvasModel {
return frames[currentFrameIndex + 1]
}

/// Where the tortoise actually *is*: the committed state blended toward
/// the frame being animated toward, which is where the sprite is drawn.
///
/// The one place that blend happens. `CanvasRenderer.drawTortoise` and
/// `TortoisePlayer.currentTortoiseState` both read it, so a cursor drawn
/// by someone else cannot drift a fraction of a command away from the
/// built-in sprite.
var liveTortoiseState: TortoiseState {
guard let next = inProgressFrame, animationProgress > 0 else { return tortoiseState }
return tortoiseState.interpolated(toward: next.tortoiseState, progress: animationProgress)
}

/// Playback speed of the last committed frame (governs animation timing).
private var committedSpeed: Double {
currentFrameIndex >= 0
Expand Down
38 changes: 12 additions & 26 deletions Sources/TortoiseUI/CanvasRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,48 +103,34 @@ enum CanvasRenderer {
}
}

/// Draws the tortoise sprite at `state`, or interpolated toward `next`
/// when a frame is mid-animation (`progress` > 0).
/// Draws the tortoise sprite at `state` — already interpolated, so this
/// takes the state the tortoise is *at*, not the pair to blend.
///
/// `CanvasModel.liveTortoiseState` does the blending, and hands the same
/// value to `TortoisePlayer.currentTortoiseState`, so a caller drawing its
/// own tortoise is drawing at the same place as this.
static func drawTortoise(
_ ctx: inout GraphicsContext, state: TortoiseState,
interpolatingTo next: TortoiseState?, progress: Double,
sprite: TortoiseSprite,
_ ctx: inout GraphicsContext, state: TortoiseState, sprite: TortoiseSprite,
transform t: CGAffineTransform, scale rawScale: Double
) {
guard state.isVisible else { return }

let pos: Point
let heading: Double
if let next, progress > 0 {
pos = Point(
x: state.position.x + progress * (next.position.x - state.position.x),
y: state.position.y + progress * (next.position.y - state.position.y)
)
// Normalize heading delta to [-180, 180] so rotation takes the short arc.
var delta = next.heading - state.heading
while delta > 180 { delta -= 360 }
while delta < -180 { delta += 360 }
heading = state.heading + progress * delta
}
else {
pos = state.position
heading = state.heading
}
guard state.isVisible, sprite != .hidden else { return }

let s = min(max(rawScale, tortoiseScaleMin), tortoiseScaleMax)

let position = CGPoint(x: pos.x, y: pos.y).applying(t)
let position = CGPoint(x: state.position.x, y: state.position.y).applying(t)
var tortoiseCtx = ctx
tortoiseCtx.translateBy(x: position.x, y: position.y)
// heading 0 = north (the sprite is authored pointing up), heading 90 =
// east (CW 90°). SwiftUI rotate(by:) is CW-positive in Y-down space,
// matching tortoise heading.
tortoiseCtx.rotate(by: .degrees(heading))
tortoiseCtx.rotate(by: .degrees(state.heading))
switch sprite {
case .triangle:
drawTriangleSprite(&tortoiseCtx, size: tortoiseBaseSize * s)
case .image(let image, let size):
drawImageSprite(&tortoiseCtx, image: image, size: size, scale: s)
case .hidden:
break
}
}

Expand Down
41 changes: 41 additions & 0 deletions Sources/TortoiseUI/Documentation.docc/TortoiseUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,47 @@ aspect ratio preserved, and — like the triangle — scales with the viewport,
clamped to 0.5×–2×. ``ViewportMode/autoFit`` insets the drawing by the
sprite's half-diagonal, so a large sprite never clips at the view edge.

### Drawing the tortoise yourself

Sometimes the tortoise has to be something a `Canvas` cannot draw: a SwiftUI
view above the canvas, a sprite in another engine, a 3-D model standing on a
real table in an immersive space. Three pieces make that possible, and they
are meant to be used together.

```swift
TortoiseCanvas(🐢, player: player)
.tortoiseSprite(.hidden) // the canvas draws no cursor

// …and somewhere that already runs once per frame:
if let state = player.currentTortoiseState, state.isVisible {
let t = ViewportMode.autoFit.transform(
canvasSize: 🐢.canvasSize, viewSize: viewSize,
drawingBounds: DrawingBounds.compute(
from: CommandPlayer.play(commands: 🐢.commands)),
spriteHalfExtent: TortoiseSprite.hidden.halfExtent)
let point = CGPoint(x: state.position.x, y: state.position.y).applying(t)
// …draw your own tortoise at `point`, rotated by `state.heading`.
}
```

- ``TortoiseSprite/hidden`` stops the canvas drawing its own cursor. It is a
property of one view, unlike `hideTortoise()`, which records a command and
so travels with the drawing into every renderer and every saved stream.
- ``TortoisePlayer/currentTortoiseState`` is where the tortoise is *right
now*, interpolated between commands — so a cursor of your own walks with
the line instead of jumping a command at a time. It changes on every
display frame, which is why it wants to be read from a per-frame context
rather than observed from a SwiftUI `body`.
- ``ViewportMode/transform(canvasSize:viewSize:drawingBounds:spriteHalfExtent:)``
maps tortoise coordinates onto the view, so the cursor lands exactly where
the built-in sprite would have. Reimplementing that mapping is the thing
worth avoiding: it agrees on the day it is written, and drifts silently
afterwards.

With ``TortoiseSprite/hidden`` the `.autoFit` inset becomes zero — there is no
sprite to keep clear of the edge — so the drawing runs edge to edge and any
margin is yours to add.

## Topics

### Views
Expand Down
4 changes: 1 addition & 3 deletions Sources/TortoiseUI/TortoiseCanvas.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,7 @@ private struct AnimationLayer: View {
transform: t, scale: s)
}
CanvasRenderer.drawTortoise(
&ctx, state: model.tortoiseState,
interpolatingTo: model.inProgressFrame?.tortoiseState,
progress: model.animationProgress, sprite: sprite,
&ctx, state: model.liveTortoiseState, sprite: sprite,
transform: t, scale: s)
}
.onChange(of: timeline.date) { _, date in
Expand Down
27 changes: 27 additions & 0 deletions Sources/TortoiseUI/TortoisePlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,33 @@ public final class TortoisePlayer {
/// `false` until the player is attached to a ``TortoiseCanvas``.
public var isFinished: Bool { model?.isFinished ?? false }

/// The tortoise exactly as the canvas is drawing it right now — position
/// and heading **interpolated between commands**, so this moves with the
/// line instead of jumping a command at a time. `nil` until the player is
/// attached to a ``TortoiseCanvas``.
///
/// It is for drawing the tortoise *yourself*, somewhere the canvas cannot
/// reach: an overlay above the view, a sprite in another engine, a 3-D
/// model standing on a real table in an immersive space. Set
/// ``TortoiseSprite/hidden`` so the canvas leaves the cursor to you, and
/// map the position through
/// ``ViewportMode/transform(canvasSize:viewSize:drawingBounds:spriteHalfExtent:)``
/// to land where the built-in sprite would have been.
///
/// It changes on every display frame while a drawing plays, which is what
/// makes the motion smooth — and also what makes it the wrong thing to
/// read from a SwiftUI `body`, since a view that observes it re-evaluates
/// at the display refresh rate. Read it from somewhere that already runs
/// once per frame (a RealityKit scene-update subscription, a
/// `TimelineView` closure, a `CADisplayLink`), and leave
/// ``currentCommandIndex`` — which changes once per command — to drive the
/// interface.
///
/// ``TortoiseState/isVisible`` follows the stream's `showTortoise` /
/// `hideTortoise` commands, so a cursor of your own should honor it the
/// way the sprite does.
public var currentTortoiseState: TortoiseState? { model?.liveTortoiseState }

// MARK: - Control

/// Suspends playback while `true`; the canvas stops advancing and stops
Expand Down
29 changes: 28 additions & 1 deletion Sources/TortoiseUI/TortoiseSprite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,47 @@ public enum TortoiseSprite: Sendable, Equatable {
/// Use `Image(uiImage:)` / `Image(nsImage:)` to pass an image you already
/// have in memory.
case image(Image, size: CGSize)

/// Draw no tortoise at all — for when you are drawing your own.
///
/// The drawing itself animates exactly as before; only the sprite is
/// omitted. Pair it with ``TortoisePlayer/currentTortoiseState`` and
/// ``ViewportMode/transform(canvasSize:viewSize:drawingBounds:spriteHalfExtent:)``
/// to put a cursor of your own — a SwiftUI overlay, a RealityKit entity,
/// a sprite in another engine — exactly where the canvas would have drawn
/// the triangle.
///
/// This is **not** `Tortoise.hideTortoise()`. That records a command: it
/// travels with the drawing into every renderer and every serialized
/// stream, and it is part of what the program says. This is a property of
/// one view, and changing it cannot change the drawing.
///
/// ``ViewportMode/autoFit`` then insets the drawing by nothing, there
/// being no sprite to keep clear of the edge, so the drawing runs edge to
/// edge and any margin is yours to add (SwiftUI's `.padding()`, or a
/// smaller frame).
case hidden
}

extension TortoiseSprite {
/// Maximum distance from the tortoise's position to any point of the
/// sprite at scale 1 — the half-diagonal, so it holds at every heading.
/// ``ViewportMode/autoFit`` insets the drawing by this much (times
/// `tortoiseScaleMax`) so the sprite never clips at the view edge.
var halfExtent: Double {
///
/// Public so that a renderer drawing its own tortoise can hand the same
/// value to
/// ``ViewportMode/transform(canvasSize:viewSize:drawingBounds:spriteHalfExtent:)``
/// that the canvas uses, and so land its cursor in the same place.
public var halfExtent: Double {
switch self {
case .triangle:
// The triangle's farthest point is its tip, at `tortoiseBaseSize`.
return tortoiseBaseSize
case .image(_, let size):
return (size.width * size.width + size.height * size.height).squareRoot() / 2
case .hidden:
return 0
}
}
}
11 changes: 10 additions & 1 deletion Sources/TortoiseUI/ViewportMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,16 @@ extension ViewportMode {
/// `spriteHalfExtent` is the tortoise sprite's half-diagonal at scale 1
/// (``TortoiseSprite/halfExtent``); `.autoFit` insets the drawing by it so
/// the sprite never clips at the view edge.
func transform(
///
/// Public so that a caller drawing its own tortoise
/// (``TortoisePlayer/currentTortoiseState``) can put it where the canvas
/// would have. The three drawing arguments all come off one canvas:
/// `canvasSize` from ``Tortoise/canvasSize``, `drawingBounds` from
/// `DrawingBounds.compute(from: CommandPlayer.play(commands:))`, and
/// `viewSize` the size the view was actually laid out at. Reimplementing
/// this instead is the failure worth avoiding: it agrees on the day it is
/// written, and drifts silently.
public func transform(
canvasSize: Size, viewSize: CGSize, drawingBounds: DrawingBounds?,
spriteHalfExtent: Double
) -> CGAffineTransform {
Expand Down
Loading