Conversation
Introduces pen-width taper as a *primitive* rather than as sugar over many .penWidth + .forward pairs: two additive command cases, .taperedForward(distance:widthTo:) and .taperedArc(radius:extent:widthTo:), each recording one command and so occupying one playback frame. This is the property the design exists for. An expansion-based taper buys width fidelity with animation time, because CommandPlayer emits a frame per command and stepDuration is distance-independent — so forward(280, widthTo: 12) from width 1 costs 89 commands, 44 SVG <line> elements and 8.9 s of animation. As a single command it costs one frame and one element, and has no interior round caps to darken at alpha < 1. - Stroke and ArcStroke gain `endWidth` (defaulting to `width`) plus `isTapered`. CommandPlayer reads endWidth back from the post-command state, so the clamp in TortoiseState.applying is the only place a negative width is handled. - The state reducer moves the tortoise exactly as the untapered command would and sets penWidth to the requested end width. - Public API is forward(_:widthTo:), backward(_:widthTo:) and circle(radius:extent:widthTo:) — no `steps:` parameter and no tuning constants, since there is nothing to subdivide. Renderers do not yet honour `endWidth`: a tapered stroke currently draws at its start width, with correct geometry. Filling the outline is stage 2 (shared geometry in Core) and stages 3-4 (SVG, canvas). Wire format: both cases are additive, so pre-taper streams still decode unchanged; tests pin the new JSON and cover payload-field tolerance. CommandSerialization.md updated per the checklist in Codable.swift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Computes the region a tapered pen sweeps, as a closed counter-clockwise polygon, in TortoiseCore — so the SVG and canvas renderers cannot disagree about the shape of a taper, and so the hard part is testable by property rather than by comparing pictures. Straight strokes are exact. The outline of a round-cap stroke is the convex hull of its two end discs, whose sides are the discs' external tangents: on a taper those sides lean, and the tangent points sit at r*(sinA, ±cosA) with sinA = (r0 - r1)/d rather than perpendicular to the spine. Offsetting the endpoints perpendicular instead — the obvious cheap approximation — leaves a notch where each side meets its cap. The degenerate cases fall out of the same construction: no length gives a disc, and |sinA| >= 1 means one disc swallows the other, so the outline is just the larger one. Arc strokes are flattened. The pen sweeps an annulus whose edges are at radius ∓ width(t)/2; with a ramping width those are spirals, so they are chorded to a sagitta tolerance (0.1 units, a twentieth of a pixel at the 2x the canvas goldens render at) and bounded at 512 segments. A pen wider than twice the arc radius clamps its inner edge at the centre rather than inverting through it. A clockwise arc is traced outer-edge-backwards, so the polygon is flipped to keep one winding convention for callers. Nothing consumes this yet; wiring it into the renderers is stages 3-4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tapered stroke has no single stroke-width, so TortoiseSVG fills the region the pen sweeps instead of stroking a line: one <polygon> per tapered stroke or arc, built from the shared outline geometry in TortoiseCore. Untapered marks are untouched and still emit <line> and stroked <path>, so nothing about existing output moves — the goldens did not need re-recording. Both renderers will fill the same polygon rather than each deriving its own, which is why the flattened points are used here in preference to SVG's own arc commands: exact <path> caps would look marginally better in isolation but would let the two renderers disagree about the shape of a taper, and agreement is the property worth keeping. This closes the last two of the three objections to the expansion-based version. A taper is one element rather than 44, and being a single filled region it has no interior round caps to blend twice — so a translucent taper no longer darkens at the seams, because it no longer has any. A zero-extent arc still draws nothing, tapered or not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TortoiseUI fills the same outline polygon TortoiseSVG fills, from the shared geometry in TortoiseCore, so the two renderers cannot disagree about the shape of a taper. Three things were needed: - Tapered strokes and arcs fill an outline instead of stroking a path. Because the polygon is in tortoise units, the transform supplies the scale; unlike a stroked path there is no width to scale by hand. - Tapered strokes are excluded from the same-width batching added in temoki#37. This is not only a correctness nicety: `Stroke.width` is the *start* width, so a tapered stroke can compare equal to an untapered run and would otherwise be silently drawn as a plain line. - The in-progress stroke truncates its width ramp along with its spine, so the pen is as thick where the tortoise stands as it will be once the stroke commits. Without this the mark would change width behind the tortoise as the frame finished. Adds a `taperedStrokes` drawing scenario covering both sweep directions and the two cases the renderers special-case — a translucent taper, which stays one blended region, and a taper whose end width equals its start, which stays an ordinary batchable stroke. Both goldens recorded and inspected: 5 polygons and 1 line, the line being the equal-width case. Only the new goldens are added. Re-recording the existing sixteen reproduced them to within a few bytes of rasteriser noise, and they still pass unchanged, so untapered output is byte-compatible and the churn was reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `TaperedPetals`, a rosette whose petals are two tapered arcs each, added to `Gallery.drawings` and the README in README order so the runner emits its SVG. Opaque on purpose: the two arcs of a petal meet cap-to-cap at its tip, and two translucent marks sharing a cap blend twice there. That is the same double-blend any two overlapping translucent strokes have always had — one blend per command, not per sub-segment — but it lands as a conspicuous dot on a showcase drawing. - A DocC section on tapered strokes, and `StrokeOutline` in Topics. - Two CLAUDE.md design notes: why the taper is a primitive rather than sugar (the unit of time in this library is the command), and why the outline geometry lives in Core with the two invariants a future change could quietly break — tapered strokes must never join the same-width batch, and the in-progress stroke must truncate its width ramp along with its spine. The six existing gallery SVGs regenerate byte-identically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ramps the pen width across a single move, so a stroke thickens or thins as the tortoise lays it down:
This replaces #50. You were right to reject that one, and the reason was the animation model, so this rebuilds it around that constraint rather than working past it.
What was wrong with #50. I measured your numbers and they were exact:
forward(280, widthTo: 12)from width 1 cost 89 commands, 44 SVG<line>elements and 8.9 s at the default speed.CommandPlayeremits a frame per command including state-only ones, andstepDurationis distance-independent, so in this library the unit of time is the command. Any taper that expands into sub-segments necessarily buys width fidelity with animation time. No choice of quantum escapes that — "sugar, not a primitive" was the headline of #50 and it was the part that was wrong.What this does instead. Two additive command cases,
.taperedForward(distance:widthTo:)and.taperedArc(radius:extent:widthTo:). One command, one frame, one element:<line><polygon>forwardalpha < 1Wire format stays intact — both cases are additive, pre-taper streams decode unchanged, and there are tests for that plus for payload-field tolerance. I followed the checklist in
Codable.swift, including theCommandSerialization.mdrow.Geometry lives in
TortoiseCore(StrokeOutline), and both renderers fill the same polygon, so they cannot disagree about the shape of a taper. Straight strokes are exact: the outline is the convex hull of the two end discs, the sides being their external tangents — offsetting endpoints along the perpendicular instead leaves a notch where side meets cap. Arc edges are spirals (radius ∓ width(t)/2) and are flattened to a sagitta tolerance, bounded at 512 segments. SVG deliberately uses those flattened points rather than its own arc commands: exact<path>caps would look marginally better in isolation and would let the renderers drift apart.The public API is smaller than #50's — no
steps:, notaperWidthQuantum/maxTaperSteps. There is nothing to subdivide, so nothing to tune.One limitation, stated plainly. Two adjacent translucent tapers still double-blend where they share a round cap — one blend per command, exactly as two overlapping translucent strokes have always behaved here. So the seam problem is fixed within a taper, not between marks. It is why the gallery example is opaque.
Untapered output is byte-identical: existing goldens pass unchanged, and the six existing gallery SVGs regenerate identically. Adds a
taperedStrokesscenario with both goldens, aTaperedPetalsgallery example, 32 tests (114 total), DocC, and two CLAUDE.md design notes.swift build,swift test, andswift-format lint --recursive --strict Sources Testsall pass.Happy to close #50 if you would rather review only this. And if a tapered pen is not a direction you want the library to go, say so and I will keep it in my fork — no hard feelings either way.
🤖 Generated with Claude Code