A node has a pivot

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>
This commit is contained in:
Your Name 2026-10-06 14:33:35 -04:00
parent e7f5f82845
commit ddef5c6bfd
23 changed files with 983 additions and 565 deletions

View file

@ -333,7 +333,7 @@ entries is meaningless — a rotation tweened through its matrix shears on the w
Composition, per node:
```
local = T(pos) · R(rot) · K(skew) · S(scale)
local = T(pos) · T(piv) · R(rot) · K(skew) · S(scale) · T(-piv)
world = world(parent) · pinv · local
```
@ -341,99 +341,132 @@ world = world(parent) · pinv · local
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
### A node has a `:pivot`, and a peg is still 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
Rotation and scale happen about the node's **pivot**, `[:xform :pivot]`, a point
in its own coordinates:
```
local = T(pos) · T(piv) · R(rot) · K(skew) · S(scale) · T(-piv)
= T(pos + piv - M·piv) · M
```
Toon Boom gives every layer and every peg a pivot, Flash gives every instance a
transformation point, After Effects calls it the anchor point. All three store
it, and the reason is one sentence: **a turn has to be a turn on every frame**,
and the only way to keep a point still through an interpolated angle is for the
angle to be composed about that point.
This was deleted in schema 7 and restored in schema 8, and the argument for
deleting it was *not wrong*, which is why it is worth writing down. It was:
```
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.
to the last bit of the mantissa — `node-test` asserts it, still. `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 written
inline, and a peg can be selected, keyed, shared between nodes and put above a
measured channel. Same expressive content, strictly more 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:
**What that identity does not say is what a node turns about when nobody has
made a peg.** It is an equivalence between a pivot and a peg *that already
exists*; it is silent on the default, and the default is what a person meets.
With no pivot in the composition, a turn about any point that is not the node's
own origin has to be paid for by writing `pos` as well — `gesture/about` solves
for it:
```
q = M⁻¹(c − p) the material point under c
p' = c − M'·q = c − M'·M⁻¹(c − p)
q = M⁻¹(c − t) the material point under c
p' = c − M'·q
```
so a turn about a point that is **not** the node's origin writes `pos` as well as
`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.
**And a drawing's origin is the middle of what it draws**, from the moment it is
drawn — `paint/centred`, which splits a stroke into a ring about its own middle
and the `pos` that puts it back. This is what keeps the paragraph above from being
the whole story, because the `pos` that `about` solves for is an **arc** in the
angle and `pos` interpolates along the **chord**:
and that solution is an **arc** in the angle while `pos` interpolates along the
**chord**:
| | pivot = origin | pivot ≠ origin |
| --- | --- | --- |
| one drag | right | right |
| between two keys | right | **wrong**, by the sagitta of the arc |
A 360° turn is where that is unmissable and was first seen: 0° and 360° are the
only two frames where a wrong pivot cannot be seen at all, so the keys looked
right and every frame between them was wrong — a shape keyed bottom-left to
top-centre with one full turn on the way left the stage completely in the middle
of the spin, orbiting its origin at a radius of 126 px on a 320×200 stage, because
a stroke used to be stored exactly as drawn and its origin was therefore the
**symbol's** origin, the top-left corner of the stage.
A 360° turn is where that is unmissable: 0° and 360° are the only two frames
where a wrong pivot cannot be seen at all, so the keys look right and every
frame between them is wrong.
With the origin on the content there is nothing to solve: `gesture/at-origin?`
holds, `turn` writes `rot` alone, `scale` writes `scale` alone, and a keyed turn is
right on every frame. `about` is then needed only where the pivot genuinely is not
any node's origin — a multi-selection about its shared box, a measured part, or a
drawing whose points have been edited away from their own middle — and in each of
those a pivot that has to be **keyed** is a peg, below. 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`.
**A drawing escaped it. A symbol instance could not.** `paint/centred` puts a
shape's origin on the middle of what it draws the moment it is drawn, so for a
drawing the pivot *is* the origin, `about` has nothing to do, and a keyed turn is
right between its keys. An instance's origin is its **symbol's**, and a symbol is
drawn on the stage, so its origin is the stage's top-left corner. Measured from
the document this was reported on: a symbol holding six drawn shapes had its
content centred at (99, 127), 161 px from its own origin, on a 320×200 stage. One
instance of it, keyed `rot` 0 → 60 and dragged round by hand, put the drawing at
(115, 116) on frame 0 and (241, 104) on frame 60 — both where they were put — and
at (−88, 121) on frame 30, a stage and a half from either. The answer on offer
was "make a peg first", for wanting to spin a drawing.
**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:
So the pivot is back, with the default and the escape hatch spelled out, because
a stored pivot without either is the field that was deleted:
| want | why a derived pivot cannot | what the peg does |
| | what | where |
| --- | --- | --- |
| 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, and the drawing's own origin cannot be moved there without moving its points out from under everything that reads them | 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 |
| **the default, for a node nobody has pivoted** | the middle of what it draws — `pick/bounds-of`, the same call the selection box comes from, so the cross starts out on the middle of the box | `gesture/pivot` |
| **choosing it, invisibly** | the first turn or scale writes that middle down, in the same edit, with the `pos` that holds the picture still | `gesture/with-pivot` |
| **choosing it, by hand** | ⌃/⌘-drag the cross on the stage: the pivot goes under the pointer and nothing moves | `gesture/repivot`, `::ui/repivot` |
| **a placement** | `clip/place-symbol` stores the middle of what the symbol draws as the instance's pivot, so an instance turns about its drawing from the moment it is dropped | `clip/place-symbol` |
| **putting it back** | ⌖ beside the pivot row in the inspector: back to the middle of what the node draws *now*, moving nothing | `gesture/centred`, `::ui/centre-pivot` |
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 pivot is a choice, and does not follow the drawing.** Once it is the node's
own, the derived middle is never consulted for it again. This is the half the
old stored anchor got right and the derived pivot got wrong: adding a shape
inside a symbol must not re-aim every keyed spin of every instance of it, and a
pivot that tracked the content did exactly that, silently, with nothing changing
on screen at the moment it happened. The cross is visible and draggable and ⌖
puts it back, which is what the anchor was missing — it was never the storing
that was wrong.
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.
**A peg is an ordinary `:group` parent, `nest/peg`, with `:pinv` captured so
nothing moves when it appears.** It is no longer the answer to "this turns about
the wrong point", and it is still the answer to three things a node's own pivot
is not:
`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.
| want | why the node's own pivot is not it | what the peg does |
| --- | --- | --- |
| a pivot **shared** between nodes — an arm and a forearm about one shoulder | two pivots that have to agree frame for frame are not one pivot | one transform, two children hanging off it |
| a **second** transform on one node — a drawing spinning about its middle while the limb swings about the shoulder | a node has one `rot` | stack them, as Harmony does |
| a hand transform over a **measured** one | `gesture/refusal` turns a drag on a measured channel away, because the next regenerate would discard it | the peg's channels are its own, so the hand transform composes outside the measurement, which stays regenerable |
The pivot of a measured node is *not* in that table: `[:xform :pivot]` is
authored on every node alike, never dense and never regenerated, so a traced
mouth can be told to turn about its own middle without a peg and with nothing a
regenerate will throw away. That is the row that used to be impossible — writing
an anchor under a measured `M` moved the thing it was meant to leave alone,
because the old composition was `T(pos)·M·T(-a)` and `pos` was the measurement's.
The conjugated form has no such problem: `T(a)·M·T(-a)` is the identity at `a`
whatever `M` is.
`demo/stage` places its seven faces on pegs, and that is now one way of writing
something a pivot says directly: the faces' `:scale` is **keyed** — they pulse —
and the source's middle has to stay on its authored centre throughout, which a
static `pos` cannot do since `T(pos)·S(k(f))` moves that point whenever `k`
changes. `T(center)·S(k(f))·T(-origin)` does, for every `k`, and so does one
instance with its pivot on the middle. The demo is left as it is, pegs and all:
it is a hand-authored scene that renders correctly and `instance-test` asserts
its structure, and a peg carrying a keyed scale is a perfectly good thing to
have written.
`gesture/about` survives for the one gesture whose pivot belongs to no node: a
**multi-selection** scaling about the middle of its shared box, where every
member has to move to keep the arrangement. Nobody keys that.
Schema 8 is the first version that **converts** rather than refusing. A schema-7
node has no pivot, 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, dense tier-2 transforms included, and the migration only restamps the
version. What a converted document does not get is a pivot anybody chose; its
nodes still turn about their origins until the first turn writes one or the cross
is dragged.
**The similarity fit already produces a decomposition.** `fitSimilarity` returns
`{s θ tx ty}`, which drops straight into `[:xform :scale]`, `[:xform :rot]` and