From 555fe5b6e38b139d11c92ec2144e27182fd4f914 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 4 Aug 2026 08:50:03 +0900 Subject: [PATCH] Allow drawing the tortoise as a custom image (TortoiseUI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `TortoiseSprite` and the `.tortoiseSprite(_:)` environment modifier, so the tortoise can be drawn as the user's own image instead of the built-in green triangle: TortoiseCanvas(🐢) .tortoiseSprite(.image(Image("Turtle"), size: CGSize(width: 40, height: 40))) The sprite is a view-side setting, not a `TortoiseCommand`, so it never enters the serialized stream and TortoiseSVG is unaffected. The image is centered on the tortoise's position and rotated so its top edge faces the heading, with transparency preserved. `size` is a bounding box in points at viewport scale 1: the image is scaled to fit inside it with its aspect ratio preserved, then scales with the viewport exactly like the triangle (clamped to 0.5x-2x). `ViewportMode.autoFit`'s edge inset now derives from the sprite's half-diagonal instead of a triangle-sized constant, so a large sprite never clips at the view edge. Both canvas layers read the environment value — only `AnimationLayer` draws the sprite, but the two layers must derive the identical transform. The default is `.triangle` and its rendering is unchanged, which the existing canvas goldens confirm. --- CHANGELOG.md | 3 + CLAUDE.md | 2 + README.md | 17 +++ Sources/TortoiseUI/CanvasRenderer.swift | 57 ++++++-- .../Documentation.docc/TortoiseUI.md | 25 +++- Sources/TortoiseUI/TortoiseCanvas.swift | 39 ++++- .../TortoiseRenderingConstants.swift | 5 +- Sources/TortoiseUI/TortoiseSprite.swift | 49 +++++++ Sources/TortoiseUI/ViewportMode.swift | 13 +- .../TortoiseUITests/TortoiseSpriteTests.swift | 134 ++++++++++++++++++ .../TortoiseSpriteTests/imageSprite.1.png | Bin 0 -> 14699 bytes .../imageSpriteAspectRatio.1.png | Bin 0 -> 14584 bytes 12 files changed, 321 insertions(+), 23 deletions(-) create mode 100644 Sources/TortoiseUI/TortoiseSprite.swift create mode 100644 Tests/TortoiseUITests/TortoiseSpriteTests.swift create mode 100644 Tests/TortoiseUITests/__Snapshots__/TortoiseSpriteTests/imageSprite.1.png create mode 100644 Tests/TortoiseUITests/__Snapshots__/TortoiseSpriteTests/imageSpriteAspectRatio.1.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e077d8..088c4f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- `TortoiseSprite` (TortoiseUI) and the `.tortoiseSprite(_:)` environment modifier — the tortoise can now be drawn as your own image instead of the built-in green triangle: `.tortoiseSprite(.image(Image("Turtle"), size: CGSize(width: 40, height: 40)))`. The image is centered on the tortoise's position and rotated so its top edge faces the heading (supply artwork that points up), with transparency preserved. `size` is a bounding box in points at viewport scale 1 — the image is scaled to fit inside it with its aspect ratio preserved, then scales with the viewport exactly like the triangle (clamped to 0.5×–2×). `ViewportMode.autoFit` now insets the drawing by the sprite's half-diagonal instead of a triangle-sized constant, so a large sprite never clips at the view edge. The default remains `.triangle`, and its rendering is unchanged + ### Changed - CI: `actions/cache` 4 → 6 and `codecov/codecov-action` 5 → 7 ([#40](https://github.com/temoki/TortoiseGraphics2/pull/40), [#39](https://github.com/temoki/TortoiseGraphics2/pull/39)) - Test dependency: `swift-snapshot-testing` 1.19.3 → 1.19.4. It is only used by the test targets, so packages depending on TortoiseGraphics2 are unaffected ([#41](https://github.com/temoki/TortoiseGraphics2/pull/41)) diff --git a/CLAUDE.md b/CLAUDE.md index 9c967ce..6dd2b75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,8 @@ Tortoise API → [TortoiseCommand] → CommandPlayer.play() → [PlaybackFrame] **`backgroundColor` defaults to `.clear`.** `TortoiseCanvas` skips the background fill when `alpha == 0`, letting SwiftUI's `.background()` modifier control the canvas background. The SVG renderer likewise omits the `` element when the background is transparent. +**`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 - **Tortoise space**: center origin, Y-up, heading 0 = north, clockwise positive. Arc angles: 0 = east, CCW positive (standard math). diff --git a/README.md b/README.md index c69ac8f..83c0e2e 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,23 @@ TortoiseCanvas(🐢) With `.autoFit`, use SwiftUI's `.padding()` to add space around the drawing. +#### Tortoise sprite (TortoiseCanvas) + +By default the tortoise is drawn as a green triangle. Use the +`.tortoiseSprite(_:)` modifier to draw your own image instead: + +```swift +TortoiseCanvas(🐢) + .tortoiseSprite(.image(Image("Turtle"), size: CGSize(width: 40, height: 40))) +``` + +The image is centered on the tortoise's position and rotated so its **top +edge** faces the heading — so supply artwork that points up. Transparency is +preserved, and `size` acts as a bounding box: the image is scaled to fit +inside it without distorting its aspect ratio, then scales with the viewport +just like the built-in triangle (clamped to 0.5×–2×). Use `Image(uiImage:)` / +`Image(nsImage:)` for an image you already have in memory. + ## Architecture ``` diff --git a/Sources/TortoiseUI/CanvasRenderer.swift b/Sources/TortoiseUI/CanvasRenderer.swift index 876f140..a9c745e 100644 --- a/Sources/TortoiseUI/CanvasRenderer.swift +++ b/Sources/TortoiseUI/CanvasRenderer.swift @@ -108,6 +108,7 @@ enum CanvasRenderer { static func drawTortoise( _ ctx: inout GraphicsContext, state: TortoiseState, interpolatingTo next: TortoiseState?, progress: Double, + sprite: TortoiseSprite, transform t: CGAffineTransform, scale rawScale: Double ) { guard state.isVisible else { return } @@ -131,27 +132,59 @@ enum CanvasRenderer { } let s = min(max(rawScale, tortoiseScaleMin), tortoiseScaleMax) - let tortoiseSize = tortoiseBaseSize * s - - // Triangle pointing north (tip at -Y in screen space = up on screen). - var path = Path() - path.move(to: CGPoint(x: 0, y: -tortoiseSize)) - path.addLine(to: CGPoint(x: -tortoiseSize * 0.6, y: tortoiseSize * 0.5)) - path.addLine(to: CGPoint(x: tortoiseSize * 0.6, y: tortoiseSize * 0.5)) - path.closeSubpath() let position = CGPoint(x: pos.x, y: pos.y).applying(t) var tortoiseCtx = ctx tortoiseCtx.translateBy(x: position.x, y: position.y) - // heading 0 = north (tip already points up), heading 90 = east (CW 90°). - // SwiftUI rotate(by:) is CW-positive in Y-down space, matching tortoise heading. + // 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.fill(path, with: .color(.green.opacity(0.7))) - tortoiseCtx.stroke(path, with: .color(.green), lineWidth: 1.5) + switch sprite { + case .triangle: + drawTriangleSprite(&tortoiseCtx, size: tortoiseBaseSize * s) + case .image(let image, let size): + drawImageSprite(&tortoiseCtx, image: image, size: size, scale: s) + } } // MARK: - Private helpers + /// Draws the built-in triangle, centered on the (already translated and + /// rotated) context's origin and pointing north — tip at -Y in screen + /// space, which is up on screen. + private static func drawTriangleSprite(_ ctx: inout GraphicsContext, size: Double) { + var path = Path() + path.move(to: CGPoint(x: 0, y: -size)) + path.addLine(to: CGPoint(x: -size * 0.6, y: size * 0.5)) + path.addLine(to: CGPoint(x: size * 0.6, y: size * 0.5)) + path.closeSubpath() + ctx.fill(path, with: .color(.green.opacity(0.7))) + ctx.stroke(path, with: .color(.green), lineWidth: 1.5) + } + + /// Draws a custom sprite image centered on the (already translated and + /// rotated) context's origin, scaled to fit inside `size` × `scale` + /// without distorting its aspect ratio. + private static func drawImageSprite( + _ ctx: inout GraphicsContext, image: Image, size: CGSize, scale s: Double + ) { + let box = CGSize(width: size.width * s, height: size.height * s) + guard box.width > 0, box.height > 0 else { return } + // Resolving is what exposes the image's intrinsic size, which the + // aspect fit needs. A `ResolvedImage` belongs to the context that + // produced it, so this cannot be hoisted out of the per-frame draw. + let resolved = ctx.resolve(image) + let intrinsic = resolved.size + guard intrinsic.width > 0, intrinsic.height > 0 else { return } + let fit = min(box.width / intrinsic.width, box.height / intrinsic.height) + let width = intrinsic.width * fit + let height = intrinsic.height * fit + ctx.draw( + resolved, + in: CGRect(x: -width / 2, y: -height / 2, width: width, height: height)) + } + /// Strokes are recorded one per command, so consecutive segments are /// separate subpaths. Round caps overlap at the shared endpoint, making /// joints look connected — matching the SVG renderer's diff --git a/Sources/TortoiseUI/Documentation.docc/TortoiseUI.md b/Sources/TortoiseUI/Documentation.docc/TortoiseUI.md index ff9843c..cb6d820 100644 --- a/Sources/TortoiseUI/Documentation.docc/TortoiseUI.md +++ b/Sources/TortoiseUI/Documentation.docc/TortoiseUI.md @@ -25,7 +25,8 @@ TortoiseCanvas(🐢) Use the `.tortoiseViewport(_:)` modifier to change how the drawing maps onto the view. The default is ``ViewportMode/autoFit``, which scales and centers -to fit the actual drawing bounding box. +to fit the actual drawing bounding box. Use `.tortoiseSprite(_:)` to draw the +tortoise as your own image instead of the built-in triangle. ### Speed @@ -76,6 +77,24 @@ command, and changing it never rewinds playback. - **`.scaleToFit`** — fits the full logical canvas inside the view, letterboxed. - **`.original`** — 1 tortoise unit = 1 point, origin at view center. +### Tortoise sprite + +``TortoiseSprite`` controls how the tortoise itself is drawn. The default is +``TortoiseSprite/triangle``; pass ``TortoiseSprite/image(_:size:)`` to use +your own artwork: + +```swift +TortoiseCanvas(🐢) + .tortoiseSprite(.image(Image("Turtle"), size: CGSize(width: 40, height: 40))) +``` + +The image is centered on the tortoise's position and rotated so its top edge +faces the heading, so supply artwork that points up. `size` is a bounding box +in points at viewport scale 1: the image is scaled to fit inside it with its +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. + ## Topics ### Views @@ -89,3 +108,7 @@ command, and changing it never rewinds playback. ### Viewport - ``ViewportMode`` + +### Appearance + +- ``TortoiseSprite`` diff --git a/Sources/TortoiseUI/TortoiseCanvas.swift b/Sources/TortoiseUI/TortoiseCanvas.swift index c6ecfd6..2a3e028 100644 --- a/Sources/TortoiseUI/TortoiseCanvas.swift +++ b/Sources/TortoiseUI/TortoiseCanvas.swift @@ -5,7 +5,9 @@ import TortoiseCore /// /// Pass a ``Tortoise`` instance, or describe the drawing inline with a closure. /// The view plays back the command stream using `TimelineView` and `Canvas`. -/// Use `.tortoiseViewport(_:)` to control how the drawing maps onto the view. +/// Use `.tortoiseViewport(_:)` to control how the drawing maps onto the view, +/// and `.tortoiseSprite(_:)` to draw the tortoise as your own image instead of +/// the built-in triangle. /// /// ```swift /// // Existing-instance form @@ -103,6 +105,9 @@ public struct TortoiseCanvas: View { private struct CommittedLayer: View { let model: CanvasModel @Environment(\.tortoiseViewport) private var viewportMode + // Only for the autoFit sprite inset — this layer never draws the sprite, + // but both layers must derive the same transform. + @Environment(\.tortoiseSprite) private var sprite var body: some View { // Snapshot the committed properties during body evaluation: these @@ -115,7 +120,7 @@ private struct CommittedLayer: View { Canvas { ctx, size in let t = viewportMode.transform( canvasSize: model.canvasSize, viewSize: size, - drawingBounds: model.drawingBounds) + drawingBounds: model.drawingBounds, spriteHalfExtent: sprite.halfExtent) let s = (t.a * t.a + t.b * t.b).squareRoot() CanvasRenderer.drawBackground(&ctx, size: size, color: background) CanvasRenderer.drawElements(&ctx, elements: elements, transform: t, scale: s) @@ -130,6 +135,7 @@ private struct AnimationLayer: View { let model: CanvasModel let player: TortoisePlayer? @Environment(\.tortoiseViewport) private var viewportMode + @Environment(\.tortoiseSprite) private var sprite var body: some View { // Pause the schedule once playback finishes (or while the player is @@ -140,7 +146,7 @@ private struct AnimationLayer: View { Canvas { ctx, size in let t = viewportMode.transform( canvasSize: model.canvasSize, viewSize: size, - drawingBounds: model.drawingBounds) + drawingBounds: model.drawingBounds, spriteHalfExtent: sprite.halfExtent) let s = (t.a * t.a + t.b * t.b).squareRoot() if let next = model.inProgressFrame, model.animationProgress > 0 { CanvasRenderer.drawInProgress( @@ -150,7 +156,7 @@ private struct AnimationLayer: View { CanvasRenderer.drawTortoise( &ctx, state: model.tortoiseState, interpolatingTo: model.inProgressFrame?.tortoiseState, - progress: model.animationProgress, + progress: model.animationProgress, sprite: sprite, transform: t, scale: s) } .onChange(of: timeline.date) { _, date in @@ -164,6 +170,7 @@ private struct AnimationLayer: View { extension EnvironmentValues { @Entry var tortoiseViewport: ViewportMode = .autoFit + @Entry var tortoiseSprite: TortoiseSprite = .triangle } extension View { @@ -171,6 +178,17 @@ extension View { public func tortoiseViewport(_ mode: ViewportMode) -> some View { environment(\.tortoiseViewport, mode) } + + /// Sets how the tortoise itself is drawn by any ``TortoiseCanvas`` in the + /// view hierarchy. The default is ``TortoiseSprite/triangle``. + /// + /// ```swift + /// TortoiseCanvas(🐢) + /// .tortoiseSprite(.image(Image("Turtle"), size: CGSize(width: 40, height: 40))) + /// ``` + public func tortoiseSprite(_ sprite: TortoiseSprite) -> some View { + environment(\.tortoiseSprite, sprite) + } } // MARK: - Preview @@ -186,6 +204,19 @@ extension View { } } +#Preview("Custom Sprite") { + TortoiseCanvas { 🐢 in + 🐢.speed = 0 + 🐢.penColor = .green + for _ in 1...6 { + 🐢.forward(80) + 🐢.right(60) + } + } + .tortoiseSprite( + .image(Image(systemName: "tortoise.fill"), size: CGSize(width: 40, height: 40))) +} + #Preview("Animated Square") { TortoiseCanvas { 🐢 in 🐢.speed = 5 diff --git a/Sources/TortoiseUI/TortoiseRenderingConstants.swift b/Sources/TortoiseUI/TortoiseRenderingConstants.swift index eef1c40..9dac913 100644 --- a/Sources/TortoiseUI/TortoiseRenderingConstants.swift +++ b/Sources/TortoiseUI/TortoiseRenderingConstants.swift @@ -1,5 +1,6 @@ // Constants governing tortoise sprite rendering. -// Shared by TortoiseCanvasView (drawing) and ViewportMode.autoFit (edge inset). -let tortoiseBaseSize: Double = 10 // sprite half-height in screen points at scale 1 +// Shared by CanvasRenderer (drawing), TortoiseSprite.halfExtent, and +// ViewportMode.autoFit (edge inset). +let tortoiseBaseSize: Double = 10 // triangle half-height in screen points at scale 1 let tortoiseScaleMin: Double = 0.5 let tortoiseScaleMax: Double = 2.0 diff --git a/Sources/TortoiseUI/TortoiseSprite.swift b/Sources/TortoiseUI/TortoiseSprite.swift new file mode 100644 index 0000000..15a6355 --- /dev/null +++ b/Sources/TortoiseUI/TortoiseSprite.swift @@ -0,0 +1,49 @@ +import CoreGraphics +import SwiftUI + +/// The visual representation of the tortoise on the canvas. +/// +/// Set it with the `.tortoiseSprite(_:)` modifier. The default is +/// ``TortoiseSprite/triangle``. +/// +/// ```swift +/// TortoiseCanvas(🐢) +/// .tortoiseSprite(.image(Image("Turtle"), size: CGSize(width: 40, height: 40))) +/// ``` +/// +/// Conformances are declared explicitly so that adding an associated value +/// to a case later cannot silently drop the implicit ones. +public enum TortoiseSprite: Sendable, Equatable { + /// The built-in green triangle. Default. + case triangle + + /// A custom image, drawn centered on the tortoise's position. + /// + /// The image is rotated so its **top edge** faces the tortoise's heading + /// (at heading 0 — north — it is drawn upright), so supply artwork that + /// points up. Transparency is preserved, so the drawing shows through. + /// + /// `size` is a bounding box in points at viewport scale 1: the image is + /// scaled to fit inside it, preserving its aspect ratio. Like the built-in + /// triangle, the sprite scales with the viewport, clamped to 0.5×–2×. + /// + /// Use `Image(uiImage:)` / `Image(nsImage:)` to pass an image you already + /// have in memory. + case image(Image, size: CGSize) +} + +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 { + 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 + } + } +} diff --git a/Sources/TortoiseUI/ViewportMode.swift b/Sources/TortoiseUI/ViewportMode.swift index db22fab..c935417 100644 --- a/Sources/TortoiseUI/ViewportMode.swift +++ b/Sources/TortoiseUI/ViewportMode.swift @@ -20,9 +20,14 @@ public enum ViewportMode: Sendable, Equatable { extension ViewportMode { /// Returns a transform mapping tortoise coordinates (center origin, Y up) /// to SwiftUI Canvas coordinates (top-left origin, Y down). - func transform(canvasSize: Size, viewSize: CGSize, drawingBounds: DrawingBounds?) - -> CGAffineTransform - { + /// + /// `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( + canvasSize: Size, viewSize: CGSize, drawingBounds: DrawingBounds?, + spriteHalfExtent: Double + ) -> CGAffineTransform { let tx = viewSize.width / 2 let ty = viewSize.height / 2 switch self { @@ -41,7 +46,7 @@ extension ViewportMode { return CGAffineTransform(a: scale, b: 0, c: 0, d: -scale, tx: tx, ty: ty) } // Inset = max rendered tortoise half-size, so the sprite never clips at the edge. - let inset = tortoiseBaseSize * tortoiseScaleMax + let inset = spriteHalfExtent * tortoiseScaleMax let pw = bb.width + 2 * inset let ph = bb.height + 2 * inset // Protect against a degenerate bounding box (single point or horizontal/vertical line). diff --git a/Tests/TortoiseUITests/TortoiseSpriteTests.swift b/Tests/TortoiseUITests/TortoiseSpriteTests.swift new file mode 100644 index 0000000..55511ec --- /dev/null +++ b/Tests/TortoiseUITests/TortoiseSpriteTests.swift @@ -0,0 +1,134 @@ +import CoreGraphics +import SwiftUI +import Testing +import TortoiseCore + +@testable import TortoiseUI + +@Suite("Tortoise sprite") +struct TortoiseSpriteTests { + @Test("triangle half-extent is the tip distance") + func triangleHalfExtent() { + #expect(TortoiseSprite.triangle.halfExtent == tortoiseBaseSize) + } + + @Test("image half-extent is the half-diagonal, so rotation never clips") + func imageHalfExtent() { + let sprite = TortoiseSprite.image(Image(systemName: "tortoise"), size: .init(40, 40)) + // hypot(40, 40) / 2 + #expect(abs(sprite.halfExtent - 28.284271) < 0.0001) + } + + @Test("autoFit insets by the sprite half-extent, so a larger sprite zooms out") + func autoFitInsetsForSprite() { + // A 200 x 200 drawing centered on the origin. + var builder = DrawingBounds.Builder() + builder.expand(to: Point(x: -100, y: -100)) + builder.expand(to: Point(x: 100, y: 100)) + let bounds = builder.build() + let viewSize = CGSize(width: 400, height: 400) + let scale = { (sprite: TortoiseSprite) in + ViewportMode.autoFit.transform( + canvasSize: .defaultCanvas, viewSize: viewSize, drawingBounds: bounds, + spriteHalfExtent: sprite.halfExtent + ).a + } + // 400 / (200 + 2 * 10 * tortoiseScaleMax) + #expect(abs(scale(.triangle) - 400.0 / 240.0) < 0.0001) + + let image = TortoiseSprite.image(Image(systemName: "tortoise"), size: .init(40, 40)) + #expect(scale(image) < scale(.triangle)) + // 400 / (200 + 2 * hypot(40, 40) / 2 * tortoiseScaleMax) + #expect(abs(scale(image) - 400.0 / (200 + 2 * 28.284271 * tortoiseScaleMax)) < 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.triangle != .image(image, size: .init(40, 40))) + #expect( + TortoiseSprite.image(image, size: .init(40, 40)) == .image(image, size: .init(40, 40))) + #expect( + TortoiseSprite.image(image, size: .init(40, 40)) != .image(image, size: .init(20, 20))) + } +} + +extension CGSize { + fileprivate init(_ width: Double, _ height: Double) { + self.init(width: width, height: height) + } +} + +#if os(macOS) + import SnapshotTesting + + @Suite("Tortoise sprite canvas snapshots") + @MainActor + struct TortoiseSpriteCanvasTests { + /// An asymmetric, up-pointing sprite built in code (no test resource): + /// a red top half over a blue bottom half, so a golden image shows both + /// that the artwork is drawn and which way it is rotated. + private static func spriteImage(width: Double, height: Double) -> Image? { + let content = VStack(spacing: 0) { + Rectangle().fill(Color.red) + Rectangle().fill(Color.blue) + } + .frame(width: width, height: height) + let renderer = ImageRenderer(content: content) + renderer.scale = 2 + guard let nsImage = renderer.nsImage else { return nil } + return Image(nsImage: nsImage) + } + + @Test("image sprite replaces the triangle and rotates with the heading") + func imageSprite() { + guard let sprite = Self.spriteImage(width: 40, height: 40) else { + Issue.record("ImageRenderer produced no sprite image") + return + } + assertCanvasSnapshot(sprite: .image(sprite, size: CGSize(width: 40, height: 40))) + } + + @Test("a non-square image is fitted inside the sprite size, not stretched") + func imageSpriteAspectRatio() { + guard let sprite = Self.spriteImage(width: 80, height: 40) else { + Issue.record("ImageRenderer produced no sprite image") + return + } + // A 2:1 image in a square box renders 40 x 20, not 40 x 40. + assertCanvasSnapshot(sprite: .image(sprite, size: CGSize(width: 40, height: 40))) + } + + /// Renders a two-segment drawing that ends heading east (90°), so a + /// correctly rotated sprite shows its red half on the east side. + private func assertCanvasSnapshot( + sprite: TortoiseSprite, fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: String = #function, line: UInt = #line, column: UInt = #column + ) { + let view = TortoiseCanvas { tortoise in + tortoise.speed = 0 + tortoise.forward(100) + tortoise.right(90) + tortoise.forward(100) + } + .tortoiseSprite(sprite) + .frame(width: 400, height: 400) + .background(Color.white) + .environment(\.colorScheme, .light) + + let renderer = ImageRenderer(content: view) + renderer.scale = 2 + renderer.proposedSize = ProposedViewSize(width: 400, height: 400) + + guard let image = renderer.nsImage else { + Issue.record("ImageRenderer produced no image") + return + } + assertSnapshot( + of: image, as: .image(precision: 0.995, perceptualPrecision: 0.98), + fileID: fileID, file: filePath, testName: function, line: line, column: column) + } + } +#endif diff --git a/Tests/TortoiseUITests/__Snapshots__/TortoiseSpriteTests/imageSprite.1.png b/Tests/TortoiseUITests/__Snapshots__/TortoiseSpriteTests/imageSprite.1.png new file mode 100644 index 0000000000000000000000000000000000000000..80961569d79a0cd3841c29bc5d6adb1e793d5d5f GIT binary patch literal 14699 zcmeHOeN0nV6u@V%ePO~uVtNkXecZ9Exh$!QY7!w^XBHGWY_po{aR3Sww{1!vD2>2WF3lt*|dJtZ4 zSc+_cY~h7Rlv6#7~>k6}}yW-5-ayD|CZVwRSf zF&yv0QU{X#EDhRZC7*xoa9Z_FmSGuqP{ONvv6rJ^rv-w_smJ1jInBXP5C<=nMzbDD)B5h2XF5EsV2V|5Hcp5FXxG^cqYL68%h)@8&7ejg%!DM>HCziU1h zy9-H@UYK{iHiexAoIR+y5K_oKTvSZcpu})+(Kp;Q-odaBk%0nsMFv716o{YyOo+7% z)6&uy2$x$r+tliz?lbbX#}WuGj_+~GK21Mds&?h%+9H`n5V*?nAw14LG$<6Pvdxsm zVyTe1Uumc$2)E~Oz3Z0IDr`$A`lv9JXv#8(FRy9&EMq~Ond#qp+jPW-MVeoN_}Bg% zp6$`*mo)I5p^Xi9*Wb(GCo<0doMj1d7%qI*(XVGr)(ZFYlNI*O@X;qB>PFn?wHymd zbeMPEy3;=w=g)|6bWnOHb~TPPufZt3)zmTJIEP+pIHc{{nrcqc3?-L&pQ#ddLM4m3w;aBjuVBT6)ySb{_^1CuYZjL-d$&-m@}5@P?#=+Ub42 z$;GZ`gIgZ9l(aJLVcF1jhTW7mx8vS3mSxUM?g$K*=?o7pZOJ{M9=LBkl#y7Xc0AI` zdK26Wd=5_=He%R?($Xi8SLHb`ut|LSJ+^Myw1%V`mAZECkpnM-te!-W`8`+W zaNF~%#|SDrthRZ6y>LN(5gra`R9Elj_N89;EKt)d{Da$_&-#EMSL=)xu8MjyOhjB9 zSR2VHZh)I>&uyjA+~!&~JiTx?arqK%63sNoZdF!D*g3?flJrfJb_1ulah!-~Fj}{A zbI6Cq*zDo>v7F)#sMuDz=m0l|>F}%oGLEOtph3oAoe6mk8^S285awtQAhTHoWh)jM zP%3B7HzJdW;EWVOjetG=M3eTSw6-(2T J&nM;W_y_%OlIj2e literal 0 HcmV?d00001 diff --git a/Tests/TortoiseUITests/__Snapshots__/TortoiseSpriteTests/imageSpriteAspectRatio.1.png b/Tests/TortoiseUITests/__Snapshots__/TortoiseSpriteTests/imageSpriteAspectRatio.1.png new file mode 100644 index 0000000000000000000000000000000000000000..c01e3d537cffec7f30186bb12ab3d422caf76f7a GIT binary patch literal 14584 zcmeI3c~BEq9LIMzo0tG%6hRORi$=Vtrcy^+C@hw<6|i`K7D8q$BE4_~D~=qyu~s|k zm|CZ;2gg)edst9gt8F8RM~%u48* zf}FJV>FLN^a4bY{bSlDkRsmZa8p<3eqq7m7s~kg+oka-lsgnWr&W{poP8;{0SBH9P zgR(lzTboDJ@z`Uh*`$!oHDD*&n7XC}L4syFH#99v(u*KVLedfwR+gdNcTf5lQ||B$ zj)sI4?UCYu<2xqnw$CxWdrOz>b3QpV^yIe|%rq##(3m&QU$`)N&UfO_B*~RSIg+CH z((jvMdbS!x<#knU`XgJfPY!FU=(BxT6=shhDFnrEp_m{klGfZ*L{Cd#OAy*23MRrZ zZw*kj028E!oZjv-a9c`A73VyD4fj(ne0|(R(4ePfIaS7UU%^#y05n-}%YQm^p41>_ z0jI&)0aEhX7f&U6?MaG4?~iwS=xK0yq?|hNO3W_LS7$lzQYd?#tXrl*<{W)T%<)UG zYcXRFZuKCAW+itMo;!Coonwht=O3sGMEp1TPgzk>-K=YlX0-d_y%2v669fq;R4 zjBrB~!qPL^z(D+ufhhaTBw>_@jOZOKmi3PFw`t>TT|MFIk&BJtnPDvxjx;UlJKYwx zuh#yjxophxb9iqw-2`Ng{%y$&m-B5RTSw16t=7@8x%-TxuSMHGA-+6zt8N2j8@?z@ zy&<;xV0NF|*_yr-kqd$J$MbqS7t0`>@Q*E$;Q1Dx+(p)`7beg!=M5s zO+1lzNX3P@Gvrk6iqpF%F=k5^$f+YeB*sdXHeoQ)wzB(ER-;_^4JM2+N~T>(vj|*# z(W;+jGfD`$1ET|EEbKclvk3Cvqltcjq0aJLmj17Y!>TV zOK@9N+{4n2NH^m&&4E%<*3j4(z+jQ+<)c;nF5h7LOgaw>uNY`;$Q>qryMXKS4hgYRMw-aQ+I=m(}q$F@k1SyX&G|Hcr+m>J$h zQ-8UD?I^?vv6KvwRz`Y;G;o7*HHDti-B^Jz2XD* zX_ejqyrj(09J5irpK|Q;rc}=TF4Z7wG}eP0voYX6<%)a>=YAvPR1^#o3=`~4PfF6g z#c#6w@_j2lqjx%;EgKkTGkSnT&!zQaRA0fCR**Y9EV44I=U<^$LPvm=k0$MSMY6t#|?dnvl!MW z!7N|>qR0gdv}ifXlbnrhWuaLQrV@+xUVWK?7?GnvMDsICSsE5$g4DI^`dEkygQTRQ zIiQ-s9C?UB_uu|R#bQ7K zm*kIvIl)%TkJH%MNEul3U)>m2&%Qz_U8_Mt@*11jcOM_klv4wpFHU9WBmb!#K`Ly< zDI#HPyp!Ls-*~6=VNQA3LCY`*3OBwXC?F_cGI-v$hRFbv0VV@X2KeaV>H|-VOfsn3 XHA$E9vjSH;|3R3Rl#$r5RId0NPyMH} literal 0 HcmV?d00001