Rotation and scale are composed about `[:xform :pivot]`, a point in the
node's own coordinates:
local = T(pos) · T(piv) · R · K · S · T(-piv)
Schema 7 deleted this field, on the argument that an anchor is a peg. The
algebra was right and the conclusion was not. The identity holds between a
pivot and a peg THAT ALREADY EXISTS; it says nothing about what a node
turns about when nobody has made one, and that default is what a person
meets. With no pivot in the composition, a turn about anything but the
node's own origin has to be paid for by solving `pos` per frame —
`gesture/about` — and that solution is an arc in the angle while `pos`
tweens along the chord. Right on the frame it is written, wrong on every
frame between two keys.
A drawing escaped it: `paint/centred` puts a shape's origin on the middle
of what it draws. A symbol instance cannot — its origin is its symbol's,
and a symbol is drawn on the stage, so its origin is the top-left corner
of the stage. Off the document this was reported on: a symbol's content
centred 161 px from its own origin, and one instance of it keyed rot 0→60
put the drawing where it was put on both keys and at (-88, 121) halfway
between, a stage and a half away. The advice on offer was "make a peg
first", for wanting to spin a drawing.
So a turn now writes `rot` and nothing else, always, and the pivot is held
exactly between two keys because the matrix is built about it on every
frame. The default, and the way back to it, are the parts the old anchor
was missing:
- a node nobody has pivoted turns about the middle of what it draws,
`pick/bounds-of` — the same bounds the selection box comes from
- the first turn or scale writes that middle down, in the same edit,
with the `pos` that holds the picture still (`gesture/with-pivot`)
- `clip/place-symbol` stores the middle of what a symbol draws as the
instance's pivot, so a drop spins in place from the start
- ⌃/⌘-drag the cross on the stage to put the pivot anywhere, moving
nothing — on any node now, not pegs alone
- ⌖ beside the pivot row in the inspector puts it back on the middle of
what the node draws NOW (`gesture/centred`)
A pivot is a CHOICE and does not follow the drawing: once it is the node's
own, adding a shape inside a symbol cannot re-aim a keyed spin of any
instance of it. `instance-test` has asserted both answers to that now, and
the stored one is right.
A peg stays a peg, for the three things a node's own pivot is not: a pivot
SHARED between nodes, a SECOND transform on one node, and a hand transform
over a measured one. `nest/repivot` is gone — a pivot inside the node's own
transform has nothing to correct in anybody else's `:pinv`, so the gesture
works on every node and is no longer refused on an animated one. A measured
node's pivot is authored like any other, so a traced mouth can be told
where to turn without a peg.
Schema 8, and the first version that converts rather than refusing: an
absent pivot reads as [0 0] and T(pos)·T(0)·M·T(-0) is T(pos)·M to the
bit, so every stored document composes to exactly the matrices it did and
the migration only restamps the version.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shape keyed from the bottom left to the top centre with a 360° turn on the way
left the stage completely in the middle of the spin and came back. The keys were
right and every frame between them was wrong, which is the signature of a wrong
pivot and a full turn: 0° and 360° are the only two frames where a wrong pivot
cannot be seen at all.
`paint/new-shape` stored a stroke EXACTLY AS DRAWN, in the containing symbol's
coordinates, and wrote no `pos`. So a drawing's origin was the SYMBOL's origin —
on the stage, its top-left corner. `node/local!` turns and scales about the
node's own origin and nothing else, deliberately, since `[:xform :anchor]` was
deleted in 925c12f. A node whose origin is nowhere near its content therefore
turns about nowhere near its content: the reported shape orbited at a radius of
126 px on a 320x200 stage.
It could not be seen while a drag was the only way to turn something, because
`gesture/about` solves for the `pos` that holds the chosen pivot still and
`turn` wrote it alongside the rotation — exactly right on the frame of the drag.
But that solution is `p' = c + R(θ)(p − c)`, an ARC, and `pos` interpolates along
the CHORD. Right on a drag, right on a key, wrong on every frame between two.
So `paint/centred` splits a stroke into a ring about its own middle and the `pos`
that puts it back, and `new-shape` is the one place every drawing is born — the
pen, the brush, and each piece the eraser leaves. The pivot rule is unchanged,
the middle of what the node draws; for a drawing that point is now its ORIGIN, so
`gesture/at-origin?` holds, `turn` writes `rot` alone, `scale` writes `scale`
alone, and a keyed turn is right on every frame. `pos` goes back to being the
motion path it reads as. Hand-authored scenes were always written this way:
`demo/scene.edn`'s card is `[-44 -30 44 -30 44 30 -44 30]` with its place in
`pos`.
NOT the universal rule, and `a-face-part-scales-about-its-own-middle` is why. A
measured part's points and position are dense tier-2 geometry in the footage's
space and cannot be re-originated, so its pivot is not its origin and `about` is
the only thing that will hold it; the same is true of an instance, whose origin
IS its symbol's coordinate system. Both still drag correctly about their middle,
both are inexact if that drag is keyed, and for both a pivot that has to persist
or be keyed is a peg — which is a node, so its pivot is its own origin, so it
collapses again one level up.
`cut/erase` took EVERY leftover piece back through `world⁻¹`, the cut shape's own
coordinates, and handed the offcuts to `new-shape`, which gives them a fresh
identity transform. Those two spaces coincide only while a shape has `pos [0 0]`,
which was every shape, so erasing anything that had been moved already scattered
its offcuts, silently. The kept piece comes back through `world⁻¹` and the new
ones through `parent⁻¹`, the space a node's `pos` lives in.
And `::adjust-last` re-traces the same stroke from stage pixels, so it goes
through `paint/place-points` rather than writing symbol-space points into a node
that now has a position of its own.
No schema change: the same fields, better values. An existing document keeps
evaluating exactly as it does now.
`a-keyed-turn-holds-its-pivot-between-its-keys` checks all 31 frames of the
tween. Checking the keys is what let this through. 604 CLJS tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`[:xform :anchor]` is deleted. `T(a)·M·T(-a)` is a transform conjugated by a
translation — "do M in a frame shifted by a" — and a parent already IS a shifted
frame, so an anchor was a peg written inline: one that could not be selected,
keyed, shared between nodes, or placed above a measured channel. Same expressive
content, strictly less reach. `node-test` asserts the two produce the same matrix.
Its two jobs split, and neither is a field on a node any more.
A pivot nobody chose is DERIVED PER DRAG and stored nowhere. `gesture/pivot` is
the middle of what the node draws — `pick/bounds-of`, the same call the stage
draws the selection box from, on the same frame — or the node's own origin when it
draws nothing. `gesture/about` solves for the position that holds that point
still, so a turn now writes `pos` as well as `rot`. Nothing is cached, so nothing
goes stale: the stored anchor was that same middle captured once at creation while
the box beside it was recomputed every render, so on anything edited since it was
made the cross and the box disagreed and the pivot was wrong. A symbol with more
than one node diverged on its first edit.
A pivot somebody chose is a PEG — `nest/peg`, an ordinary `:group` parent sitting
on the derived pivot with `:pinv` captured so nothing moves. It is the answer to
the three things a derived pivot cannot do: a pivot that persists (an arm about
its shoulder), a pivot that travels (a keyed `pos`), and a hand transform over a
measured one. The last was impossible before — `local`'s translation is
`pos − M·a`, so under a measured `M` writing an anchor moves the thing it was
meant to leave alone. `flow/freeze`'s `pivoted` pass knew this and skipped every
`node/measured?` node, which is exactly why the traced mouth pivoted about
(-234, -395) on a 320x200 stage: the top-left corner of the footage. That pass is
gone; there is no node a derived pivot can be missing from.
`demo/stage` is the one place the anchor did work a static `pos` cannot: `:scale`
is keyed, and the source's middle has to stay on its authored centre throughout.
It is now seven pegs, identical to the pixel.
Also: `events/ui`'s `fitted` rescales a dropped tracing right after placement, and
the anchor had been silently keeping the picture centred through that; it solves
for the middle explicitly now.
Schema 7. Nothing is converted, as in 6: every project is marked 7 and one still
carrying an anchor is refused by name, with what to do about it. Dropping an
anchor is pixel-exact wherever rotation and scale are the identity — everywhere a
freeze or a drop wrote one — but not on anything since turned by hand, and not at
all where `pos` is dense, so a conversion would be silent and wrong for exactly
the nodes somebody had placed themselves.
601 CLJS tests, 68 Django tests, and the onion and take browser suites pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
Finishing the last commit, which traded a simpler definition for noisier call
sites: deleting the arity ladders left `(symbol/resolver sym st pal/index-of
nil nil)` at twenty-odd places, and two trailing nils tell a reader nothing
except to go and count positions.
The ladder was a symptom. The disease is five positional parameters, and the
split that matters is which of them are OPTIONAL:
store, palette positional, because neither is optional. A dense channel
cannot be read without the store it names — that is the
crash two commits ago — and every op carries a colour.
pose-tracks one call site, in `clip/resolver`'s own recursion
source/picture-fps two call sites
So the last three become one `opts` map, and the common call loses a nil. The
point is not the nil: it is that the sixth option, whenever it arrives, is a
key at one call site rather than a nil at fifty.
`clip/resolver` also had `sid` FOURTH, behind two arguments that say nothing
about which symbol is being resolved. It is second now, beside the clip it is
in: `(clip/resolver c :main store pal/index-of nil)`.
62 call sites rewritten by parsing the forms rather than by regex, because
`clip/resolver`'s arguments move past each other and a regex cannot see that.
An earlier attempt at this dropped `palette` on the floor and still compiled
at 62 sites — it only surfaced as an arity error, so if that had been a
same-arity mistake the tests would have been the last line of defence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pre-alpha. Nothing here is owed a call shape it used to have.
Twelve convenience arities deleted, and the only reason to single any of
them out is that one of them was a live bug: `channel/value-at`'s `([ch f])`
filled in a nil tier-2 store, so a caller could omit it, read correctly for
every channel that happened not to be dense, and throw the first time one
was. That is the iris crash, and threading the store through `gesture/values`
last commit fixed the symptom while leaving the trapdoor open. Deleting the
arity found `node/toggle-key` standing on it too — the inspector's stopwatch
on a measured channel, the same throw, never reported.
Gone, and what the compiler then made explicit at each site:
channel/value-at, cursor, dense-at the store, and `nil` where a caller
genuinely has none and means it
channel/keyed `:hold`, which is a cut rather than a
tween and not a thing to leave implied
symbol/resolver (4), eval-frame (3) store, palette, pose-tracks, opts
clip/resolver opts
mix/buffer!, store/install! dead: no caller used the short form
`pick/local-bounds` goes the same way — it was `bounds-of` with the closure
thrown away, so callers build the closure and call it.
Every site was found by shadow-cljs `:fn-arity` rather than by grep, which is
the argument for the change: 90-odd call sites, and the compiler listed all of
them. BUILD BOTH TARGETS — the last three only appear in `:app`, since `:test`
compiles what the tests reach and the inspector, the pool drag and the vertex
overlay are not that.
Left alone, because an argument with a default is not the same thing as a
shim: genuine optionality like `fx/http`'s body, `geom`'s iteration count,
`zip`'s injected clock.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three faults, one gesture. None of them was in `gesture/scale`, whose
`s' = s·b/a` about the pivot was right all along.
THE PIVOT. `clip/place-symbol` writes an instance's anchor, `paint/new-shape`
a drawing's, `nest/group` a new symbol's and `face-placement` the source
placement's — and `flow/freeze` wrote none for the parts underneath, so a
traced mouth turned and scaled about its own coordinate ORIGIN, which for
head-local geometry is the top-left corner of the footage. On a 320x200 stage
that put the mouth's pivot at (-234, -395), so dragging a corner outward slid
the shape about and shrank it. `pick/pivot` is `clip/center`'s rule for a node
rather than a symbol; `freeze/pivoted` applies it to every node the freeze
makes, skipping `node/measured?` — the predicate `gesture/refusal` already
refuses a hand edit by, so a pivot is written exactly where a hand can use
one. That also keeps it off `:head`, whose scale is not 1 and where an anchor
would NOT cancel out of `local!`; skipping it for drawing nothing would have
been true only by accident. Asserted in pixels: the pass moves nothing.
THE JUMP. `ui/stage`'s overlay dereferenced the document while RENDERING and
used it when the pointer went down. The store is a mutable handle behind an id
that does not change when the document does — `:paint/revision` says that, and
the overlay subscribes to neither it nor `::render/clip` — so after any edit
the next drag began from the transform the node had before the last one: still
under the press, jumping on the first pointermove. `ctx` now carries the id and
`loaded` reads at pointer-down.
THE CRASH. `gesture/values` read its channels without the tier-2 store, which
is fine until a selection lands on a dense transform — an iris follows the
gaze, a brow the raise, a head the similarity — and then `dense-at` throws and
takes the stage down, in `begin!` and again in `handles`. It takes the store
now. Kept in this commit because it is the same two functions.
`pick/bounds-of` yields a closure, as `clip/resolver` does, so an instance's
resolver is built once for a walk instead of once per frame — which is what
let `pivot` be the one walk it is rather than a separate path for instances.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A face's :head carries :trace {:frames :origin}: the frames its photo holds
on, and whether the head reads every frame, jumps to the trace frames, or
holds frame 0. It replaces :anchors, so which measured frame a head reads is
one stored fact. An instance's :underlay shows the tracing stills over every
face at or below it, registered through each face's own head, at an opacity,
unkeyed. The clip resolver answers where a row path went on its last frame,
so the paint loop reads the photo's matrix instead of resolving again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A click selects the thing in the open symbol, a double-click goes one
level in, ⌘-click goes to the shape itself and Esc comes back out —
Figma's rule — and a click inside the selection keeps it, so a shape
several symbols down can be dragged. The selection is the one a
timeline row makes, so the inspector shows it and its row opens and
scrolls into view.
A drag writes what the inspector writes: a key on the node's own frame
where the channel has keys, its one value where it has none. It is
previewed like a bar being slid and let go as one edit, so one undo
step. Measured transforms refuse. A shape's points are edited by
double-clicking it, and new shapes turn about their middle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>