From 7756fd14184919fc52b4aa0d57aab90532c4a33a Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Mon, 17 Aug 2026 08:02:39 +0900 Subject: [PATCH] Let a renderer draw the tortoise itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additions that only make sense together, for putting the tortoise somewhere a Canvas cannot reach — an overlay, another engine, a 3-D model standing on a real table in an immersive space. TortoiseSprite.hidden stops the canvas drawing its own cursor. It is not hideTortoise(): that records a command, which serializes and travels into every renderer, while this is a property of one view. Its halfExtent is 0, so autoFit keeps no room for a sprite it will not draw. TortoisePlayer.currentTortoiseState is where the tortoise is right now, interpolated between commands. Without it a custom cursor can only step a whole command at a time, while the line it is supposedly drawing grows smoothly underneath — currentCommandIndex changes ten times a second, and the sprite moves every display frame. ViewportMode.transform and TortoiseSprite.halfExtent become public, so the cursor lands where the sprite would have. The alternative is for callers to reimplement autoFit, which agrees on the day it is written and drifts silently afterwards. The blend moves into TortoiseState.interpolated(toward:progress:), in Core beside applying(_:) and now the only implementation of it: drawTortoise takes an already-interpolated state, and the player returns the same value the canvas draws at, so the two cannot disagree. Only position and heading move — a pen goes down at a command, not across one. --- CHANGELOG.md | 41 +++++++++++ CLAUDE.md | 4 ++ Sources/TortoiseCore/TortoiseState.swift | 32 +++++++++ Sources/TortoiseUI/CanvasModel.swift | 12 ++++ Sources/TortoiseUI/CanvasRenderer.swift | 38 ++++------ .../Documentation.docc/TortoiseUI.md | 41 +++++++++++ Sources/TortoiseUI/TortoiseCanvas.swift | 4 +- Sources/TortoiseUI/TortoisePlayer.swift | 27 +++++++ Sources/TortoiseUI/TortoiseSprite.swift | 29 +++++++- Sources/TortoiseUI/ViewportMode.swift | 11 ++- .../TortoiseCoreTests/TortoiseCoreTests.swift | 68 ++++++++++++++++++ .../TortoiseUITests/TortoisePlayerTests.swift | 46 ++++++++++++ .../TortoiseUITests/TortoiseSpriteTests.swift | 23 ++++++ .../TortoiseSpriteTests/hiddenSprite.1.png | Bin 0 -> 14088 bytes 14 files changed, 345 insertions(+), 31 deletions(-) create mode 100644 Tests/TortoiseUITests/__Snapshots__/TortoiseSpriteTests/hiddenSprite.1.png diff --git a/CHANGELOG.md b/CHANGELOG.md index d8d3b31..ad0c491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 608f21d..d023721 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` 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. diff --git a/Sources/TortoiseCore/TortoiseState.swift b/Sources/TortoiseCore/TortoiseState.swift index 21d0203..8257de6 100644 --- a/Sources/TortoiseCore/TortoiseState.swift +++ b/Sources/TortoiseCore/TortoiseState.swift @@ -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: diff --git a/Sources/TortoiseUI/CanvasModel.swift b/Sources/TortoiseUI/CanvasModel.swift index 84d76f6..fc0671b 100644 --- a/Sources/TortoiseUI/CanvasModel.swift +++ b/Sources/TortoiseUI/CanvasModel.swift @@ -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 diff --git a/Sources/TortoiseUI/CanvasRenderer.swift b/Sources/TortoiseUI/CanvasRenderer.swift index a9c745e..bda398d 100644 --- a/Sources/TortoiseUI/CanvasRenderer.swift +++ b/Sources/TortoiseUI/CanvasRenderer.swift @@ -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 } } diff --git a/Sources/TortoiseUI/Documentation.docc/TortoiseUI.md b/Sources/TortoiseUI/Documentation.docc/TortoiseUI.md index cb6d820..87cfb74 100644 --- a/Sources/TortoiseUI/Documentation.docc/TortoiseUI.md +++ b/Sources/TortoiseUI/Documentation.docc/TortoiseUI.md @@ -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 diff --git a/Sources/TortoiseUI/TortoiseCanvas.swift b/Sources/TortoiseUI/TortoiseCanvas.swift index 2a3e028..d73e8d7 100644 --- a/Sources/TortoiseUI/TortoiseCanvas.swift +++ b/Sources/TortoiseUI/TortoiseCanvas.swift @@ -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 diff --git a/Sources/TortoiseUI/TortoisePlayer.swift b/Sources/TortoiseUI/TortoisePlayer.swift index 8d02781..9392534 100644 --- a/Sources/TortoiseUI/TortoisePlayer.swift +++ b/Sources/TortoiseUI/TortoisePlayer.swift @@ -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 diff --git a/Sources/TortoiseUI/TortoiseSprite.swift b/Sources/TortoiseUI/TortoiseSprite.swift index 15a6355..3918960 100644 --- a/Sources/TortoiseUI/TortoiseSprite.swift +++ b/Sources/TortoiseUI/TortoiseSprite.swift @@ -30,6 +30,26 @@ 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 { @@ -37,13 +57,20 @@ extension TortoiseSprite { /// 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 } } } diff --git a/Sources/TortoiseUI/ViewportMode.swift b/Sources/TortoiseUI/ViewportMode.swift index c935417..1aeea93 100644 --- a/Sources/TortoiseUI/ViewportMode.swift +++ b/Sources/TortoiseUI/ViewportMode.swift @@ -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 { diff --git a/Tests/TortoiseCoreTests/TortoiseCoreTests.swift b/Tests/TortoiseCoreTests/TortoiseCoreTests.swift index 99194e7..ab9d052 100644 --- a/Tests/TortoiseCoreTests/TortoiseCoreTests.swift +++ b/Tests/TortoiseCoreTests/TortoiseCoreTests.swift @@ -614,6 +614,74 @@ struct ColorTests { } } +// MARK: - Interpolation between commands + +@Suite("TortoiseState interpolation") +struct TortoiseStateInterpolationTests { + private let north = TortoiseState.default + + @Test("halfway along a move is halfway between the endpoints") + func positionHalfway() { + let next = north.applying(.forward(100)) + let mid = north.interpolated(toward: next, progress: 0.5) + #expect(isClose(mid.position, Point(x: 0, y: 50))) + } + + @Test("the ends are exactly the states themselves") + func endpointsAreExact() { + let next = north.applying(.forward(100)) + #expect(north.interpolated(toward: next, progress: 0) == north) + #expect(north.interpolated(toward: next, progress: 1) == next) + } + + @Test("progress outside 0...1 is clamped, so an overrunning timer cannot overshoot") + func progressClamped() { + let next = north.applying(.forward(100)) + #expect(north.interpolated(toward: next, progress: 2.5) == next) + #expect(north.interpolated(toward: next, progress: -1) == north) + } + + @Test("a turn takes the short way round, even across 0°") + func headingTakesShortArc() { + var from = TortoiseState.default + from.heading = 350 + var to = TortoiseState.default + to.heading = 10 + // 350° → 10° is 20° forward, not 340° back. + #expect(isClose(from.interpolated(toward: to, progress: 0.5).heading, 0)) + #expect(isClose(to.interpolated(toward: from, progress: 0.5).heading, 0)) + } + + @Test("the interpolated heading is normalized to [0, 360)") + func headingNormalized() { + var from = TortoiseState.default + from.heading = 350 + var to = TortoiseState.default + to.heading = 10 + let heading = from.interpolated(toward: to, progress: 0.9).heading + #expect(heading >= 0 && heading < 360) + #expect(isClose(heading, 8)) + } + + @Test("only position and heading move — a pen is never half down") + func onlyPoseInterpolates() { + var from = TortoiseState.default + from.isPenDown = false + from.isVisible = false + from.penWidth = 1 + var to = from.applying(.forward(100)) + to = to.applying(.penDown) + to = to.applying(.showTortoise) + to = to.applying(.penWidth(9)) + + let mid = from.interpolated(toward: to, progress: 0.5) + #expect(isClose(mid.position, Point(x: 0, y: 50))) + #expect(mid.isPenDown == false) + #expect(mid.isVisible == false) + #expect(isClose(mid.penWidth, 1)) + } +} + // MARK: - Point @Suite("Point") diff --git a/Tests/TortoiseUITests/TortoisePlayerTests.swift b/Tests/TortoiseUITests/TortoisePlayerTests.swift index 1c2eace..e778ac3 100644 --- a/Tests/TortoiseUITests/TortoisePlayerTests.swift +++ b/Tests/TortoiseUITests/TortoisePlayerTests.swift @@ -179,12 +179,58 @@ struct TortoisePlayerTests { let player = TortoisePlayer() #expect(player.currentCommandIndex == -1) #expect(!player.isFinished) + #expect(player.currentTortoiseState == nil) player.step() player.seek(to: 3) player.isPaused = true player.isPaused = false } + // MARK: - currentTortoiseState + + @Test("currentTortoiseState is mid-command, not the last committed one") + func currentStateIsInterpolated() { + let model = makeSquareModel() + let player = TortoisePlayer() + player.model = model + + // Speed 5 is 0.1 s per command, so 0.05 s is halfway through the + // first `forward(100)`: the tortoise stands at y = 50 with nothing + // committed yet. A cursor driven by `currentCommandIndex` alone would + // still be at the origin, and would jump the whole 100 at once. + let t0 = Date(timeIntervalSinceReferenceDate: 0) + model.tick(date: t0) + model.tick(date: t0.addingTimeInterval(0.05)) + + #expect(player.currentCommandIndex == -1) + guard let state = player.currentTortoiseState else { + Issue.record("an attached player should report a state") + return + } + #expect(abs(state.position.y - 50) < 0.001) + #expect(abs(state.position.x) < 0.001) + } + + @Test("it is exactly the state the canvas draws its sprite at") + func currentStateMatchesTheCanvas() { + let model = makeSquareModel() + let player = TortoisePlayer() + player.model = model + let t0 = Date(timeIntervalSinceReferenceDate: 0) + model.tick(date: t0) + model.tick(date: t0.addingTimeInterval(0.05)) + #expect(player.currentTortoiseState == model.liveTortoiseState) + } + + @Test("with nothing in flight it is the committed state itself") + func currentStateAtRest() { + let model = makeSquareModel() + let player = TortoisePlayer() + player.model = model + player.step() // Commits instantly, so progress is back to 0. + #expect(player.currentTortoiseState == model.tortoiseState) + } + @Test("attached player mirrors and controls the model") func attachedPlayerControlsModel() { let model = makeSquareModel() diff --git a/Tests/TortoiseUITests/TortoiseSpriteTests.swift b/Tests/TortoiseUITests/TortoiseSpriteTests.swift index 7dfb4d9..964f972 100644 --- a/Tests/TortoiseUITests/TortoiseSpriteTests.swift +++ b/Tests/TortoiseUITests/TortoiseSpriteTests.swift @@ -43,10 +43,28 @@ struct TortoiseSpriteTests { #expect(abs(scale(image) - 400.0 / (200 + 2 * 28.284271 * tortoiseScaleMax)) < 0.0001) } + @Test("a hidden sprite has no extent, so autoFit keeps no room for it") + func hiddenHalfExtent() { + #expect(TortoiseSprite.hidden.halfExtent == 0) + + var builder = DrawingBounds.Builder() + builder.expand(to: Point(x: -100, y: -100)) + builder.expand(to: Point(x: 100, y: 100)) + let transform = ViewportMode.autoFit.transform( + canvasSize: .defaultCanvas, viewSize: CGSize(width: 400, height: 400), + drawingBounds: builder.build(), spriteHalfExtent: TortoiseSprite.hidden.halfExtent) + // The 200 x 200 drawing fills all 400 points, where the triangle would + // have cost it 40. Any margin is the caller's to add. + #expect(abs(transform.a - 2.0) < 0.0001) + } + @Test("equality distinguishes the built-in triangle from an image") func equality() { let image = Image(systemName: "tortoise") #expect(TortoiseSprite.triangle == .triangle) + #expect(TortoiseSprite.hidden == .hidden) + #expect(TortoiseSprite.hidden != .triangle) + #expect(TortoiseSprite.hidden != .image(image, size: .init(40, 40))) #expect(TortoiseSprite.triangle != .image(image, size: .init(40, 40))) #expect( TortoiseSprite.image(image, size: .init(40, 40)) == .image(image, size: .init(40, 40))) @@ -117,6 +135,11 @@ extension CGSize { assertCanvasSnapshot(sprite: .image(sprite, size: CGSize(width: 40, height: 40))) } + @Test("a hidden sprite leaves the drawing and draws no cursor") + func hiddenSprite() { + assertCanvasSnapshot(sprite: .hidden) + } + @Test("a non-square image is fitted inside the sprite size, not stretched") func imageSpriteAspectRatio() { // 160 x 80 px at scale 2 = 80 x 40 pt. In an 80 x 80 box it renders diff --git a/Tests/TortoiseUITests/__Snapshots__/TortoiseSpriteTests/hiddenSprite.1.png b/Tests/TortoiseUITests/__Snapshots__/TortoiseSpriteTests/hiddenSprite.1.png new file mode 100644 index 0000000000000000000000000000000000000000..95cf88ffa458d47cbe5e82e00d554f5ff60ec77d GIT binary patch literal 14088 zcmeHO?MqWp7(ZwCZf?HhT)w4cx7w(TR5HS{Nqvy4DS|KfQs->D_xc}1^b8K?+_UGr?AbZb^ZWhA z+6v6MA%QW02%!*@F{=n6hNDHTB0Zn#R8^$H^+mZEsDFI-9C@%+mYA;Q=c9PiRwEY2 zASE>+B@M?)?HqgvG2VU!LM_#Z_1l<9OFZXA)(qFp$Za}Ix9j>t zQfcaprS%9!8fd|$BK-nFCk~sk(u-~Qr+YI0a-o(fC@IXDYP#6#5MHZ}4?N4n7bX_h zU(PITt}lIUL0G}6wHz;yMu6h?j?WA1wjOpXD4I)SWfOLs4R$8igogP>(4iJ#RVB_q zo>O)v@#B4Gvgm}qnFTx>E1UB(=UnJy&$3p0o7kyZYh`m}PsYr*c6X+4w0D|lZg`tH zY(nxkd>#I?3ugk(1gnNPAqHCT1b70ns5dzPo)GaHcmh1}AEWJ=AM*w;b8)r1mew|G zL*>aUDb22mpwn>DZ`1h zjeHqPywO3aiCej$>>Wy9(}*}!9j=MyvMbxpN{ptv%!R6J3mV0_kE&=f!dF91mR2SLKHJJjiAO23qGjJae2 z^piYEPgjqUAHfzQG)9ThS#n+IXgX*PvSL^3QSXbLI- zw-6)_KoJw@EhJ5X3`G$W2n!%A u_+?iR7C>0=i}WBYfUrQb!(SE_W&%=<#hkK5U9!@Dl$o;4S^XK7TYmr<6B`@= literal 0 HcmV?d00001