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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<rect>` 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).
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
57 changes: 45 additions & 12 deletions Sources/TortoiseUI/CanvasRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
Expand Down
25 changes: 24 additions & 1 deletion Sources/TortoiseUI/Documentation.docc/TortoiseUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -89,3 +108,7 @@ command, and changing it never rewinds playback.
### Viewport

- ``ViewportMode``

### Appearance

- ``TortoiseSprite``
39 changes: 35 additions & 4 deletions Sources/TortoiseUI/TortoiseCanvas.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -164,13 +170,25 @@ private struct AnimationLayer: View {

extension EnvironmentValues {
@Entry var tortoiseViewport: ViewportMode = .autoFit
@Entry var tortoiseSprite: TortoiseSprite = .triangle
}

extension View {
/// Sets the viewport mode for any ``TortoiseCanvas`` in the view hierarchy.
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
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions Sources/TortoiseUI/TortoiseRenderingConstants.swift
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions Sources/TortoiseUI/TortoiseSprite.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
13 changes: 9 additions & 4 deletions Sources/TortoiseUI/ViewportMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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).
Expand Down
Loading
Loading