An anchor is a peg

`[: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
This commit is contained in:
Your Name 2026-10-05 19:12:16 -04:00
parent a6b6c116c6
commit 925c12fc77
25 changed files with 1009 additions and 429 deletions

View file

@ -172,7 +172,6 @@ Every animatable property is a channel, and channels are addressed **by path**:
[:xform :rot] {:animated? false :value 0.0}
[:xform :scale] {:animated? false :value [1.0 1.0]}
[:xform :skew] {:animated? false :value [0.0 0.0]}
[:xform :anchor]{:animated? false :value [0.0 0.0]}
[:geom :pts] {:animated? true :interp :hold :dense {...} :generated {...}}
[:style :color] {:animated? false :value :skin-dark}
[:vis] {:animated? true :interp :hold :keys {0 true, 37 false}}}
@ -324,7 +323,7 @@ to change to allow it.
## Transform: decomposed, never a matrix
```clojure
{:pos [x y] :rot θ :scale [sx sy] :skew [kx ky] :anchor [ax ay]}
{:pos [x y] :rot θ :scale [sx sy] :skew [kx ky]}
```
Stored decomposed for two reasons. Each component has to be independently
@ -334,18 +333,79 @@ entries is meaningless — a rotation tweened through its matrix shears on the w
Composition, per node:
```
local = T(pos) · T(anchor) · R(rot) · K(skew) · S(scale) · T(-anchor)
local = T(pos) · R(rot) · K(skew) · S(scale)
world = world(parent) · pinv · local
```
`:anchor` is Flash's registration point and Blender's origin: rotation and scale
happen about it, and getting it wrong is why hand-placed parts swing rather than
turn.
`:pinv` is Blender's `parent_inverse`, captured at the moment of parenting so the
child does not jump when it acquires a parent. Small, and its absence is the kind
of thing that makes a parenting feature feel broken.
### There is no `:anchor`, because an anchor is a peg
Rotation and scale happen about the node's **own origin**. There is no
registration point in the decomposition, and that is a deletion rather than a
gap, because
```
T(pos) · T(a) · R·K·S · T(-a) ≡ peg at pos+a carrying R·K·S, child at -a
```
to the last bit of the mantissa — `node-test` asserts it. `T(a)·M·T(-a)` is `M`
conjugated by a translation, which is "do `M` in a frame shifted by `a`", and a
**parent already is a shifted frame**. So an anchor was a peg that could not be
selected, could not be keyed, could not be shared between nodes, and could not be
put above a measured channel. Same expressive content, strictly less reach.
What it did, two mechanisms now do, split along who owns the pivot:
**A pivot nobody chose is derived per drag and never stored.** `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` then solves for the position that holds that point
still:
```
q = M⁻¹(c − p) the material point under c
p' = c − M'·q = c − M'·M⁻¹(c − p)
```
so a turn writes `pos` **and** `rot`. Nothing is cached, so nothing can go stale:
the stored anchor was the centre of what the node drew, 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 visibly disagreed and the pivot was wrong.
A symbol with more than one node diverged on the first edit.
**A pivot somebody chose is a peg** — an ordinary `:group` parent, `nest/peg`,
with `:pinv` captured so nothing moves when it appears. Toon Boom's peg, Fusion's
separate Transform node, Harmony's peg-over-the-drawing. It is the answer to the
three things a derived pivot cannot do:
| want | why a derived pivot cannot | what the peg does |
| --- | --- | --- |
| a pivot that persists — an arm turning about its shoulder | a gesture's pivot is the middle of the drawing and lives for one drag | the peg's `pos`, static, nowhere near the middle |
| a pivot that travels — a foot roll | an anchor could only be keyed against `pos`, interpolated in the same breath, the two obliged to agree frame for frame | the peg's `pos` is an ordinary channel, so key it |
| a hand transform over a **measured** one | impossible: `local`'s translation is `pos − M·a`, and under a measured `M` writing `a` moves the thing it was meant to leave alone | the peg's channels are its own, so the hand transform composes outside the measurement, which stays regenerable |
That last row is why the rotoscoped parts were worst. `flow/freeze` used to run a
`pivoted` pass writing a default anchor onto everything it had made, and it
**skipped every `node/measured?` node** — correctly, for the reason in the table.
So the traced mouth, lids and brows got no pivot at all and turned about the
origin of head-local space, which is the top-left corner of the *footage*: on a
320×200 stage the mouth pivoted about (−234, −395), off the stage by more than a
stage. The pass is gone; there is no node a derived pivot can be missing from.
A peg is also how `demo/stage` places its seven faces, and that is the case that
makes the pair necessary rather than tidy: `:scale` is **keyed** — the faces pulse
— and the source's middle has to stay on its authored centre throughout. A static
`pos` cannot do it alone, since `T(pos)·S(k(f))` moves that point whenever `k`
changes. `T(center)·S(k(f))·T(-origin)` does, for every `k`, with nothing keyed
that was not keyed before.
`gesture/refusal` still turns a hand edit on a measured channel away, because the
next regenerate would discard it — but it can now name a way through, and the way
is a peg.
**The similarity fit already produces a decomposition.** `fitSimilarity` returns
`{s θ tx ty}`, which drops straight into `[:xform :scale]`, `[:xform :rot]` and
`[:xform :pos]` with no conversion. The analysis output and the animation model

View file

@ -146,7 +146,6 @@ Full specification in `docs/animation-model.md`. The subset to build:
[:xform :rot] {:animated? false :value 0.0}
[:xform :scale] {:animated? false :value [1.0 1.0]}
[:xform :skew] {:animated? false :value [0.0 0.0]}
[:xform :anchor] {:animated? false :value [0.0 0.0]}
[:geom :pts] {:animated? true :interp :hold
:dense {:store "sha256:…" :offset 0 :stride 40 :frames 600}
:generated {:by :roto/lips-outer :analysis "sha256:…"
@ -170,15 +169,24 @@ uses to offer a parameter panel instead of raw keys. It lives on the *channel*,
not the node, because a node wants a rotoscoped `[:geom :pts]` and a
hand-animated `[:xform :pos]` at the same time.
`:skew`, `:span`, `:anchor` and `:over` stay in the shape even though nothing
drives them yet: each is a component of a decomposition or of a composition
order, and adding one later migrates every stored transform.
`:skew`, `:span` and `:over` stay in the shape even though nothing drives them
yet: each is a component of a decomposition or of a composition order, and adding
one later migrates every stored transform.
`:anchor` was in this list and has since been **deleted**, which is the one place
the reasoning above came out wrong. It is not a component of the decomposition:
`T(a)·M·T(-a)` is `M` conjugated by a translation, and a parent already is a
translated frame, so an anchor is a peg written inline — one that cannot be
selected, keyed, shared, or put above a measured channel. Rotation and scale
happen about the node's own origin; a pivot nobody chose is derived per drag by
`domain/gesture` and a pivot somebody chose is a peg. See
docs/animation-model.md, "There is no `:anchor`, because an anchor is a peg".
Transform composition, per node:
```
local = T(pos) · T(anchor) · R(rot) · K(skew) · S(scale) · T(-anchor)
world = world(parent) · local
local = T(pos) · R(rot) · K(skew) · S(scale)
world = world(parent) · pinv · local
```
## What the prototype knows that you would otherwise rediscover