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 0000000..95cf88f Binary files /dev/null and b/Tests/TortoiseUITests/__Snapshots__/TortoiseSpriteTests/hiddenSprite.1.png differ