diff --git a/CLAUDE.md b/CLAUDE.md index e04e660..aeb5741 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,10 @@ Tortoise API → [TortoiseCommand] → CommandPlayer.play() → [PlaybackFrame] **`Color.defaultBackground` is the single source of truth for the initial background (#44).** `Tortoise` starts at it, `CommandPlayer.play`'s `initialBackgroundColor` defaults to it, and `CanvasModel` / `SVGBuilder` use it as their empty-stream fallback. It is white. Renderers must never substitute a fallback of their own: they previously started from `.clear` while `Tortoise.backgroundColor` reported white, so a program that issued no `.backgroundColor` command painted nothing — the tortoise and its own drawing disagreed about the color of the paper. Note that `Tortoise.backgroundColor` is the *current* value, not the initial one, so it must not be threaded in as `initialBackgroundColor`; that would back-date a later background change to frame 0. Transparency is still available, but must be asked for: `tortoise.backgroundColor = .clear`. Both renderers still skip the fill / omit the `` when `alpha == 0`, which is what makes SwiftUI's `.background()` modifier work. +**Pen-width taper is a primitive, not sugar (`taperedForward` / `taperedArc`).** `forward(_:widthTo:)` and `circle(radius:extent:widthTo:)` each record *one* command. The tempting alternative — subdividing the move into short constant-width segments emitting ordinary `.penWidth` + `.forward` pairs — needs no renderer changes at all, and is wrong. In this library the unit of *time* is the command: `CommandPlayer` emits a frame per command including state-only ones, and `stepDuration` is distance-independent, so an expansion-based taper buys width fidelity with animation time. `forward(280, widthTo: 12)` from width 1 costs 89 commands, 44 SVG `` elements and 8.9 s at the default speed, and its sub-segments' round caps blend twice at every seam when `alpha < 1`. As one command it is one frame, one element, and one blended region. Do not "optimize" this back into an expansion, and do not add a `steps:` parameter — there is nothing to subdivide. + +**`StrokeOutline` is the single source of taper geometry.** A varying width cannot be stroked, so both renderers *fill* the region the pen sweeps, and both fill the identical polygon from Core — that is the whole reason the math lives there rather than in either renderer. Straight strokes are exact: the outline is the convex hull of the two end discs, whose sides are the discs' external tangents (`sinA = (r0 - r1)/d`), *not* the endpoints offset along the perpendicular — the cheap version leaves a notch where each side meets its cap. Arc edges (`radius ∓ width(t)/2`) are spirals and get flattened to a sagitta tolerance, bounded at `maxSegments`. SVG deliberately uses these flattened points rather than its own arc commands: exact `` caps would look marginally better in isolation and would let the two renderers drift apart. Two consequences to preserve: **tapered strokes must never join the same-width batching** in `CanvasRenderer.drawElements` (`Stroke.width` is only the *start* width, so a taper can compare equal to an untapered run and be silently drawn as a plain line), and **the in-progress stroke truncates its width ramp along with its spine**, or the mark changes width behind the tortoise as the frame finishes. + **`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 diff --git a/README.md b/README.md index 60ad5c2..daac2cd 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ itself. `swift run ExamplesRunner` regenerates the images below. | [Square Spiral](Sources/Examples/Gallery/SquareSpiral.swift) | [Fractal Tree](Sources/Examples/Gallery/FractalTree.swift) | [Koch Snowflake](Sources/Examples/Gallery/KochSnowflake.swift) | | Circle Rosette | Filled Star | Waves | | [Circle Rosette](Sources/Examples/Gallery/CircleRosette.swift) | [Filled Star](Sources/Examples/Gallery/FilledStar.swift) | [Waves](Sources/Examples/Gallery/Waves.swift) | +| Tapered Petals | | | +| [Tapered Petals](Sources/Examples/Gallery/TaperedPetals.swift) | | | ## Showcase diff --git a/Sources/Examples/Gallery/Gallery.swift b/Sources/Examples/Gallery/Gallery.swift index 08c5114..de6d623 100644 --- a/Sources/Examples/Gallery/Gallery.swift +++ b/Sources/Examples/Gallery/Gallery.swift @@ -14,5 +14,6 @@ public enum Gallery { ("circle-rosette", CircleRosette.draw), ("filled-star", FilledStar.draw), ("waves", Waves.draw), + ("tapered-petals", TaperedPetals.draw), ] } diff --git a/Sources/Examples/Gallery/TaperedPetals.swift b/Sources/Examples/Gallery/TaperedPetals.swift new file mode 100644 index 0000000..fc40957 --- /dev/null +++ b/Sources/Examples/Gallery/TaperedPetals.swift @@ -0,0 +1,54 @@ +import SwiftUI +import TortoiseCore +import TortoiseUI + +/// A rosette of petals drawn with a pen that thickens and thins as it goes. +/// +/// Each petal is two tapered arcs: out from the centre with the pen swelling, +/// back again with it closing to a point. Because a taper is a single command +/// rather than a run of stepped-width segments, each arc here is one playback +/// frame and one filled region — the petals animate at the same pace as any +/// other arc, and the translucent fills blend once rather than darkening +/// wherever sub-segments would have overlapped. +enum TaperedPetals { + @MainActor + static func draw(_ 🐢: Tortoise) { + 🐢.backgroundColor = TortoiseCore.Color(red: 0.07, green: 0.07, blue: 0.12) + 🐢.speed = 10 + + let petals = 9 + for i in 0.. TortoiseCore.Color { + let angle = hue * 2 * .pi + return TortoiseCore.Color( + red: 0.55 + 0.45 * cos(angle), + green: 0.45 + 0.4 * cos(angle - 2.1), + blue: 0.6 + 0.4 * cos(angle - 4.2), + alpha: 1 + ) + } +} + +#Preview("Tapered Petals") { + TortoiseCanvas(TaperedPetals.draw) + .padding() +} diff --git a/Sources/TortoiseCore/ArcStroke.swift b/Sources/TortoiseCore/ArcStroke.swift index 8d265bb..62b2c55 100644 --- a/Sources/TortoiseCore/ArcStroke.swift +++ b/Sources/TortoiseCore/ArcStroke.swift @@ -11,5 +11,26 @@ public struct ArcStroke: Sendable, Equatable { /// Sweep angle in degrees (positive = CCW in tortoise space). public let sweep: Double public let color: Color + /// Pen width at the start of the sweep. public let width: Double + /// Pen width at the end of the sweep. + public let endWidth: Double + + /// Creates an arc stroke. `endWidth` defaults to `width`, giving the + /// constant-width arc that ``TortoiseCommand/arc(radius:extent:)`` produces. + public init( + center: Point, radius: Double, startAngle: Double, sweep: Double, + color: Color, width: Double, endWidth: Double? = nil + ) { + self.center = center + self.radius = radius + self.startAngle = startAngle + self.sweep = sweep + self.color = color + self.width = width + self.endWidth = endWidth ?? width + } + + /// Whether the pen width changes across this arc. + public var isTapered: Bool { endWidth != width } } diff --git a/Sources/TortoiseCore/Codable.swift b/Sources/TortoiseCore/Codable.swift index b988549..f9635ed 100644 --- a/Sources/TortoiseCore/Codable.swift +++ b/Sources/TortoiseCore/Codable.swift @@ -97,6 +97,7 @@ extension TortoiseCommand: Codable { /// are the frozen-format contract. private enum CodingKeys: String, CodingKey { case forward + case taperedForward case rotate case home case setPosition @@ -114,6 +115,7 @@ extension TortoiseCommand: Codable { case backgroundColor case clear case arc + case taperedArc case dot } @@ -126,6 +128,7 @@ extension TortoiseCommand: Codable { case radius case extent case size + case widthTo } /// Accepts any key. Used to count the raw keys of a command object: @@ -160,6 +163,12 @@ extension TortoiseCommand: Codable { switch key { case .forward: self = .forward(try payload().decode(Double.self, forKey: .distance)) + case .taperedForward: + let taperPayload = try payload() + self = .taperedForward( + distance: try taperPayload.decode(Double.self, forKey: .distance), + widthTo: try taperPayload.decode(Double.self, forKey: .widthTo) + ) case .rotate: self = .rotate(try payload().decode(Double.self, forKey: .degrees)) case .home: @@ -198,6 +207,13 @@ extension TortoiseCommand: Codable { radius: try arcPayload.decode(Double.self, forKey: .radius), extent: try arcPayload.decode(Double.self, forKey: .extent) ) + case .taperedArc: + let taperPayload = try payload() + self = .taperedArc( + radius: try taperPayload.decode(Double.self, forKey: .radius), + extent: try taperPayload.decode(Double.self, forKey: .extent), + widthTo: try taperPayload.decode(Double.self, forKey: .widthTo) + ) case .dot: self = .dot(try payload().decode(Double.self, forKey: .size)) } @@ -217,6 +233,11 @@ extension TortoiseCommand: Codable { switch self { case .forward(let distance): try encodeScalar(distance, .distance, forKey: .forward) + case .taperedForward(let distance, let widthTo): + var payload = container.nestedContainer( + keyedBy: PayloadKeys.self, forKey: .taperedForward) + try payload.encode(distance, forKey: .distance) + try payload.encode(widthTo, forKey: .widthTo) case .rotate(let degrees): try encodeScalar(degrees, .degrees, forKey: .rotate) case .home: @@ -253,6 +274,11 @@ extension TortoiseCommand: Codable { var payload = container.nestedContainer(keyedBy: PayloadKeys.self, forKey: .arc) try payload.encode(radius, forKey: .radius) try payload.encode(extent, forKey: .extent) + case .taperedArc(let radius, let extent, let widthTo): + var payload = container.nestedContainer(keyedBy: PayloadKeys.self, forKey: .taperedArc) + try payload.encode(radius, forKey: .radius) + try payload.encode(extent, forKey: .extent) + try payload.encode(widthTo, forKey: .widthTo) case .dot(let size): try encodeScalar(size, .size, forKey: .dot) } diff --git a/Sources/TortoiseCore/CommandPlayer.swift b/Sources/TortoiseCore/CommandPlayer.swift index b50a552..d7a46e9 100644 --- a/Sources/TortoiseCore/CommandPlayer.swift +++ b/Sources/TortoiseCore/CommandPlayer.swift @@ -40,7 +40,20 @@ public enum CommandPlayer { } fillPoints?.append(tortoise.position) - case .arc(let radius, let extent): + case .taperedForward: + if before.isPenDown { + // `endWidth` is read back from the post-command state rather + // than from the payload, so the clamp in `applying(_:)` is + // the only place a negative width is handled. + newStroke = Stroke( + from: before.position, to: tortoise.position, + color: before.penColor, width: before.penWidth, + endWidth: tortoise.penWidth + ) + } + fillPoints?.append(tortoise.position) + + case .arc(let radius, let extent), .taperedArc(let radius, let extent, _): if before.isPenDown { let center = Tortoise.arcCenter( position: before.position, heading: before.heading, radius: radius) @@ -54,7 +67,8 @@ public enum CommandPlayer { startAngle: atan2(dy, dx) * (180 / .pi), sweep: radius < 0 ? -extent : extent, color: before.penColor, - width: before.penWidth + width: before.penWidth, + endWidth: tortoise.penWidth ) } fillPoints?.append(tortoise.position) diff --git a/Sources/TortoiseCore/Documentation.docc/CommandSerialization.md b/Sources/TortoiseCore/Documentation.docc/CommandSerialization.md index 98db910..584e86e 100644 --- a/Sources/TortoiseCore/Documentation.docc/CommandSerialization.md +++ b/Sources/TortoiseCore/Documentation.docc/CommandSerialization.md @@ -33,6 +33,7 @@ A command encodes as a JSON object with exactly one key, the command name: | Command | JSON | | --- | --- | | `forward(100)` | `{"forward":{"distance":100}}` | +| `forward(200, widthTo: 12)` | `{"taperedForward":{"distance":200,"widthTo":12}}` | | `rotate(-45.5)` | `{"rotate":{"degrees":-45.5}}` | | `home` | `{"home":{}}` | | `setPosition(Point(x: 10, y: 20))` | `{"setPosition":{"x":10,"y":20}}` | @@ -47,6 +48,7 @@ A command encodes as a JSON object with exactly one key, the command name: | `backgroundColor(.black)` | `{"backgroundColor":{"red":0,"green":0,"blue":0,"alpha":1}}` | | `clear` | `{"clear":{}}` | | `circle(radius: -50, extent: 180)` | `{"arc":{"radius":-50,"extent":180}}` | +| `circle(radius: 70, extent: 270, widthTo: 10)` | `{"taperedArc":{"radius":70,"extent":270,"widthTo":10}}` | | `dot(8)` | `{"dot":{"size":8}}` | On decode, a command object must contain **exactly one key, and it must be diff --git a/Sources/TortoiseCore/Documentation.docc/TortoiseCore.md b/Sources/TortoiseCore/Documentation.docc/TortoiseCore.md index eecfa14..26fdcf5 100644 --- a/Sources/TortoiseCore/Documentation.docc/TortoiseCore.md +++ b/Sources/TortoiseCore/Documentation.docc/TortoiseCore.md @@ -26,6 +26,29 @@ This means animation, SVG export, and unit tests all share a single source of tr a snapshot of tortoise state after each command — which renderers step through to produce output. +### Tapered strokes + +A pen can change width across a single move, so a stroke thickens or thins as +the tortoise lays it down: + +```swift +🐢.penWidth = 1 +🐢.forward(200, widthTo: 12) +🐢.circle(radius: 70, extent: 270, widthTo: 10) +``` + +Each is one ``TortoiseCommand`` — ``TortoiseCommand/taperedForward(distance:widthTo:)`` +and ``TortoiseCommand/taperedArc(radius:extent:widthTo:)`` — so a taper occupies +a single ``PlaybackFrame`` and animates in the same time as the untapered move +it replaces. After the call ``Tortoise/penWidth`` is the width you asked for. + +Renderers cannot express a varying width by stroking a path at one width, so +they fill the region the pen sweeps instead. ``StrokeOutline`` computes that +region, and both bundled renderers fill the same polygon, so they cannot +disagree about the shape of a taper. A stroke whose end width equals its start +width is not tapered at all (``Stroke/isTapered``) and is drawn the ordinary +way. + ### Coordinate system - **Origin** — center of the logical canvas. @@ -58,6 +81,7 @@ to produce output. ### Geometry - ``DrawingBounds`` +- ``StrokeOutline`` ### Value Types diff --git a/Sources/TortoiseCore/Stroke.swift b/Sources/TortoiseCore/Stroke.swift index 7a909c1..ef8ff33 100644 --- a/Sources/TortoiseCore/Stroke.swift +++ b/Sources/TortoiseCore/Stroke.swift @@ -1,7 +1,32 @@ -/// A line segment drawn by the tortoise. +/// A straight pen stroke between two points. +/// +/// ``width`` is the pen width at ``from`` and ``endWidth`` the width at ``to``. +/// They are equal for an ordinary stroke; when they differ the stroke is +/// *tapered* and renderers fill its outline rather than stroking a line at a +/// single width. public struct Stroke: Sendable, Equatable { public let from: Point public let to: Point public let color: Color + /// Pen width at ``from``. public let width: Double + /// Pen width at ``to``. + public let endWidth: Double + + /// Creates a stroke. `endWidth` defaults to `width`, giving the + /// constant-width stroke that every non-tapered command produces. + public init(from: Point, to: Point, color: Color, width: Double, endWidth: Double? = nil) { + self.from = from + self.to = to + self.color = color + self.width = width + self.endWidth = endWidth ?? width + } + + /// Whether the pen width changes across this stroke. + /// + /// Renderers use this to choose between stroking a line and filling an + /// outline; it is also why a tapered stroke can never join a same-width + /// batch. + public var isTapered: Bool { endWidth != width } } diff --git a/Sources/TortoiseCore/StrokeOutline.swift b/Sources/TortoiseCore/StrokeOutline.swift new file mode 100644 index 0000000..7feedf5 --- /dev/null +++ b/Sources/TortoiseCore/StrokeOutline.swift @@ -0,0 +1,201 @@ +import Foundation + +/// Outline polygons for tapered strokes. +/// +/// A stroke whose pen width changes along its length cannot be drawn by +/// stroking a path at one width, so renderers fill the region the pen sweeps +/// instead. That region is computed here — once, in `TortoiseCore` — so the +/// SVG and canvas renderers cannot disagree about the shape of a taper. +/// +/// Polygons come back closed (the first point is not repeated at the end) and +/// wound counter-clockwise in tortoise space. An empty array means there is +/// nothing to fill. +public enum StrokeOutline { + /// Maximum distance a flattened chord may deviate from the true curve. + /// + /// A tenth of a logical unit is a twentieth of a pixel at the 2× scale the + /// canvas goldens render at, so flattening is invisible while keeping the + /// point counts small enough to stay cheap per frame. + public static let defaultTolerance = 0.1 + + /// Upper bound on the chords used for any single curved run, so a huge + /// radius or a pathological tolerance cannot produce an unbounded polygon. + public static let maxSegments = 512 + + // MARK: - Straight strokes + + /// The outline of a straight stroke with round caps. + /// + /// The exact result is the convex hull of the two end discs: the sides are + /// the discs' external tangents, which for a taper are *not* parallel to + /// the stroke and do not meet the caps at the halfway point. Offsetting + /// each endpoint along the perpendicular instead would leave a visible + /// notch where side meets cap, which is why this does the tangent + /// construction rather than the cheaper approximation. + public static func polygon( + for stroke: Stroke, tolerance: Double = defaultTolerance + ) -> [Point] { + hull( + from: stroke.from, radius0: max(0, stroke.width) / 2, + to: stroke.to, radius1: max(0, stroke.endWidth) / 2, + tolerance: tolerance) + } + + /// The convex hull of two discs, as a closed polygon. + static func hull( + from p0: Point, radius0 r0: Double, to p1: Point, radius1 r1: Double, tolerance: Double + ) -> [Point] { + guard r0 > 0 || r1 > 0 else { return [] } + + let delta = p1 - p0 + let d = delta.magnitude + + // No length: the pen never moved, so the mark is a single disc. + guard d > .ulpOfOne else { + return circle(center: p0, radius: max(r0, r1), tolerance: tolerance) + } + + // One disc swallows the other; there are no external tangents to draw. + let sinA = (r0 - r1) / d + guard abs(sinA) < 1 else { + return r0 > r1 + ? circle(center: p0, radius: r0, tolerance: tolerance) + : circle(center: p1, radius: r1, tolerance: tolerance) + } + let cosA = (1 - sinA * sinA).squareRoot() + + // Tangent directions, as the axis angle rotated by ±(90° - A). Both + // tangent points on a given side share this direction, which is what + // makes the sides touch each disc exactly. + let axis = atan2(delta.y, delta.x) + let halfOpen = atan2(cosA, sinA) // = 90° - A, in radians + let anglePlus = axis + halfOpen + let angleMinus = axis - halfOpen + + var points: [Point] = [] + // Side one, then the far cap wrapping forward past `p1` … + points.append(pointOn(center: p0, radius: r0, angle: angleMinus)) + points += arcPoints( + center: p1, radius: r1, from: angleMinus, sweep: 2 * halfOpen, + tolerance: tolerance, includingLast: true) + // … then side two, and the near cap wrapping back past `p0`. + points += arcPoints( + center: p0, radius: r0, from: anglePlus, sweep: 2 * (.pi - halfOpen), + tolerance: tolerance, includingLast: false) + return points + } + + // MARK: - Arcs + + /// The outline of a circular arc stroke with round caps. + /// + /// The pen sweeps an annulus whose inner and outer edges are at + /// `radius ∓ penWidth(t)/2`. With a varying width those edges are spirals + /// rather than circles, so they are flattened to chords; the true envelope + /// additionally leans by `d(width)/d(angle)`, which is below `tolerance` + /// for any width ramp a pen plausibly makes over an arc. + /// + /// A pen wider than twice the arc radius would invert the inner edge + /// through the center; it is clamped at the center instead, so the shape + /// degenerates to a filled disc sector rather than turning inside out. + public static func polygon( + for arc: ArcStroke, tolerance: Double = defaultTolerance + ) -> [Point] { + let r0 = max(0, arc.width) / 2 + let r1 = max(0, arc.endWidth) / 2 + guard r0 > 0 || r1 > 0 else { return [] } + + let sweep = arc.sweep * .pi / 180 + guard abs(sweep) > .ulpOfOne else { + // No sweep: the pen sat still, leaving its cap behind. + return circle( + center: pointOn( + center: arc.center, radius: arc.radius, + angle: arc.startAngle * .pi / 180), + radius: r0, tolerance: tolerance) + } + + let start = arc.startAngle * .pi / 180 + let end = start + sweep + let steps = segmentCount( + radius: arc.radius + max(r0, r1), sweep: sweep, tolerance: tolerance) + + func width(at t: Double) -> Double { r0 + (r1 - r0) * t } + func angle(at t: Double) -> Double { start + sweep * t } + + var points: [Point] = [] + // Outer edge, start → end. + for i in 0...steps { + let t = Double(i) / Double(steps) + points.append( + pointOn(center: arc.center, radius: arc.radius + width(at: t), angle: angle(at: t))) + } + // Cap at the far end, bulging along the direction of travel. + let capSweep: Double = sweep > 0 ? .pi : -.pi + points += arcPoints( + center: pointOn(center: arc.center, radius: arc.radius, angle: end), + radius: r1, from: end, sweep: capSweep, tolerance: tolerance, includingLast: false) + // Inner edge, end → start. + for i in 0...steps { + let t = 1 - Double(i) / Double(steps) + let inner = max(0, arc.radius - width(at: t)) + points.append(pointOn(center: arc.center, radius: inner, angle: angle(at: t))) + } + // Cap at the near end, bulging against the direction of travel. + points += arcPoints( + center: pointOn(center: arc.center, radius: arc.radius, angle: start), + radius: r0, from: start + .pi, sweep: capSweep, tolerance: tolerance, + includingLast: false) + // A clockwise arc is traced outer-edge-backwards, which winds the + // polygon the other way; flip it so callers get one convention. + return sweep > 0 ? points : points.reversed() + } + + // MARK: - Primitives + + static func circle(center: Point, radius: Double, tolerance: Double) -> [Point] { + guard radius > 0 else { return [] } + return arcPoints( + center: center, radius: radius, from: 0, sweep: 2 * .pi, tolerance: tolerance, + includingLast: false) + } + + /// Flattens a circular arc to chords. `includingLast` controls whether the + /// final point is emitted, so runs can be concatenated without duplicates. + static func arcPoints( + center: Point, radius: Double, from startAngle: Double, sweep: Double, + tolerance: Double, includingLast: Bool + ) -> [Point] { + guard radius > 0 else { return includingLast ? [center] : [] } + let steps = segmentCount(radius: radius, sweep: sweep, tolerance: tolerance) + let upper = includingLast ? steps : steps - 1 + guard upper >= 0 else { return [] } + return (0...upper).map { i in + pointOn( + center: center, radius: radius, + angle: startAngle + sweep * (Double(i) / Double(steps))) + } + } + + /// Chord count for a given sagitta tolerance: the error of a chord + /// subtending `Δ` on radius `r` is `r(1 - cos(Δ/2))`. + static func segmentCount(radius: Double, sweep: Double, tolerance: Double) -> Int { + let absSweep = abs(sweep) + guard absSweep > .ulpOfOne else { return 1 } + let tol = max(tolerance, 1e-6) + guard radius > tol else { return 1 } + let maxStep = 2 * acos((1 - tol / radius).clamped(to: -1...1)) + guard maxStep > .ulpOfOne else { return maxSegments } + return min(maxSegments, max(1, Int((absSweep / maxStep).rounded(.up)))) + } + + static func pointOn(center: Point, radius: Double, angle: Double) -> Point { + Point(x: center.x + radius * cos(angle), y: center.y + radius * sin(angle)) + } +} + +extension Double { + fileprivate func clamped(to range: ClosedRange) -> Double { + min(max(self, range.lowerBound), range.upperBound) + } +} diff --git a/Sources/TortoiseCore/Tortoise.swift b/Sources/TortoiseCore/Tortoise.swift index 3b49e80..ecc42dc 100644 --- a/Sources/TortoiseCore/Tortoise.swift +++ b/Sources/TortoiseCore/Tortoise.swift @@ -115,6 +115,30 @@ public final class Tortoise { forward(-distance) } + /// Move forward by `distance` pixels while ramping the pen width from its + /// current value to `endWidth`. + /// + /// The stroke thickens or thins as the tortoise lays it down. This is a + /// single command, so it animates in the same time as a plain + /// ``forward(_:)`` of the same distance and serializes as one entry; + /// renderers fill the stroke's outline instead of stroking it at one width. + /// + /// After the call ``penWidth`` is `endWidth` (clamped to `>= 0`). + /// + /// - Parameters: + /// - distance: Distance to travel (negative = backward). + /// - endWidth: Pen width at the end of the move. + public func forward(_ distance: Double, widthTo endWidth: Double) { + record(.taperedForward(distance: distance, widthTo: endWidth)) + } + + /// Move backward by `distance` pixels while ramping the pen width to `endWidth`. + /// + /// See ``forward(_:widthTo:)``. + public func backward(_ distance: Double, widthTo endWidth: Double) { + forward(-distance, widthTo: endWidth) + } + /// Rotate clockwise by `degrees`. public func right(_ degrees: Double) { record(.rotate(degrees)) @@ -198,6 +222,17 @@ public final class Tortoise { record(.arc(radius: radius, extent: extent)) } + /// Draw a circular arc while ramping the pen width from its current value + /// to `endWidth`. + /// + /// Geometry is identical to ``circle(radius:extent:)``; only the pen width + /// differs. Like ``forward(_:widthTo:)`` this is a single command. + /// + /// After the call ``penWidth`` is `endWidth` (clamped to `>= 0`). + public func circle(radius: Double, extent: Double = 360, widthTo endWidth: Double) { + record(.taperedArc(radius: radius, extent: extent, widthTo: endWidth)) + } + // MARK: - Pen public func penDown() { diff --git a/Sources/TortoiseCore/TortoiseCommand.swift b/Sources/TortoiseCore/TortoiseCommand.swift index a1e72f4..fa67236 100644 --- a/Sources/TortoiseCore/TortoiseCommand.swift +++ b/Sources/TortoiseCore/TortoiseCommand.swift @@ -10,6 +10,15 @@ public enum TortoiseCommand: Sendable, Equatable { // MARK: Movement /// Move forward (positive) or backward (negative) by `distance` pixels. case forward(Double) + /// Move forward by `distance` pixels while ramping the pen width from its + /// current value to `widthTo`. + /// + /// One command, so it occupies a single playback frame and animates in the + /// same time as a plain ``forward(_:)`` of the same distance. The ramp is a + /// property of the resulting ``Stroke``, which renderers fill as an outline + /// rather than stroking at a single width. After this command + /// ``Tortoise/penWidth`` is `widthTo`. + case taperedForward(distance: Double, widthTo: Double) /// Rotate clockwise (positive) or counterclockwise (negative) by `degrees`. case rotate(Double) /// Move to the origin (0, 0) and reset heading to 0 (north). @@ -50,6 +59,12 @@ public enum TortoiseCommand: Sendable, Equatable { /// A negative `radius` mirrors the arc (center on the tortoise's right, /// sweep directions flipped), matching Python turtle. case arc(radius: Double, extent: Double) + /// Draw a circular arc while ramping the pen width to `widthTo`. + /// + /// Geometry matches ``arc(radius:extent:)`` exactly; only the pen width + /// differs. Like ``taperedForward(distance:widthTo:)`` this is a single + /// command and a single frame. After it ``Tortoise/penWidth`` is `widthTo`. + case taperedArc(radius: Double, extent: Double, widthTo: Double) // MARK: Dot /// Draw a filled circle at the current position without moving the tortoise. diff --git a/Sources/TortoiseCore/TortoiseState.swift b/Sources/TortoiseCore/TortoiseState.swift index 8257de6..c4a233f 100644 --- a/Sources/TortoiseCore/TortoiseState.swift +++ b/Sources/TortoiseCore/TortoiseState.swift @@ -78,6 +78,9 @@ extension TortoiseState { switch command { case .forward(let distance): state.position = position.moved(distance: distance, heading: heading) + case .taperedForward(let distance, let widthTo): + state.position = position.moved(distance: distance, heading: heading) + state.penWidth = max(0, widthTo) case .rotate(let degrees): state.heading = Self.normalizedHeading(heading + degrees) case .home: @@ -108,6 +111,12 @@ extension TortoiseState { position: position, heading: heading, radius: radius, extent: extent) state.position = end.position state.heading = Self.normalizedHeading(end.heading) + case .taperedArc(let radius, let extent, let widthTo): + let end = Tortoise.arcEndState( + position: position, heading: heading, radius: radius, extent: extent) + state.position = end.position + state.heading = Self.normalizedHeading(end.heading) + state.penWidth = max(0, widthTo) case .beginFill, .endFill, .backgroundColor, .clear, .dot: break } diff --git a/Sources/TortoiseSVG/TortoiseSVG.swift b/Sources/TortoiseSVG/TortoiseSVG.swift index 22584bb..92dc1b9 100644 --- a/Sources/TortoiseSVG/TortoiseSVG.swift +++ b/Sources/TortoiseSVG/TortoiseSVG.swift @@ -165,6 +165,12 @@ private struct SVGBuilder { } private func svgStroke(_ stroke: Stroke) -> String { + // A tapered stroke has no single stroke-width, so the region the pen + // sweeps is filled instead. The polygon comes from TortoiseCore, which + // is also what the canvas renderer fills — the two cannot drift. + if stroke.isTapered { + return svgOutline(StrokeOutline.polygon(for: stroke), color: stroke.color) + } let x1 = n(x(stroke.from.x)) let y1 = n(y(stroke.from.y)) let x2 = n(x(stroke.to.x)) @@ -173,10 +179,25 @@ private struct SVGBuilder { #" "# } + /// Emits a filled outline polygon, used wherever a pen width varies along + /// the mark and a stroked path cannot express it. + private func svgOutline(_ polygon: [Point], color penColor: Color) -> String { + guard polygon.count >= 3 else { return "" } + let pts = + polygon + .map { "\(n(x($0.x))),\(n(y($0.y)))" } + .joined(separator: " ") + return #" "# + } + private func svgArc(_ arc: ArcStroke) -> String { let absSwep = abs(arc.sweep) guard absSwep > 0 else { return "" } + if arc.isTapered { + return svgOutline(StrokeOutline.polygon(for: arc), color: arc.color) + } + let cx = x(arc.center.x) let cy = y(arc.center.y) let r = arc.radius diff --git a/Sources/TortoiseUI/CanvasRenderer.swift b/Sources/TortoiseUI/CanvasRenderer.swift index bda398d..c67f6e4 100644 --- a/Sources/TortoiseUI/CanvasRenderer.swift +++ b/Sources/TortoiseUI/CanvasRenderer.swift @@ -34,6 +34,14 @@ enum CanvasRenderer { path.closeSubpath() ctx.fill(path, with: .color(SwiftUI.Color(fill.color))) + case .stroke(let first) where first.isTapered: + // No single stroke-width can express a taper, so fill the + // region the pen swept — the same polygon TortoiseSVG fills. + i += 1 + ctx.fill( + outlinePath(StrokeOutline.polygon(for: first), transform: t), + with: .color(SwiftUI.Color(first.color))) + case .stroke(let first): // Merge the maximal run of same-color, same-width strokes into // one multi-subpath `Path` and stroke it once. Round caps are @@ -47,7 +55,7 @@ enum CanvasRenderer { var path = Path() var j = i while j < elements.endIndex, case .stroke(let next) = elements[j], - next.color == first.color, next.width == first.width + !next.isTapered, next.color == first.color, next.width == first.width { path.move(to: CGPoint(x: next.from.x, y: next.from.y).applying(t)) path.addLine(to: CGPoint(x: next.to.x, y: next.to.y).applying(t)) @@ -59,6 +67,12 @@ enum CanvasRenderer { style: strokeStyle(width: first.width * s)) i = j + case .arcStroke(let arc) where arc.isTapered: + i += 1 + ctx.fill( + outlinePath(StrokeOutline.polygon(for: arc), transform: t), + with: .color(SwiftUI.Color(arc.color))) + case .arcStroke(let arc): i += 1 ctx.stroke( @@ -83,23 +97,51 @@ enum CanvasRenderer { transform t: CGAffineTransform, scale s: Double ) { if let stroke = frame.newStroke { - var path = Path() - let from = CGPoint(x: stroke.from.x, y: stroke.from.y).applying(t) - let partialTo = CGPoint( + let partial = Point( x: stroke.from.x + p * (stroke.to.x - stroke.from.x), - y: stroke.from.y + p * (stroke.to.y - stroke.from.y) - ).applying(t) - path.move(to: from) - path.addLine(to: partialTo) - ctx.stroke( - path, with: .color(SwiftUI.Color(stroke.color)), - style: strokeStyle(width: stroke.width * s)) + y: stroke.from.y + p * (stroke.to.y - stroke.from.y)) + if stroke.isTapered { + // Truncate the width ramp along with the spine, so the pen is + // as thick where the tortoise stands as it will be when the + // stroke commits — the mark never changes width behind it. + ctx.fill( + outlinePath( + StrokeOutline.polygon( + for: Stroke( + from: stroke.from, to: partial, color: stroke.color, + width: stroke.width, + endWidth: stroke.width + p * (stroke.endWidth - stroke.width))), + transform: t), + with: .color(SwiftUI.Color(stroke.color))) + } + else { + var path = Path() + path.move(to: CGPoint(x: stroke.from.x, y: stroke.from.y).applying(t)) + path.addLine(to: CGPoint(x: partial.x, y: partial.y).applying(t)) + ctx.stroke( + path, with: .color(SwiftUI.Color(stroke.color)), + style: strokeStyle(width: stroke.width * s)) + } } if let arc = frame.newArcStroke { - ctx.stroke( - arcPath(arc, sweep: arc.sweep * p, transform: t), - with: .color(SwiftUI.Color(arc.color)), - style: strokeStyle(width: arc.width * s)) + if arc.isTapered { + ctx.fill( + outlinePath( + StrokeOutline.polygon( + for: ArcStroke( + center: arc.center, radius: arc.radius, + startAngle: arc.startAngle, sweep: arc.sweep * p, + color: arc.color, width: arc.width, + endWidth: arc.width + p * (arc.endWidth - arc.width))), + transform: t), + with: .color(SwiftUI.Color(arc.color))) + } + else { + ctx.stroke( + arcPath(arc, sweep: arc.sweep * p, transform: t), + with: .color(SwiftUI.Color(arc.color)), + style: strokeStyle(width: arc.width * s)) + } } } @@ -136,6 +178,20 @@ enum CanvasRenderer { // MARK: - Private helpers + /// Converts an outline polygon from ``StrokeOutline`` into a closed path in + /// screen space. The polygon is in tortoise units, so `transform` supplies + /// the scale — unlike a stroked path, whose width has to be scaled by hand. + static func outlinePath(_ polygon: [Point], transform t: CGAffineTransform) -> Path { + var path = Path() + guard let first = polygon.first else { return path } + path.move(to: CGPoint(x: first.x, y: first.y).applying(t)) + for pt in polygon.dropFirst() { + path.addLine(to: CGPoint(x: pt.x, y: pt.y).applying(t)) + } + path.closeSubpath() + return path + } + /// 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. diff --git a/Tests/TortoiseCoreTests/CodableTests.swift b/Tests/TortoiseCoreTests/CodableTests.swift index 30f9b72..00af3bd 100644 --- a/Tests/TortoiseCoreTests/CodableTests.swift +++ b/Tests/TortoiseCoreTests/CodableTests.swift @@ -38,6 +38,14 @@ struct CodableTests { ), (.clear, #"{"clear":{}}"#), (.arc(radius: -50, extent: 180), #"{"arc":{"extent":180,"radius":-50}}"#), + ( + .taperedForward(distance: 200, widthTo: 12), + #"{"taperedForward":{"distance":200,"widthTo":12}}"# + ), + ( + .taperedArc(radius: 70, extent: 270, widthTo: 10), + #"{"taperedArc":{"extent":270,"radius":70,"widthTo":10}}"# + ), (.dot(8), #"{"dot":{"size":8}}"#), ] @@ -144,3 +152,40 @@ struct CodableTests { #expect(color == Color(red: 0.2, green: 0.4, blue: 0.6, alpha: 1)) } } + +// MARK: - Backward compatibility + +@Suite("Wire-format compatibility") +struct WireFormatCompatibilityTests { + /// Adding a command case must not disturb existing data: a stream recorded + /// before the tapered cases existed still decodes unchanged. + @Test("a pre-taper stream still decodes") + func preTaperStreamDecodes() throws { + let json = """ + [{"penWidth":{"width":1}},{"forward":{"distance":100}},\ + {"arc":{"radius":50,"extent":90}},{"dot":{"size":8}}] + """ + let decoded = try JSONDecoder().decode([TortoiseCommand].self, from: Data(json.utf8)) + #expect( + decoded == [ + .penWidth(1), .forward(100), .arc(radius: 50, extent: 90), .dot(8), + ]) + } + + /// Payloads may grow compatibly, so a reader that predates a future field + /// must ignore it rather than fail. + @Test("an unknown field inside a tapered payload is ignored") + func unknownPayloadFieldIgnored() throws { + let json = #"{"taperedForward":{"distance":200,"widthTo":12,"easing":"linear"}}"# + let decoded = try JSONDecoder().decode(TortoiseCommand.self, from: Data(json.utf8)) + #expect(decoded == .taperedForward(distance: 200, widthTo: 12)) + } + + @Test("a tapered payload missing widthTo fails to decode") + func missingWidthToFails() { + let json = #"{"taperedForward":{"distance":200}}"# + #expect(throws: (any Error).self) { + try JSONDecoder().decode(TortoiseCommand.self, from: Data(json.utf8)) + } + } +} diff --git a/Tests/TortoiseCoreTests/StrokeOutlineTests.swift b/Tests/TortoiseCoreTests/StrokeOutlineTests.swift new file mode 100644 index 0000000..aced7e6 --- /dev/null +++ b/Tests/TortoiseCoreTests/StrokeOutlineTests.swift @@ -0,0 +1,245 @@ +import Foundation +import Testing + +@testable import TortoiseCore + +/// Signed area; positive means counter-clockwise in a y-up space. +private func signedArea(_ polygon: [Point]) -> Double { + guard polygon.count >= 3 else { return 0 } + var total = 0.0 + for i in polygon.indices { + let a = polygon[i] + let b = polygon[(i + 1) % polygon.count] + total += a.x * b.y - b.x * a.y + } + return total / 2 +} + +/// Distance from `p` to the segment `a`–`b`. +private func distance(_ p: Point, toSegment a: Point, _ b: Point) -> Double { + let ab = b - a + let lengthSquared = ab.x * ab.x + ab.y * ab.y + guard lengthSquared > 0 else { return p.distance(to: a) } + var t = ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / lengthSquared + t = min(max(t, 0), 1) + return p.distance(to: Point(x: a.x + t * ab.x, y: a.y + t * ab.y)) +} + +@Suite("Stroke outline geometry") +struct StrokeOutlineTests { + private let tolerance = StrokeOutline.defaultTolerance + + // MARK: Straight strokes + + @Test("an untapered stroke outlines to a stadium of the right width") + func untaperedIsStadium() { + let stroke = Stroke( + from: Point(x: -50, y: 0), to: Point(x: 50, y: 0), color: .black, width: 8) + let polygon = StrokeOutline.polygon(for: stroke) + + // Every vertex sits on the boundary: exactly half a pen width from the + // spine, within flattening tolerance. + for p in polygon { + let d = distance(p, toSegment: stroke.from, stroke.to) + #expect(abs(d - 4) <= tolerance) + } + // And the extremes are the caps, half a width beyond each end. + #expect(abs(polygon.map(\.x).min()! - -54) <= tolerance) + #expect(abs(polygon.map(\.x).max()! - 54) <= tolerance) + } + + @Test("a tapered stroke is half its start width at one end and its end width at the other") + func taperedWidthsAtEnds() { + let from = Point(x: 0, y: 0) + let to = Point(x: 100, y: 0) + let stroke = Stroke(from: from, to: to, color: .black, width: 2, endWidth: 20) + let polygon = StrokeOutline.polygon(for: stroke) + + // Widest extent across the spine, near each endpoint. + let nearStart = polygon.filter { $0.x < 1 }.map { abs($0.y) }.max() ?? 0 + let nearEnd = polygon.filter { $0.x > 99 }.map { abs($0.y) }.max() ?? 0 + #expect(abs(nearStart - 1) <= tolerance) + #expect(abs(nearEnd - 10) <= tolerance) + } + + /// The tangent construction is what distinguishes this from offsetting the + /// endpoints perpendicular to the spine; on a taper the sides must lean. + @Test("the sides of a taper are the discs' external tangents") + func sidesAreExternalTangents() { + let from = Point(x: 0, y: 0) + let to = Point(x: 100, y: 0) + let r0 = 1.0 + let r1 = 10.0 + let polygon = StrokeOutline.polygon( + for: Stroke(from: from, to: to, color: .black, width: r0 * 2, endWidth: r1 * 2)) + + // The four tangent points are exact, so assert them outright. Note the + // sign of sinA: when the pen widens, the small end's tangent leans + // *backwards* past `from` — a perpendicular offset would have put it at + // (0, r0) and left a notch where the side meets the cap. + let sinA = (r0 - r1) / 100 + let cosA = (1 - sinA * sinA).squareRoot() + let expected = [ + Point(x: r0 * sinA, y: r0 * cosA), + Point(x: r0 * sinA, y: -r0 * cosA), + Point(x: 100 + r1 * sinA, y: r1 * cosA), + Point(x: 100 + r1 * sinA, y: -r1 * cosA), + ] + for point in expected { + #expect( + polygon.contains { $0.distance(to: point) <= 1e-9 }, + "missing tangent point \(point)") + } + #expect(sinA < 0) + + // No vertex may fall inside either end disc — that is what "hull" means. + for p in polygon { + #expect(p.distance(to: from) >= r0 - tolerance) + #expect(p.distance(to: to) >= r1 - tolerance) + } + } + + @Test("a zero-length stroke outlines to a disc") + func zeroLengthIsDisc() { + let p = Point(x: 5, y: -3) + let polygon = StrokeOutline.polygon( + for: Stroke(from: p, to: p, color: .black, width: 6)) + #expect(!polygon.isEmpty) + for q in polygon { #expect(abs(q.distance(to: p) - 3) <= tolerance) } + } + + @Test("when one end disc swallows the other the outline is that disc") + func containedDiscCollapses() { + // A 40-wide pen moving 3 units to a 2-wide pen: the start disc contains + // everything the pen sweeps. + let from = Point(x: 0, y: 0) + let polygon = StrokeOutline.polygon( + for: Stroke(from: from, to: Point(x: 3, y: 0), color: .black, width: 40, endWidth: 2)) + for q in polygon { #expect(abs(q.distance(to: from) - 20) <= tolerance) } + } + + @Test("a zero-width stroke outlines to nothing") + func zeroWidthIsEmpty() { + let polygon = StrokeOutline.polygon( + for: Stroke(from: .zero, to: Point(x: 10, y: 0), color: .black, width: 0)) + #expect(polygon.isEmpty) + } + + @Test("outlines are closed, non-degenerate and counter-clockwise") + func windingAndClosure() { + let cases = [ + Stroke(from: .zero, to: Point(x: 100, y: 0), color: .black, width: 6), + Stroke(from: .zero, to: Point(x: 100, y: 0), color: .black, width: 2, endWidth: 16), + Stroke(from: .zero, to: Point(x: -40, y: 70), color: .black, width: 12, endWidth: 3), + ] + for stroke in cases { + let polygon = StrokeOutline.polygon(for: stroke) + #expect(polygon.count >= 3) + // Closed means "not explicitly repeated" — renderers close it. + #expect(polygon.first != polygon.last) + #expect(signedArea(polygon) > 0) + } + } + + @Test("a taper and its reverse enclose the same area") + func reversalIsSymmetric() { + let a = StrokeOutline.polygon( + for: Stroke(from: .zero, to: Point(x: 80, y: 0), color: .black, width: 3, endWidth: 14)) + let b = StrokeOutline.polygon( + for: Stroke(from: Point(x: 80, y: 0), to: .zero, color: .black, width: 14, endWidth: 3)) + #expect(abs(signedArea(a) - signedArea(b)) < 0.01) + } + + // MARK: Arcs + + @Test("an untapered arc outlines to an annulus sector") + func untaperedArcIsAnnulus() { + let arc = ArcStroke( + center: .zero, radius: 50, startAngle: 0, sweep: 90, color: .black, width: 10) + let polygon = StrokeOutline.polygon(for: arc) + + // Every vertex is either on an edge (45 or 55 from center) or on a cap. + let capCenters = [Point(x: 50, y: 0), Point(x: 0, y: 50)] + for p in polygon { + let radial = p.distance(to: .zero) + let onEdge = abs(radial - 45) <= tolerance || abs(radial - 55) <= tolerance + let onCap = capCenters.contains { abs(p.distance(to: $0) - 5) <= tolerance } + #expect(onEdge || onCap) + } + } + + @Test("a tapered arc's edges follow the ramping width") + func taperedArcEdges() { + let arc = ArcStroke( + center: .zero, radius: 60, startAngle: 0, sweep: 180, color: .black, width: 4, + endWidth: 16) + let polygon = StrokeOutline.polygon(for: arc) + + // Outer edge never exceeds radius + half the widest width, and the + // whole shape stays within the widest cap. + for p in polygon { #expect(p.distance(to: .zero) <= 60 + 8 + tolerance) } + // At the start the band is 4 wide; at the end, 16. + let atStart = polygon.filter { abs($0.y) < 0.5 && $0.x > 0 }.map { $0.distance(to: .zero) } + #expect(abs((atStart.max() ?? 0) - (atStart.min() ?? 0) - 4) <= 2 * tolerance) + } + + @Test("a wide pen clamps the inner edge at the centre instead of inverting") + func widePenClampsInnerEdge() { + let arc = ArcStroke( + center: .zero, radius: 10, startAngle: 0, sweep: 120, color: .black, width: 60) + let polygon = StrokeOutline.polygon(for: arc) + // Nothing crosses to a negative radius, which is what inversion would do. + for p in polygon { #expect(p.distance(to: .zero) <= 10 + 30 + tolerance) } + #expect(polygon.contains { $0.distance(to: .zero) <= tolerance }) + } + + @Test("a zero-sweep arc outlines to the cap left behind") + func zeroSweepArcIsDisc() { + let arc = ArcStroke( + center: .zero, radius: 40, startAngle: 0, sweep: 0, color: .black, width: 8) + let polygon = StrokeOutline.polygon(for: arc) + for p in polygon { #expect(abs(p.distance(to: Point(x: 40, y: 0)) - 4) <= tolerance) } + } + + @Test("arc outlines are counter-clockwise for either sweep direction") + func arcWinding() { + for sweep in [90.0, -90.0, 270.0, -270.0] { + let polygon = StrokeOutline.polygon( + for: ArcStroke( + center: .zero, radius: 50, startAngle: 30, sweep: sweep, color: .black, + width: 6, endWidth: 12)) + #expect(polygon.count >= 3) + #expect(signedArea(polygon) > 0, "sweep \(sweep) wound backwards") + } + } + + // MARK: Flattening + + @Test("segment count follows the sagitta tolerance") + func segmentCountFollowsTolerance() { + let coarse = StrokeOutline.segmentCount(radius: 100, sweep: .pi, tolerance: 1) + let fine = StrokeOutline.segmentCount(radius: 100, sweep: .pi, tolerance: 0.01) + #expect(fine > coarse) + + // The realised sagitta must actually respect the tolerance. + let steps = StrokeOutline.segmentCount(radius: 100, sweep: .pi, tolerance: 0.1) + let sagitta = 100 * (1 - cos(.pi / Double(steps) / 2)) + #expect(sagitta <= 0.1 + 1e-9) + } + + @Test("flattening is bounded however absurd the inputs") + func flatteningIsBounded() { + #expect( + StrokeOutline.segmentCount(radius: 1e9, sweep: 2 * .pi, tolerance: 1e-9) + == StrokeOutline.maxSegments) + #expect(StrokeOutline.segmentCount(radius: 0, sweep: .pi, tolerance: 0.1) == 1) + #expect(StrokeOutline.segmentCount(radius: 10, sweep: 0, tolerance: 0.1) == 1) + } + + @Test("outlines are deterministic") + func deterministic() { + let stroke = Stroke( + from: .zero, to: Point(x: 70, y: 20), color: .black, width: 3, endWidth: 11) + #expect(StrokeOutline.polygon(for: stroke) == StrokeOutline.polygon(for: stroke)) + } +} diff --git a/Tests/TortoiseCoreTests/TortoiseCoreTests.swift b/Tests/TortoiseCoreTests/TortoiseCoreTests.swift index ab9d052..508380e 100644 --- a/Tests/TortoiseCoreTests/TortoiseCoreTests.swift +++ b/Tests/TortoiseCoreTests/TortoiseCoreTests.swift @@ -566,7 +566,7 @@ struct StateConsistencyTests { let t = Tortoise() for step in 0..<300 { - switch step % 10 { + switch step % 12 { case 0: t.forward(nextDouble(-150...150)) case 1: t.right(nextDouble(-720...720)) case 2: t.left(nextDouble(0...360)) @@ -582,6 +582,11 @@ struct StateConsistencyTests { t.penDown() } case 8: t.speed = nextDouble(-1...10) + case 9: t.forward(nextDouble(-150...150), widthTo: nextDouble(-2...9)) + case 10: + t.circle( + radius: nextDouble(-120...120), extent: nextDouble(-400...400), + widthTo: nextDouble(-2...9)) default: t.home() } } @@ -696,3 +701,164 @@ struct PointTests { #expect(isClose(Point(x: 0, y: 0).distance(to: Point(x: 3, y: 4)), 5)) } } + +// MARK: - Pen-width taper + +@Suite("Pen-width taper") +@MainActor +struct PenWidthTaperTests { + /// The property the whole design exists for: a taper costs one command, + /// so it occupies one playback frame and animates in the same time as the + /// equivalent untapered move. + @Test("a taper records exactly one command and one frame") + func singleCommandAndFrame() { + let t = Tortoise() + t.penWidth = 1 + t.forward(200, widthTo: 12) + + // penWidth setter + the taper itself. + #expect(t.commands.count == 2) + #expect(t.commands.last == .taperedForward(distance: 200, widthTo: 12)) + + let plain = Tortoise() + plain.penWidth = 1 + plain.forward(200) + #expect( + CommandPlayer.play(commands: t.commands).count + == CommandPlayer.play(commands: plain.commands).count) + } + + @Test("tapered arc records exactly one command") + func taperedArcIsOneCommand() { + let t = Tortoise() + t.circle(radius: 70, extent: 270, widthTo: 10) + #expect(t.commands == [.taperedArc(radius: 70, extent: 270, widthTo: 10)]) + } + + @Test("penWidth after a taper is the requested end width") + func penWidthLandsOnEndWidth() { + let t = Tortoise() + t.penWidth = 1 + t.forward(100, widthTo: 7.5) + #expect(t.penWidth == 7.5) + + t.circle(radius: 40, extent: 90, widthTo: 2) + #expect(t.penWidth == 2) + } + + @Test("a negative end width clamps to zero") + func negativeEndWidthClamps() { + let t = Tortoise() + t.forward(50, widthTo: -4) + #expect(t.penWidth == 0) + #expect(CommandPlayer.play(commands: t.commands).last?.newStroke?.endWidth == 0) + } + + @Test("a taper moves exactly as far as the untapered move") + func geometryMatchesUntapered() { + let tapered = Tortoise() + tapered.right(37) + tapered.forward(123, widthTo: 9) + + let plain = Tortoise() + plain.right(37) + plain.forward(123) + + #expect(isClose(tapered.position, plain.position)) + #expect(isClose(tapered.heading, plain.heading)) + } + + @Test("a tapered arc lands where an untapered arc would") + func arcGeometryMatchesUntapered() { + let tapered = Tortoise() + tapered.circle(radius: -60, extent: 215, widthTo: 11) + + let plain = Tortoise() + plain.circle(radius: -60, extent: 215) + + #expect(isClose(tapered.position, plain.position)) + #expect(isClose(tapered.heading, plain.heading)) + } + + @Test("backward taper mirrors forward taper") + func backwardMirrorsForward() { + let back = Tortoise() + back.backward(80, widthTo: 5) + #expect(back.commands == [.taperedForward(distance: -80, widthTo: 5)]) + #expect(back.penWidth == 5) + } + + @Test("the stroke carries start and end width") + func strokeCarriesBothWidths() { + let t = Tortoise() + t.penWidth = 2 + t.forward(100, widthTo: 8) + + let stroke = try! #require(CommandPlayer.play(commands: t.commands).last?.newStroke) + #expect(stroke.width == 2) + #expect(stroke.endWidth == 8) + #expect(stroke.isTapered) + } + + @Test("the arc stroke carries start and end width") + func arcStrokeCarriesBothWidths() { + let t = Tortoise() + t.penWidth = 3 + t.circle(radius: 50, extent: 120, widthTo: 9) + + let arc = try! #require(CommandPlayer.play(commands: t.commands).last?.newArcStroke) + #expect(arc.width == 3) + #expect(arc.endWidth == 9) + #expect(arc.isTapered) + } + + /// A caller passing its current width gets an ordinary stroke, so it stays + /// eligible for the same-width batching in the canvas renderer. + @Test("a taper to the current width produces an untapered stroke") + func noWidthChangeIsNotTapered() { + let t = Tortoise() + t.penWidth = 4 + t.forward(100, widthTo: 4) + + let stroke = try! #require(CommandPlayer.play(commands: t.commands).last?.newStroke) + #expect(!stroke.isTapered) + #expect(stroke.width == 4 && stroke.endWidth == 4) + } + + @Test("an ordinary move is never tapered") + func ordinaryMoveIsNotTapered() { + let t = Tortoise() + t.penWidth = 5 + t.forward(50) + let stroke = try! #require(CommandPlayer.play(commands: t.commands).last?.newStroke) + #expect(!stroke.isTapered) + #expect(stroke.endWidth == 5) + } + + @Test("a taper with the pen up draws nothing but still sets the width") + func penUpDrawsNothingButSetsWidth() { + let t = Tortoise() + t.penUp() + t.forward(100, widthTo: 9) + + let frame = try! #require(CommandPlayer.play(commands: t.commands).last) + #expect(frame.newStroke == nil) + #expect(t.penWidth == 9) + #expect(frame.tortoiseState.penWidth == 9) + } + + @Test("tapered vertices participate in fills like ordinary moves") + func taperContributesFillVertices() { + let t = Tortoise() + t.beginFill() + t.forward(50, widthTo: 6) + t.right(120) + t.forward(50, widthTo: 1) + t.right(120) + t.forward(50) + t.endFill() + + let fill = CommandPlayer.play(commands: t.commands).compactMap(\.completedFill).last + #expect(try! #require(fill).points.count == 4) + } +} diff --git a/Tests/TortoiseSVGTests/TortoiseSVGTests.swift b/Tests/TortoiseSVGTests/TortoiseSVGTests.swift index 6bd430e..48e1798 100644 --- a/Tests/TortoiseSVGTests/TortoiseSVGTests.swift +++ b/Tests/TortoiseSVGTests/TortoiseSVGTests.swift @@ -216,3 +216,111 @@ struct TortoiseSVGTests { #expect(out.contains("rgba(255,0,0,0.5)")) } } + +// MARK: - Tapered strokes + +@Suite("Tapered stroke SVG") +@MainActor +struct TaperedStrokeSVGTests { + @Test("a tapered stroke becomes a single filled polygon") + func taperedStrokeIsOnePolygon() { + let t = Tortoise() + t.penWidth = 1 + t.forward(150, widthTo: 14) + let svg = t.svg() + + #expect(svg.components(separatedBy: " + + + + + + + + + \ No newline at end of file diff --git a/Tests/TortoiseTestSupport/DrawingScenarios.swift b/Tests/TortoiseTestSupport/DrawingScenarios.swift index 5292052..d7bc1ae 100644 --- a/Tests/TortoiseTestSupport/DrawingScenarios.swift +++ b/Tests/TortoiseTestSupport/DrawingScenarios.swift @@ -18,9 +18,64 @@ extension DrawingScenario { showAfterHide, speedChanges, translucentOverlaps, + taperedStrokes, showcase, ] + /// Covers `taperedForward` and `taperedArc`, including the two cases the + /// renderers special-case: a translucent taper (which must stay a single + /// blended region rather than seaming) and a taper whose end width equals + /// its start width (which must stay an ordinary, batchable stroke). + public static let taperedStrokes = DrawingScenario("taperedStrokes") { t in + t.penUp() + t.setPosition(x: -170, y: 150) + t.penDown() + t.heading = 90 + t.penColor = .blue + t.penWidth = 1 + t.forward(320, widthTo: 22) + + t.penUp() + t.setPosition(x: -170, y: 90) + t.penDown() + t.penColor = .red + t.penWidth = 22 + t.forward(320, widthTo: 1) + + // Translucent: one filled region, so no seams to darken. + t.penUp() + t.setPosition(x: -170, y: 30) + t.penDown() + t.penColor = Color(red: 0, green: 0.5, blue: 0.25, alpha: 0.4) + t.penWidth = 2 + t.forward(320, widthTo: 26) + + // Equal widths: must remain an ordinary stroke. + t.penUp() + t.setPosition(x: -170, y: -20) + t.penDown() + t.penColor = .black + t.penWidth = 5 + t.forward(320, widthTo: 5) + + // Tapered arcs, both sweep directions. + t.penUp() + t.setPosition(x: -90, y: -80) + t.penDown() + t.penColor = .purple + t.penWidth = 2 + t.heading = 90 + t.circle(radius: 55, extent: 300, widthTo: 18) + + t.penUp() + t.setPosition(x: 90, y: -80) + t.penDown() + t.penColor = .orange + t.penWidth = 16 + t.heading = 90 + t.circle(radius: -55, extent: 300, widthTo: 2) + } + /// Covers `forward` and `rotate` (via forward/backward/right/left). public static let linesAndTurns = DrawingScenario("linesAndTurns") { t in for _ in 0..<4 { diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.taperedStrokes.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.taperedStrokes.png new file mode 100644 index 0000000..f0b749c Binary files /dev/null and b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.taperedStrokes.png differ diff --git a/docs/examples/tapered-petals.svg b/docs/examples/tapered-petals.svg new file mode 100644 index 0000000..83dfcce --- /dev/null +++ b/docs/examples/tapered-petals.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file