Skip to content

Add pen-width taper as a command primitive (replaces #50) - #52

Open
wildthink wants to merge 5 commits into
temoki:mainfrom
wildthink:taper-upstream
Open

wildthink wants to merge 5 commits into
temoki:mainfrom
wildthink:taper-upstream

Conversation

@wildthink

Copy link
Copy Markdown

Ramps the pen width across a single move, so a stroke thickens or thins as the tortoise lays it down:

🐢.penWidth = 1
🐢.forward(200, widthTo: 12)
🐢.circle(radius: 70, extent: 270, widthTo: 10)

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. CommandPlayer emits a frame per command including state-only ones, and stepDuration is 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:

#50 this
commands 89 1
SVG elements 44 <line> 1 <polygon>
animation @ speed 5 8.9 s same as a plain forward
seams at alpha < 1 27 none

Wire 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 the CommandSerialization.md row.

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:, no taperWidthQuantum / 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 taperedStrokes scenario with both goldens, a TaperedPetals gallery example, 32 tests (114 total), DocC, and two CLAUDE.md design notes. swift build, swift test, and swift-format lint --recursive --strict Sources Tests all 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

wildthink-pub and others added 5 commits September 21, 2026 16:40
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants