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

@ -0,0 +1,45 @@
"""Schema 8: a node has a pivot.
`[:xform :pivot]` is back in the transform, as the point rotation and scale are
composed about: `local = T(pos)·T(piv)·R·K·S·T(-piv)`. Schema 7 deleted it, on
the argument that an anchor is a peg — true as algebra, and not true as a feature.
A peg is a node, and a turn about a point that is not the turning node's own
origin still has to solve for a position to hold that point still; that solution
is an arc in the angle while a position channel tweens along the chord, so it is
right on the frame it is written and wrong on every frame between two keys. A
drawing escaped it, since its origin is the middle of what it draws. A symbol
instance could not: its origin is its symbol's, which is the top-left corner of
the stage, so one keyed turn of an instance swung its drawing round that corner
on an orbit the size of the stage.
CONVERTED, unlike 6 and 7, because adding this one is exact. 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 last bit of the mantissa. Every stored document therefore composes to exactly
the matrices it composed to before, dense tier-2 transforms included, so there is
nothing to guess at and no node a conversion could silently move. The version is
restamped and nothing else is touched.
What a converted document does NOT get is a pivot somebody chose: nodes placed
before this carry none, so they still turn about their own origin until the first
turn or scale writes one — `gesture/with-pivot`, from the middle of what the node
draws at that moment — or until the cross is dragged (ctrl/cmd-drag on the stage).
"""
from django.db import migrations, models
def forwards(apps, schema_editor):
apps.get_model("clips", "Project").objects.update(schema_version=8)
class Migration(migrations.Migration):
dependencies = [("clips", "0016_an_anchor_is_a_peg")]
operations = [
migrations.AlterField(
model_name="project",
name="schema_version",
field=models.PositiveIntegerField(default=8),
),
migrations.RunPython(forwards, migrations.RunPython.noop),
]

View file

@ -250,7 +250,7 @@ class Project(models.Model):
settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects", settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects",
) )
name = models.CharField(max_length=200, default="untitled") name = models.CharField(max_length=200, default="untitled")
schema_version = models.PositiveIntegerField(default=7) schema_version = models.PositiveIntegerField(default=8)
seq = models.PositiveBigIntegerField(default=0) seq = models.PositiveBigIntegerField(default=0)
palette = models.CharField(max_length=64, default="arthur/default") palette = models.CharField(max_length=64, default="arthur/default")
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)

View file

@ -333,7 +333,7 @@ entries is meaningless — a rotation tweened through its matrix shears on the w
Composition, per node: 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 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 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. 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 Rotation and scale happen about the node's **pivot**, `[:xform :pivot]`, a point
registration point in the decomposition, and that is a deletion rather than a in its own coordinates:
gap, because
```
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 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` to the last bit of the mantissa — `node-test` asserts it, still. `T(a)·M·T(-a)`
conjugated by a translation, which is "do `M` in a frame shifted by `a`", and a is `M` conjugated by a translation, which is "do `M` in a frame shifted by `a`",
**parent already is a shifted frame**. So an anchor was a peg that could not be and a **parent already is a shifted frame**. So an anchor was a peg written
selected, could not be keyed, could not be shared between nodes, and could not be inline, and a peg can be selected, keyed, shared between nodes and put above a
put above a measured channel. Same expressive content, strictly less reach. measured channel. Same expressive content, strictly more reach.
What it did, two mechanisms now do, split along who owns the pivot: **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
**A pivot nobody chose is derived per drag and never stored.** `gesture/pivot` is exists*; it is silent on the default, and the default is what a person meets.
the middle of what the node draws — `pick/bounds-of`, the same call the stage With no pivot in the composition, a turn about any point that is not the node's
draws the selection box from, on the same frame — or the node's own origin when it own origin has to be paid for by writing `pos` as well — `gesture/about` solves
draws nothing. `gesture/about` then solves for the position that holds that point for it:
still:
``` ```
q = M⁻¹(c − p) the material point under c q = M⁻¹(c − t) the material point under c
p' = c − M'·q = c − M'·M⁻¹(c − p) p' = c − M'·q
``` ```
so a turn about a point that is **not** the node's origin writes `pos` as well as and that solution is an **arc** in the angle while `pos` interpolates along the
`rot`. Nothing is cached, so nothing can go stale: the stored anchor was the **chord**:
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**:
| | pivot = origin | pivot ≠ origin | | | pivot = origin | pivot ≠ origin |
| --- | --- | --- | | --- | --- | --- |
| one drag | right | right | | one drag | right | right |
| between two keys | right | **wrong**, by the sagitta of the arc | | 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 A 360° turn is where that is unmissable: 0° and 360° are the only two frames
only two frames where a wrong pivot cannot be seen at all, so the keys looked where a wrong pivot cannot be seen at all, so the keys look right and every
right and every frame between them was wrong — a shape keyed bottom-left to frame between them is wrong.
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.
With the origin on the content there is nothing to solve: `gesture/at-origin?` **A drawing escaped it. A symbol instance could not.** `paint/centred` puts a
holds, `turn` writes `rot` alone, `scale` writes `scale` alone, and a keyed turn is shape's origin on the middle of what it draws the moment it is drawn, so for a
right on every frame. `about` is then needed only where the pivot genuinely is not drawing the pivot *is* the origin, `about` has nothing to do, and a keyed turn is
any node's origin — a multi-selection about its shared box, a measured part, or a right between its keys. An instance's origin is its **symbol's**, and a symbol is
drawing whose points have been edited away from their own middle — and in each of drawn on the stage, so its origin is the stage's top-left corner. Measured from
those a pivot that has to be **keyed** is a peg, below. Hand-authored scenes were the document this was reported on: a symbol holding six drawn shapes had its
always written this way: `demo/scene.edn`'s card is content centred at (99, 127), 161 px from its own origin, on a 320×200 stage. One
`[-44 -30 44 -30 44 30 -44 30]` with its place in `pos`. 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`, So the pivot is back, with the default and the escape hatch spelled out, because
with `:pinv` captured so nothing moves when it appears. Toon Boom's peg, Fusion's a stored pivot without either is the field that was deleted:
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 | | | 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 | | **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` |
| 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 | | **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` |
| 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 | | **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 **A pivot is a choice, and does not follow the drawing.** Once it is the node's
`pivoted` pass writing a default anchor onto everything it had made, and it own, the derived middle is never consulted for it again. This is the half the
**skipped every `node/measured?` node** — correctly, for the reason in the table. old stored anchor got right and the derived pivot got wrong: adding a shape
So the traced mouth, lids and brows got no pivot at all and turned about the inside a symbol must not re-aim every keyed spin of every instance of it, and a
origin of head-local space, which is the top-left corner of the *footage*: on a pivot that tracked the content did exactly that, silently, with nothing changing
320×200 stage the mouth pivoted about (−234, −395), off the stage by more than a on screen at the moment it happened. The cross is visible and draggable and ⌖
stage. The pass is gone; there is no node a derived pivot can be missing from. 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 **A peg is an ordinary `:group` parent, `nest/peg`, with `:pinv` captured so
makes the pair necessary rather than tidy: `:scale` is **keyed** — the faces pulse nothing moves when it appears.** It is no longer the answer to "this turns about
— and the source's middle has to stay on its authored centre throughout. A static the wrong point", and it is still the answer to three things a node's own pivot
`pos` cannot do it alone, since `T(pos)·S(k(f))` moves that point whenever `k` is not:
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 | want | why the node's own pivot is not it | what the peg does |
next regenerate would discard it — but it can now name a way through, and the way | --- | --- | --- |
is a peg. | 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 **The similarity fit already produces a decomposition.** `fitSimilarity` returns
`{s θ tx ty}`, which drops straight into `[:xform :scale]`, `[:xform :rot]` and `{s θ tx ty}`, which drops straight into `[:xform :scale]`, `[:xform :rot]` and

View file

@ -589,13 +589,17 @@
that draws nothing gets the STAGE's middle, which is where a drawing made into that draws nothing gets the STAGE's middle, which is where a drawing made into
it will be, because drawings are made on the stage. it will be, because drawings are made on the stage.
WHERE A DROP LANDS, and nothing else any more. It used to be copied into each WHERE A DROP LANDS, AND WHAT THE INSTANCE TURNS ABOUT. `place-symbol` puts this
new instance's anchor as a pivot default, which made it a cache of a derived point under the pointer and stores it as the instance's `[:xform :pivot]`, and
value that nothing invalidated — so a symbol edited afterwards kept its `ui/drag`'s ghost draws the cross there so a drop lands where it was aimed —
instances pivoting about where its drawing had been. Pivots are derived per one point, one meaning, three uses.
drag now and there is nothing to keep in step; see `domain/gesture`. What is
left is positional: `place-symbol` puts this point under the pointer, and IT IS A DEFAULT AND NOT A CACHE, which is the distinction the stored anchor got
`ui/drag`'s ghost draws the cross there so a drop lands where it was aimed." wrong. A pivot written here is a CHOICE made on the instance's behalf at the
moment it is placed, the same way `paint/centred` chooses a drawing's origin
when it is drawn; editing the symbol afterwards does not revise either, and the
cross is draggable so neither is a trap. What the anchor got wrong was being
invisible and unmovable, not being stored."
[clip store sid] [clip store sid]
(let [resolve (resolver clip sid store pal/index-of {:grid-fps (fps clip sid)}) (let [resolve (resolver clip sid store pal/index-of {:grid-fps (fps clip sid)})
bounds (fn [[x0 y0 x1 y1 :as b] x y] bounds (fn [[x0 y0 x1 y1 :as b] x y]
@ -618,12 +622,19 @@
(defn place-symbol (defn place-symbol
"An instance of symbol `sid`, inside symbol `host`, at `frame` of `host`. "An instance of symbol `sid`, inside symbol `host`, at `frame` of `host`.
THE MIDDLE GOES UNDER THE POINTER. `center` says where the symbol's drawing THE MIDDLE GOES UNDER THE POINTER, AND IS WHAT THE INSTANCE TURNS ABOUT.
sits in its own coordinates, and `pos` is set so that point lands on `point`, a `center` says where the symbol's drawing sits in its own coordinates; `pos` is
stage pixel; without one — a drop on the timeline — the drawing stays where it set so that point lands on `point`, a stage pixel — without one, a drop on the
was drawn. No pivot is stored: an instance turns about the middle of what it timeline, the drawing stays where it was drawn — and the same point is stored as
draws at the moment it is dragged, so editing the symbol afterwards cannot the instance's `[:xform :pivot]`, so a turn or a scale happens about the middle
leave a pivot behind. See `domain/gesture`. of the drawing rather than about the symbol's origin.
WITHOUT THAT PIVOT AN INSTANCE TURNS ABOUT THE CORNER OF THE STAGE. A symbol's
origin is the stage's, because that is where its contents were drawn, so the
middle of a drawing inside one is typically a hundred-odd pixels away from it on
a 320x200 stage. `node/local!` composes about the pivot, so this one stored
point is the difference between spinning in place and orbiting the top-left
corner. See `domain/gesture`.
THE UUID IS AN ARGUMENT. An instance's identity is the key it has in the node THE UUID IS AN ARGUMENT. An instance's identity is the key it has in the node
map — it is what `:linked-to`, an export target and a saved leaf all name — so map — it is what `:linked-to`, an export target and a saved leaf all name — so
@ -657,7 +668,8 @@
:source {:symbol sid} :source {:symbol sid}
:playback {:in 0 :speed 1 :end :stop} :playback {:in 0 :speed 1 :end :stop}
:channels {[:xform :pos] {:animated? false :channels {[:xform :pos] {:animated? false
:value (if point (mapv - point middle) [0 0])}}}))))) :value (if point (mapv - point middle) [0 0])}
[:xform :pivot] {:animated? false :value middle}}})))))
(defn place-sound (defn place-sound
"Place a sound at host frame `frame`. Its span uses `source`'s fps when "Place a sound at host frame `frame`. Its span uses `source`'s fps when

View file

@ -2,37 +2,43 @@
"Moving, turning and scaling a node by hand on the stage, as channel values. "Moving, turning and scaling a node by hand on the stage, as channel values.
A drag says where the pointer went in stage pixels; this says what that makes A drag says where the pointer went in stage pixels; this says what that makes
the node's `[:xform :pos]`, `[:xform :rot]` or `[:xform :scale]`, given its the node's `[:xform :pos]`, `[:xform :rot]`, `[:xform :scale]` or
`nest/placement`. Whatever is above the node — instances, parents, a `:pinv` — `[:xform :pivot]`, given its `nest/placement`. Whatever is above the node —
is in the placement's matrices, so a shape five symbols down moves under the instances, parents, a `:pinv` — is in the placement's matrices, so a shape five
pointer like one on top. symbols down moves under the pointer like one on top.
A GESTURE PIVOTS ABOUT THE MIDDLE OF WHAT THE NODE DRAWS, and its own origin A GESTURE TURNS AND SCALES ABOUT THE NODE'S OWN PIVOT, and nothing here solves
when it draws nothing. One rule, for a drawing, a peg and a measured face part for a position to fake one with. `node/local!` composes the rotation and the
alike — `pivot` below. scale about `[:xform :pivot]`, so a turn is `rot` alone, ALWAYS — one channel,
one key, and the pivot held exactly on every frame between two keys because the
matrix is built about it on every frame. This is Toon Boom's layer pivot and
Flash's transformation point, and the reason both store one.
WHAT THAT COSTS DEPENDS ON WHERE THE ORIGIN IS, and that is the whole of why A PIVOT NOBODY HAS CHOSEN IS THE MIDDLE OF WHAT THE NODE DRAWS, and the first
`paint/centred` exists. `node/local!` turns and scales about the node's own turn or scale WRITES IT DOWN — `pivot` derives it from `pick/bounds-of` on the
origin and nothing else, so a pivot anywhere else has to be paid for by writing frame the drag starts, and `with-pivot` turns that into a `[:xform :pivot]` and
`pos` as well — `about` solves for it — and that solution is an ARC in the the `[:xform :pos]` that leaves the picture exactly where it is. After that it
angle while `pos` interpolates along the CHORD. It is therefore exact on the is an ordinary stored, keyable, draggable channel, and the derived value is
frame it is written and nowhere between two keys, which is fine for a drag and never consulted again: a pivot is a CHOICE, and re-deriving it per drag means
is not a thing to key: a keyed 360° turn came back to the right place having editing a symbol silently moves what its instances turn about.
gone right off the stage in the middle, because 0° and 360° are the only frames
where the error vanishes.
So a drawing's origin is put on the middle of what it draws the moment it is WHAT THIS REPLACES, because it was a deletion that cost a feature. There used
drawn, and then the pivot IS the origin, `about` has nothing to do, and a turn to be no pivot in the decomposition at all: a drag derived the middle of the
writes `rot` alone — right on every frame, keyed or not. `turn` and `scale` drawing, and `about` solved for the `pos` that holds that point still under the
check for that and write the one channel. new angle. That solution is an ARC in the angle while `pos` interpolates along
the CHORD, so it is exact on the frame it is written and WRONG EVERYWHERE
BETWEEN TWO KEYS. A drawing escaped it, because `paint/centred` puts a shape's
origin on the middle of what it draws and the correction is then nil — but a
SYMBOL INSTANCE cannot: its origin is its symbol's, and a symbol is drawn on the
stage, so its origin is the stage's top-left corner. One keyed turn of an
instance therefore swung its drawing round the corner of the stage on an orbit
the size of the stage. The answer at the time was a peg, and a peg is a real
thing — it is how a pivot is SHARED, or put over a measured transform — but it
is not something anybody should have to make in order to spin a drawing.
Two kinds of node are left where the pivot is not the origin and cannot be made `about` is still here and is still that equation, for the one gesture that
to be: a MEASURED part, whose points and position are dense tier-2 geometry in genuinely has a pivot belonging to no node: a MULTI-SELECTION scaling about the
the footage's space, and a drawing whose points have been edited far enough to middle of its shared box. Nobody keys that.
take their middle off its origin. Both still drag correctly about their middle,
both are inexact if that drag is keyed, and for both the answer to a pivot that
has to persist or be keyed is a peg — which is a node, so its pivot is its
origin, so this all collapses again one level up.
The normal keying rule is `node/set-channel`, the inspector's: a channel with The normal keying rule is `node/set-channel`, the inspector's: a channel with
keys gets one on the node's own frame, and one without has its one value keys gets one on the node's own frame, and one without has its one value
@ -54,7 +60,15 @@
[n f store] [n f store]
(let [at #(ch/value-at (get (node/channels n) [:xform %]) f store) (let [at #(ch/value-at (get (node/channels n) [:xform %]) f store)
xy #(let [v (at %)] [(ch/component v 0) (ch/component v 1)])] xy #(let [v (at %)] [(ch/component v 0) (ch/component v 1)])]
{:pos (xy :pos) :rot (at :rot) :scale (xy :scale) :skew (xy :skew)})) {:pos (xy :pos) :pivot (xy :pivot) :rot (at :rot)
:scale (xy :scale) :skew (xy :skew)
;; WHETHER THE NODE HAS A PIVOT OF ITS OWN, and not what it is: a node
;; with no `[:xform :pivot]` channel reads `[0 0]` off `node/defaults`,
;; which is a real pivot — a drawing's own middle — and also what a node
;; nobody has pivoted yet looks like. The two have to be told apart
;; exactly once, when a drag decides whether to write the derived middle
;; down; see `pivot` and `with-pivot`.
:chosen? (contains? (:channels n) [:xform :pivot])}))
(defn- measured-channel? [n path] (defn- measured-channel? [n path]
(let [c (get-in n [:channels path])] (let [c (get-in n [:channels path])]
@ -81,78 +95,164 @@
and scale and nothing else. `node/local!` with a zero `pos` rather than a and scale and nothing else. `node/local!` with a zero `pos` rather than a
second closed form, so there is one place the decomposition is written out." second closed form, so there is one place the decomposition is written out."
[{:keys [rot scale skew]}] [{:keys [rot scale skew]}]
(node/local! (node/mat) [0 0] rot scale skew)) (node/local! (node/mat) [0 0] [0 0] rot scale skew))
(defn local-of (defn local-of
"The node's whole local transform, `T(pos)·R·K·S` — what takes a point in the "The node's whole local transform, `T(pos)·T(piv)·R·K·S·T(-piv)` — what takes a
node's own coordinates to its parent's." point in the node's own coordinates to its parent's."
[{:keys [pos rot scale skew]}] [{:keys [pos pivot rot scale skew]}]
(node/local! (node/mat) pos rot scale skew)) (node/local! (node/mat) pos pivot rot scale skew))
(defn about (defn about
"The `pos` that keeps parent-space point `c` still while the node's linear part "The `pos` that keeps parent-space point `c` still while the node's linear part
changes from `v`'s to `v'`'s. Nil when `v`'s is singular — a node scaled to changes from `v`'s to `v'`'s. Nil when `v`'s is singular — a node scaled to
nothing has no point under `c` to hold. nothing has no point under `c` to hold.
THE ONE EQUATION a pivot needs, and it replaces the stored anchor outright. FOR A PIVOT THAT IS NO NODE'S, which since the pivot went into the
Local is `T(p)·M`. The material point sitting under `c` is `q = M⁻¹(c − p)`, decomposition is one gesture and only one: a multi-selection scaling about the
and holding it there under the new `M'` is middle of its shared box, where every member has to move to keep the
arrangement and none of them owns the point. `turn` and `scale` do not call
this, and the namespace docstring says why — the position it solves for is an
ARC in the angle and `pos` tweens along the CHORD, so it is exact on the frame
it is written and wrong between two keys.
p' = c − M'·q = c − M'·M⁻¹·(c − p) Local is `T(t)·M` with `t = pos + a − M·a`, the composed translation. The
material point sitting under `c` is `q = M⁻¹(c − t)`, holding it there under the
new `M'` wants `t' = c − M'·q`, and the `pos` that composes to that `t'` is
Checkable at both ends it has to be right at: with `c` the node's own origin, p' = t' − a + M'·a = c − M'·(q − a) − a
`c = p`, so `q = 0` and `p' = p` — turning about yourself never moves you. And
for a pure turn, `M' = R(da)·M`, so `M'·M⁻¹ = R(da)` and `p' = c + R(da)(p − c)`,
which is the familiar rotation of `p` about `c`.
WHAT IT COSTS is in the namespace docstring, and it is the reason `turn` and Checkable at both ends it has to be right at: with `c` the node's own pivot
`scale` would rather not call this at all: `p'` is an arc in the angle and `pos` point, `c = pos + a`, so `q = a` and `p' = c − a = pos` — turning about your own
interpolates along the chord, so a pivot that is not the node's own origin is pivot never moves you. And with no pivot at all, `a = 0`, it is the familiar
held exactly on the frame it is written and nowhere between two keys. Needed `p' = c − M'·M⁻¹(c − p)`."
for a multi-selection about its shared box, for a measured part, and for a
drawing edited away from its middle; not needed, and not called, when the pivot
is the origin."
[v v' c] [v v' c]
(let [m (linear v)] (let [m (linear v)
l (local-of v)
t [(aget l 4) (aget l 5)]
a (:pivot v)]
(when-let [inv (node/invert m)] (when-let [inv (node/invert m)]
(let [q (through inv (mapv - c (:pos v)))] (let [q (through inv (mapv - c t))]
(mapv - c (through (linear v') q)))))) (mapv - (mapv - c (through (linear v') (mapv - q a))) a)))))
(defn pivot (defn middle
"The parent-space point a drag on this node turns and scales about: the middle "The middle of `bounds` — what the node draws, in its own coordinates — through
of `bounds` — what it draws, in its own coordinates — through its own its own transform, so a parent-space point; or its own origin when it draws
transform, or its own origin when it draws nothing. nothing. What a node nobody has pivoted yet turns about."
ONE RULE FOR SHAPES, PEGS AND MEASURED PARTS ALIKE, and `pick/bounds-of` on the
same frame is where the bounds come from, so the cross and the selection box
cannot drift apart: they are one computation.
FOR A DRAWING THIS IS ITS ORIGIN, and that is not a coincidence to keep up
either — `paint/centred` puts a shape's origin on the middle of what it draws
when it is drawn. Which is what makes the ordinary case free: `at-origin?`
holds, so a turn writes `rot` alone and a keyed turn is right between its keys.
A `:group` draws nothing, so `bounds` is nil and this is its `pos`, which is
the same statement — a peg's origin is where it was put."
[v bounds] [v bounds]
(let [[x0 y0 x1 y1] bounds] (let [[x0 y0 x1 y1] bounds]
(through (local-of v) (through (local-of v)
(if bounds [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)] [0 0])))) (if bounds [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)] [0 0]))))
(defn at-origin? (defn pivot
"Is parent-space point `c` the node's own origin? "The parent-space point a drag on this node turns and scales about: ITS OWN
PIVOT, `pos + piv` — or, for a node nobody has pivoted yet, the middle of
`bounds`, what it draws in its own coordinates, through its own transform.
THE CHOSEN ONE WINS, and that is the whole of the rule. A pivot is where
somebody put it: it does not follow the drawing afterwards, any more than
Flash's transformation point or a Harmony layer's pivot does, because a turn
that quietly changes its centre when a symbol is edited is worse than one
sitting somewhere a hand can see and move it. `ui/stage` draws the cross here
and `::ui/repivot` drags it.
THE DERIVED ONE IS A DEFAULT AND NOT A BEHAVIOUR. `pick/bounds-of` on the same
frame is where the bounds come from, so the cross and the selection box start
out as one computation — and the first turn or scale WRITES IT DOWN, which is
`with-pivot`. A `:group` draws nothing, so `bounds` is nil and this is its own
origin, which is where a peg was put.
`middle` is the derived half on its own, for `centred` — which is the way back
to it once a pivot HAS been chosen."
[v bounds]
(if (:chosen? v) (mapv + (:pos v) (:pivot v)) (middle v bounds)))
(defn at-pivot?
"Is parent-space point `c` where the node already pivots?
WITHIN A MILLIONTH OF A PIXEL, because this asks a question about intent and WITHIN A MILLIONTH OF A PIXEL, because this asks a question about intent and
gets an answer in floating point: `paint/centred` subtracts the middle of a gets an answer in floating point: `paint/centred` subtracts the middle of a
ring from its own points, so re-deriving that middle from the result lands on ring from its own points, so re-deriving that middle from the result lands on
zero to within the rounding of the subtraction, a part in 1e14 of the zero to within the rounding of the subtraction, a part in 1e14 of the
coordinates. A pivot that close to the origin IS the origin — there is no coordinates. A point that close to the pivot IS the pivot — there is no gesture
gesture in which a millionth of a pixel is a pivot somewhere else." in which a millionth of a pixel is a pivot somewhere else — and the whole point
of asking is to leave a drawing's channels alone: a pivot of `[0 0]` written
onto a shape that already turns about its own middle is a key nobody asked for
on a value that was already right."
[v c] [v c]
(let [[dx dy] (mapv - c (:pos v))] (let [[dx dy] (mapv - c (mapv + (:pos v) (:pivot v)))]
(< (js/Math.hypot dx dy) 1e-6))) (< (js/Math.hypot dx dy) 1e-6)))
(defn repivot
"The channel values that put the node's pivot on parent-space point `c` WITHOUT
MOVING THE PICTURE, or nil when its linear part is singular.
Local is `T(pos)·T(a)·M·T(-a)`, so the new pivot has to be the material point
that is under `c` now, and the new position has to put it there:
a' = a + M⁻¹(c − (pos + a)) the point under c, in the node's own space
p' = c − a' so that p' + a' = c
and then `p' + a' + M(x − a')` is `pos + a + M(x − a)` for EVERY x, which is
the \"moving nothing\" in the first line, exact and not to a tolerance.
WITH NO ROTATION OR SCALE IT DOES NOT TOUCH `pos` AT ALL — `M = I` gives
`a' = c − pos` and `p' = pos` — which is the ordinary case of setting a pivot up
before animating, and is why this can be done quietly inside a first turn
without starting a position channel nobody asked for.
EXACT ON THE FRAME IT IS WRITTEN. `M` is this frame's, so on a node whose
rotation or scale is already keyed, the compensation that holds the picture
still here is not the one that would hold it still three frames later. That is
not an artefact of the arithmetic: moving a pivot genuinely changes what the
keyed angles mean. Flash and Harmony both let you do it and both move the
in-betweens; the alternative is refusing to repivot anything already animated,
which is the node a pivot is most often wrong on."
[v c]
(when-let [inv (node/invert (linear v))]
(let [a' (mapv + (:pivot v) (through inv (mapv - c (mapv + (:pos v) (:pivot v)))))]
{[:xform :pivot] a'
[:xform :pos] (mapv - c a')})))
(defn centred
"The channel values that put the node's pivot back on the middle of what it
draws NOW, moving nothing. Nil when its linear part is singular.
THE WAY BACK, and the thing a stored pivot needs in order to be safe to store.
A pivot does not follow the drawing — that is the point of storing it, since a
keyed spin must not be re-aimed by someone drawing one more shape inside the
symbol — but a drawing does grow, and \"put it back in the middle of what is
there now\" is then an obvious thing to want and an unobvious thing to do by
hand. It is `repivot` at the point `pivot` would have derived, so the button
and the default cannot disagree: this is exactly where an untouched node's
cross already is."
[v bounds]
(repivot v (middle v bounds)))
(defn- with-pivot
"`[v vs]`: the node's transform with its pivot on parent-space `c`, and the
channel values that put it there — or `v` untouched and nil, when that is where
it pivots already or when it has a pivot of its own.
THE ONE PLACE A DERIVED PIVOT BECOMES A STORED ONE. `turn` and `scale` both
start here, so the first drag on a node nobody has pivoted writes the middle of
what it draws down with the gesture, in the same edit, and every drag after it
turns about the stored one. The returned `v` carries the new pivot, because the
gesture itself is measured about it: scaling about the pivot it is in the act of
choosing is one answer, not two."
[v c]
(if (and c (not (:chosen? v)) (not (at-pivot? v c)))
(if-let [vs (repivot v c)]
[(assoc v :pivot (get vs [:xform :pivot]) :pos (get vs [:xform :pos])) vs]
[v nil])
[v nil]))
(defn move (defn move
"The node's position with the drag carried from stage point `p0` to `p1`." "The node's position with the drag carried from stage point `p0` to `p1`.
ONE CHANNEL, AND IT NEVER DISTURBS THE PIVOT: `[:xform :pivot]` is in the
node's own coordinates, so it travels with the node and a move is `pos` alone,
exactly as it was before there was a pivot at all."
[{:keys [parent]} {:keys [pos]} p0 p1] [{:keys [parent]} {:keys [pos]} p0 p1]
(when-let [inv (node/invert parent)] (when-let [inv (node/invert parent)]
{[:xform :pos] (mapv + pos (mapv - (through inv p1) (through inv p0)))})) {[:xform :pos] (mapv + pos (mapv - (through inv p1) (through inv p0)))}))
@ -166,41 +266,42 @@
(js/Math.atan2 y x)))) (js/Math.atan2 y x))))
(defn turn (defn turn
"The node turned by `da` radians about parent-space point `c`: the rotation, "The node turned by `da` radians about parent-space point `c`: the rotation, and
and — only if `c` is not the node's own origin — the position that holds `c` — only on the first turn of a node nobody has pivoted — the pivot and position
still. that put its pivot on `c` without moving it.
ONE CHANNEL WHENEVER IT CAN BE, which for a drawing is always, because ONE CHANNEL, ONCE THE PIVOT IS ITS OWN, and that is the whole reason the pivot
`paint/centred` put its origin on the middle of what it draws. `about` would is in `node/local!` rather than solved for here. `rot` is the only thing a turn
return the position unchanged here, to within the rounding of its own matrix changes, so a keyed turn interpolates ONE number and the matrix is composed
inverse; not calling it is the difference between a turn that writes `rot` and about the pivot on every frame of it: the pivot is held exactly between two keys
one that writes `rot` and a `pos` key that is a hair off the one already there. and not merely at them. A `pos` written beside the rotation — which is what
The second is the one that sends a keyed spin off the stage, since `pos` tweens `about` solves for, and what this used to do for anything whose origin was not
along the chord of an arc it has no way to know about. its middle — tweens along the chord of an arc it has no way to know about, and a
360° turn keyed that way leaves the stage in the middle and comes back.
`c` is still honoured where it is genuinely not the origin — a measured part, a `c` IS A DEFAULT, NOT A TARGET. A node with its own pivot ignores it and turns
drawing edited away from its middle — and is then exact on this frame alone." about what it has, which is what makes a pivot something a hand can place and
rely on; `pivot` is where `c` comes from either way."
[v c da] [v c da]
(let [v' (update v :rot + da)] (let [[v vs] (with-pivot v c)]
(cond-> {[:xform :rot] (:rot v')} (merge vs {[:xform :rot] (+ (:rot v) da)})))
(and c (not (at-origin? v c)))
(into (when-let [p (about v v' c)] {[:xform :pos] p})))))
(defn scale (defn scale
"The node's scale with the point under stage `p0` taken to `p1`, about "The node's scale with the point under stage `p0` taken to `p1`, about its own
parent-space pivot `c`, along the node's own axes — or by the same factor on pivot, along the node's own axes — or by the same factor on both when
both when `uniform?` — and, only if `c` is not the node's own origin, the `uniform?` — and, on the first scale of a node nobody has pivoted, the pivot and
position that holds `c` still. position that put its pivot on `c` without moving it.
The factors are measured in the node's OWN coordinates, which is what makes a The factors are measured in the node's OWN coordinates, which is what makes a
corner drag track the pointer on a node that has been turned. On a drawing the corner drag track the pointer on a node that has been turned, and they are
pivot is the origin, so `q` is the origin too and the pointer offsets are measured FROM THE PIVOT `q`: scaling by `k` takes `q + d` to `q + k·d`, so the
already measured from it — and, as in `turn`, the position is left alone rather factor a corner wants is the ratio of its offsets from the pivot before and
than rewritten to a hair off itself." after. `with-pivot` runs first because a pivot being chosen by this very drag is
the pivot the drag has to be measured about."
[{:keys [world]} v c p0 p1 uniform?] [{:keys [world]} v c p0 p1 uniform?]
(when-let [winv (node/invert world)] (let [[v vs] (with-pivot v c)]
(when-let [minv (node/invert (linear v))] (when-let [winv (node/invert world)]
(let [q (through minv (mapv - c (:pos v))) (let [q (:pivot v)
a (mapv - (through winv p0) q) a (mapv - (through winv p0) q)
b (mapv - (through winv p1) q) b (mapv - (through winv p1) q)
k (fn [a b] (if (< (js/Math.abs a) 1e-6) 1 (/ b a))) k (fn [a b] (if (< (js/Math.abs a) 1e-6) 1 (/ b a)))
@ -208,11 +309,8 @@
(let [aa (reduce + (map * a a))] (let [aa (reduce + (map * a a))]
(if (< aa 1e-9) 1 (/ (reduce + (map * a b)) aa)))) (if (< aa 1e-9) 1 (/ (reduce + (map * a b)) aa))))
s (:scale v) s (:scale v)
s' (if r (mapv #(* r %) s) (mapv * s (map k a b))) s' (if r (mapv #(* r %) s) (mapv * s (map k a b)))]
v' (assoc v :scale s')] (merge vs {[:xform :scale] s'})))))
(cond-> {[:xform :scale] s'}
(and c (not (at-origin? v c)))
(into (when-let [p (about v v' c)] {[:xform :pos] p})))))))
(defn scale-by (defn scale-by
"The node scaled by factor `k` on both axes about parent-space point `c`, and "The node scaled by factor `k` on both axes about parent-space point `c`, and
@ -220,8 +318,11 @@
What a MULTI-SELECTION scales by: one factor for everything about the shared What a MULTI-SELECTION scales by: one factor for everything about the shared
box, so a group of shapes keeps its arrangement instead of each member solving box, so a group of shapes keeps its arrangement instead of each member solving
for its own factors. `scale` is the single-node form, where the factors come for its own factors. The point belongs to the box and not to any node in it, so
out of the pointer in the node's own axes." this is the one gesture that still writes a position to hold a pivot still —
`about`, with everything its docstring says that costs. `scale` is the
single-node form, where the factors come out of the pointer in the node's own
axes and the pivot is the node's own."
[v c k] [v c k]
(let [v' (update v :scale #(mapv (partial * k) %))] (let [v' (update v :scale #(mapv (partial * k) %))]
(cond-> {[:xform :scale] (:scale v')} (cond-> {[:xform :scale] (:scale v')}

View file

@ -647,33 +647,31 @@
whatever it hangs off now, sitting on the pivot the node has at frame `f`. whatever it hangs off now, sitting on the pivot the node has at frame `f`.
`{:clip :sid :id}` or `{:refused why}`. `{:clip :sid :id}` or `{:refused why}`.
A PEG IS WHAT AN ANCHOR WAS, REACHABLE. `T(a)·M·T(-a)` — a transform conjugated WHAT A PEG IS FOR, NOW THAT A NODE HAS ITS OWN PIVOT. `[:xform :pivot]` is how
by a translation — is \"do M in a frame shifted by `a`\", and a parent already IS ONE node turns about a point of its own — that is `node/local!`, it needs no
a shifted frame; `node-test` asserts the two produce the same matrix. So the parent, and it is right between two keys. A peg is for the three things that
stored anchor was a peg that could not be selected, keyed, shared, or placed are about MORE THAN ONE NODE, or about a node whose channels are not yours to
over a measured channel, and this is the same capability with none of those write:
restrictions. It is the answer to all three things a derived pivot cannot do:
A PIVOT THAT PERSISTS. A gesture's pivot is the middle of what the node draws A PIVOT SHARED BETWEEN NODES. An arm and a forearm turning about one shoulder
and lives for one drag. An arm that turns about its SHOULDER wants a pivot is one transform driving two drawings, and two pivots that have to agree
nowhere near that middle, and it wants it on every frame, not re-derived per frame for frame are not that. The peg is the shoulder, and they hang off it.
drag — and it cannot be the drawing's own origin, since moving that moves the
drawing's points out from under everything that reads them.
A PIVOT THAT IS KEYED. The peg's `pos` is an ordinary channel, so a pivot A SECOND TRANSFORM ON ONE NODE. A drawing turning about its own middle while
that travels — a foot roll — is a keyed position. An anchor could only have the whole limb swings about the shoulder is two rotations, and a node has one
been keyed against `pos`, which is interpolated in the same breath, and the `rot`. Toon Boom stacks pegs for exactly this.
two would have had to agree frame for frame.
A HAND TRANSFORM OVER A MEASURED ONE. This is the one that was impossible. A HAND TRANSFORM OVER A MEASURED ONE. `gesture/refusal` turns a drag on a
`gesture/refusal` turns a drag on a measured node away because the next measured node away because the next regenerate would discard it. A peg's
regenerate would discard it, and an anchor written onto one would move the
thing it was meant to leave alone — `node/local!`'s translation is
`pos − M·a`, and under a measured `M` that is not the identity. A peg's
channels are its own, so the hand transform composes OUTSIDE the measured one channels are its own, so the hand transform composes OUTSIDE the measured one
and the measurement stays regenerable. Resolve publishes a track and parents a and the measurement stays regenerable. Resolve publishes a track and parents a
transform to it; Harmony puts a peg over the drawing. Same shape. transform to it; Harmony puts a peg over the drawing. Same shape.
The node's own pivot is NOT one of them, and that is the correction: a peg used
to be the only pivot there was, so making one was the answer to \"this turns
about the wrong point\" — which is a thing to drag a cross for, not a node to
create. See `xform-paths`.
NOTHING MOVES, and that is `:pinv`'s whole job — Blender's parent-inverse, the NOTHING MOVES, and that is `:pinv`'s whole job — Blender's parent-inverse, the
same field `transplant` writes for the same reason. The peg takes the node's same field `transplant` writes for the same reason. The peg takes the node's
place in the hierarchy, inheriting its `:parent` and its `:pinv`; the node hangs place in the hierarchy, inheriting its `:parent` and its `:pinv`; the node hangs
@ -682,7 +680,8 @@
... · pinv · T(c) · T(-c) · local = ... · pinv · local ... · pinv · T(c) · T(-c) · local = ... · pinv · local
to the bit. The node's channels are untouched, which is what lets this work on a to the bit. The node's channels are untouched, which is what lets this work on a
measured node at all. measured node at all — and what lets the node keep its own pivot, which is in
its own coordinates and therefore says nothing about who its parent is.
THE PEG TAKES THE NODE'S `:z`, so draw order is unchanged: a parent's z path is THE PEG TAKES THE NODE'S `:z`, so draw order is unchanged: a parent's z path is
a prefix of its child's, so the node now sorts at `[… z z]` where it sorted at a prefix of its child's, so the node now sorts at `[… z z]` where it sorted at
@ -715,73 +714,5 @@
(update id assoc (update id assoc
:parent uuid :parent uuid
:pinv (vec (array-seq :pinv (vec (array-seq
(node/local! (node/mat) (mapv - c) 0 [1 1] [0 0])))))))})))) (node/local! (node/mat) (mapv - c) [0 0] 0 [1 1] [0 0])))))))}))))
(defn- animated?
[n path]
(let [c (get-in n [:channels path])]
(boolean (or (:keys c) (:dense c) (:generated c)))))
(defn repivot
"Move the peg at row path `path` so its origin sits on parent-space point `c`,
WITHOUT MOVING ANYTHING UNDER IT. `{:clip :sid :id}` or `{:refused why}`.
THE GESTURE A PEG WAS MISSING, and the one the stage's ordinary drag is not.
Dragging a peg writes its `pos`, and a peg is a PARENT, so that translates
everything below it — which is the right behaviour for the drag and the wrong
one for \"put the pivot here\". They are different operations and only look like
one because both move the same cross. After Effects splits them the same way:
dragging a layer moves it, the pan-behind tool moves its anchor and compensates.
What makes it hold still is that only the COMPOSITION of the peg's own
transform and each child's parent-inverse reaches the child:
world(child) = … · local(peg) · pinv(child) · local(child)
so moving the peg from `local` to `local'` and solving
pinv(child)' = local'⁻¹ · local · pinv(child)
leaves that product exactly as it was. The child's own channels are never
touched, which is what lets this work over a MEASURED child — the case the
whole peg exists for.
REFUSED ON AN ANIMATED PEG, rather than quietly wrong. `pinv` is one stored
matrix and the compensation above depends on `local(peg)`, so if the peg's
position is keyed there is a different correction to make on every frame and no
single `pinv` is it. A peg being animated is the normal case once it is doing
its job, so the honest move is to say so and let another peg above it carry the
new pivot."
[clip store open path f c]
(let [{:keys [sid id]} (placement clip store open path f)
n (when sid (get-in clip [:symbols sid :nodes id]))
nodes (when sid (:nodes (clip/symbol clip sid)))
kids (when n (keep (fn [[k m]] (when (= id (:parent m)) k)) nodes))]
(cond
(nil? n) {:refused "it is not on screen at this frame"}
(empty? kids) {:refused "nothing hangs off it, so it has no pivot to move"}
(animated? n [:xform :pos])
{:refused (str "its position is animated, so one stored parent-inverse "
"cannot hold its children still on every frame — put a peg "
"over it and move that one's pivot instead")}
:else
(let [v (gesture/values n f store)
old (gesture/local-of v)
new (node/local! (node/mat) c (:rot v) (:scale v) (:skew v))]
(if-let [back (node/invert new)]
{:sid sid :id id
:clip (clip/update-symbol
clip sid update :nodes
(fn [ns]
(reduce
(fn [ns k]
(let [fix (node/mul! (node/mat) back old)
;; `local'⁻¹ · local · pinv`, with an absent pinv the
;; identity it stands for.
pv (if-let [p (node/pinv (get ns k))]
(node/mul! (node/mat) fix p)
fix)]
(assoc-in ns [k :pinv] (vec (array-seq pv)))))
(update ns id node/set-channel [:xform :pos] f (vec c))
kids)))}
{:refused "it is scaled to nothing, so it has no pivot to move"})))))

View file

@ -33,17 +33,29 @@
stored transform, so it is in the shape and in the composition order from the stored transform, so it is in the shape and in the composition order from the
start. start.
THERE IS NO :anchor, and that is a deletion rather than an omission. An anchor :pivot IS THE POINT ROTATION AND SCALE HAPPEN ABOUT, in the node's own
is `T(a)·M·T(-a)` — the conjugation of a transform by a translation, which is coordinates, and it is in the decomposition because THERE IS NOWHERE ELSE IT
to say \"do M in a frame shifted by a\" — and a PARENT ALREADY IS A SHIFTED CAN BE. Toon Boom gives every layer and peg a pivot; Flash gives every instance
FRAME. So an anchor is a peg that cannot be addressed, cannot be keyed, cannot a transformation point; After Effects calls it the anchor point. All three store
be shared between nodes, and cannot be put above a measured channel, which is it, and all three are right to, for one reason: a turn has to be a turn ON EVERY
exactly where a pivot is most needed. Same expressive content, strictly less FRAME, and the only way to keep a point still through an interpolated angle is
reach. What it used to do, two mechanisms now do properly: `domain/gesture` for the angle to be composed about that point. Solving for the `pos` that holds
turns and scales about a point computed at the moment of the drag and stores a point still — `gesture/about` — is exact on the frame it is solved and wrong
nothing, and a peg — an ordinary `:group` parent — carries a pivot that has to between two keys, because the solution is an ARC in the angle and `pos` tweens
persist, be keyed, or sit over a measured transform. See docs/animation-model.md." along the CHORD. See docs/animation-model.md, which has the measurements off the
[[:xform :pos] [:xform :rot] [:xform :scale] [:xform :skew]]) document where this was found.
So `local!` is `T(pos)·T(piv)·R·K·S·T(-piv)`, the transform conjugated by a
translation — \"do M in a frame shifted by piv\" — and the node's pivot point
sits at `pos + piv` in its parent. A pivot of `[0 0]`, which is the default,
leaves that exactly `T(pos)·R·K·S`: a drawing needs no pivot, because
`paint/centred` already put its origin on the middle of what it draws.
A PEG IS STILL A PEG. `nest/peg` is how a pivot gets SHARED between nodes, or
put above a measured transform that a regenerate would overwrite; this is how
one node turns about its own middle. The two were conflated — the peg was made
to stand in for the pivot — and that is what cost a keyed turn its pivot."
[[:xform :pos] [:xform :pivot] [:xform :rot] [:xform :scale] [:xform :skew]])
(def valid-paths (def valid-paths
"The set of valid channel paths follows from the node's :kind, and that is a "The set of valid channel paths follows from the node's :kind, and that is a
@ -73,8 +85,18 @@
(def defaults (def defaults
"The identity transform, as channels. A node's channel map is merged over this, "The identity transform, as channels. A node's channel map is merged over this,
so a hand-written scene says only what it means to say." so a hand-written scene says only what it means to say.
THE DEFAULT PIVOT IS THE NODE'S OWN ORIGIN, which is a real default and not a
missing value: a drawing's origin IS the middle of what it draws — `paint/centred`
put it there — so a shape turns about its own middle with no pivot stored, and
`local!` collapses to `T(pos)·R·K·S` for it. What needs a pivot of its own is a
node whose content is nowhere near its origin: a symbol instance, whose origin
is the symbol's, and a measured part, whose origin is the corner of the footage.
Both get one where they are made — `clip/place-symbol` — and `gesture/turn`
gives one to anything that turns without having been given one."
{[:xform :pos] (ch/framed [0.0 0.0]) {[:xform :pos] (ch/framed [0.0 0.0])
[:xform :pivot] (ch/framed [0.0 0.0])
[:xform :rot] (ch/framed 0.0) [:xform :rot] (ch/framed 0.0)
[:xform :scale] (ch/framed [1.0 1.0]) [:xform :scale] (ch/framed [1.0 1.0])
[:xform :skew] (ch/framed [0.0 0.0]) [:xform :skew] (ch/framed [0.0 0.0])
@ -101,10 +123,14 @@
the next regenerate, which is what `gesture/refusal` refuses. The way to the next regenerate, which is what `gesture/refusal` refuses. The way to
transform one of these by hand is a PEG above it: the hand transform is then on transform one of these by hand is a PEG above it: the hand transform is then on
a node of its own and the measured channels underneath are left to be a node of its own and the measured channels underneath are left to be
regenerated. Nothing has to be written onto the measured node at all, which is regenerated. Nothing has to be written onto the measured node at all.
what the old pivot default could never manage — an anchor under a measured
scale does not cancel out of `local!`, so writing one moved the very thing it ITS PIVOT IS STILL ITS OWN, and that is the one part of its transform a hand
was meant to leave alone." may write: `[:xform :pivot]` is authored on every node alike, never dense and
never regenerated, so a measured mouth can be told to turn about its own middle
without a peg and without anything a regenerate would discard. What the peg is
still for is the TRANSFORM over a measured one, which is this function's
business; where the pivot goes is not."
[n] [n]
(boolean (some #(let [c (get-in n [:channels [:xform %]])] (boolean (some #(let [c (get-in n [:channels [:xform %]])]
(or (:dense c) (:generated c))) (or (:dense c) (:generated c)))
@ -363,11 +389,17 @@
dest)) dest))
(defn local! (defn local!
"dest := T(pos) · R(rot) · K(skew) · S(scale) "dest := T(pos) · T(piv) · R(rot) · K(skew) · S(scale) · T(-piv)
Written out closed-form rather than as four matrix products, because this runs The transform CONJUGATED BY ITS PIVOT, which is to say: do the rotation, skew
per node per frame and the four products would each allocate. The derivation, and scale in a frame shifted to `piv`, so the point `piv` of the node's own
so the constants are checkable rather than trusted: coordinates does not move however they change. Toon Boom's layer pivot, Flash's
transformation point, After Effects' anchor point; `xform-paths` says why it is
in the decomposition rather than solved for per drag.
Written out closed-form rather than as six matrix products, because this runs
per node per frame and each product would allocate. The derivation, so the
constants are checkable rather than trusted:
R·K·S = | c -s | · | 1 kx | · | sx 0 | R·K·S = | c -s | · | 1 kx | · | sx 0 |
| s c | | ky 1 | | 0 sy | | s c | | ky 1 | | 0 sy |
@ -378,28 +410,36 @@
R·K·S = | sx(c - s·ky) sy(c·kx - s) | R·K·S = | sx(c - s·ky) sy(c·kx - s) |
| sx(s + c·ky) sy(s·kx + c) | | sx(s + c·ky) sy(s·kx + c) |
and the translation is pos itself, because rotation and scale happen about the which is the linear part, UNTOUCHED BY THE PIVOT — a conjugation by a
node's OWN ORIGIN and nothing else. Turning about any other point is translation cannot change it, which is why a pivot is free to move without
`gesture/about`, which solves for the `pos` that holds the chosen point still reshaping anything. All of it lands in the translation:
and writes it alongside the rotation — so a pivot is a fact about a drag rather
than a field on a node, and there is no stored pivot to go stale. A pivot that T(p)·T(a)·M·T(-a) = T(p + a - M·a) · M
has to outlive a drag is a peg: see `xform-paths`.
so with `a = [0 0]` this is exactly `T(pos)·R·K·S` and a node with no pivot
composes as it always did, to the bit.
:skew is stored as shear FACTORS, not angles — kx is x gained per unit y — so :skew is stored as shear FACTORS, not angles — kx is x gained per unit y — so
that the identity is 0 and a decomposition round-trips without a tangent." that the identity is 0 and a decomposition round-trips without a tangent."
[^js dest pos rot scale skew] [^js dest pos piv rot scale skew]
(let [c (js/Math.cos rot) (let [c (js/Math.cos rot)
s (js/Math.sin rot) s (js/Math.sin rot)
sx (ch/component scale 0) sx (ch/component scale 0)
sy (ch/component scale 1) sy (ch/component scale 1)
kx (ch/component skew 0) kx (ch/component skew 0)
ky (ch/component skew 1)] ky (ch/component skew 1)
(aset dest 0 (* sx (- c (* s ky)))) ax (ch/component piv 0)
(aset dest 1 (* sx (+ s (* c ky)))) ay (ch/component piv 1)
(aset dest 2 (* sy (- (* c kx) s))) a (* sx (- c (* s ky)))
(aset dest 3 (* sy (+ (* s kx) c))) b (* sx (+ s (* c ky)))
(aset dest 4 (ch/component pos 0)) c* (* sy (- (* c kx) s))
(aset dest 5 (ch/component pos 1)) d (* sy (+ (* s kx) c))]
(aset dest 0 a)
(aset dest 1 b)
(aset dest 2 c*)
(aset dest 3 d)
(aset dest 4 (+ (ch/component pos 0) ax (- (+ (* a ax) (* c* ay)))))
(aset dest 5 (+ (ch/component pos 1) ay (- (+ (* b ax) (* d ay)))))
dest)) dest))
(defn pinv (defn pinv
@ -487,9 +527,10 @@
(pos? (get-in n [:time :rate]))))) (pos? (get-in n [:time :rate])))))
(conj ":time :rate must be positive") (conj ":time :rate must be positive")
(contains? (:channels n) [:xform :anchor]) (contains? (:channels n) [:xform :anchor])
(conj (str ":anchor is gone — a pivot nobody chose is derived from what " (conj (str ":anchor is now [:xform :pivot], and it means the same point "
"the node draws, and a pivot to keep is a peg over it; " "in the same coordinates — but the composition around it "
"edit > add peg, then turn and key that")) "changed from T(pos)·M·T(-a) to T(pos)·T(a)·M·T(-a), so a "
"turned or scaled node reading one as the other would move"))
(nil? (:z n)) (conj "no :z — draw order is authored per scene, not implied by the tree") (nil? (:z n)) (conj "no :z — draw order is authored per scene, not implied by the tree")
(and (:span n) (not (and (vector? (:span n)) (= 2 (count (:span n))) (and (:span n) (not (and (vector? (:span n)) (= 2 (count (:span n)))
(every? finite-number? (:span n)) (every? finite-number? (:span n))
@ -504,9 +545,10 @@
;; `[:xform :anchor]` is excluded because it has a NAMED refusal above. ;; `[:xform :anchor]` is excluded because it has a NAMED refusal above.
;; It is the one invalid path a stored document is likely to carry — every ;; It is the one invalid path a stored document is likely to carry — every
;; schema-6 drawing and placement had one — so "not valid on a :poly node" ;; schema-6 drawing and placement had one, and schema 7 refused them all
;; would be the first thing a person saw, and it says nothing about what to ;; rather than convert — so "not valid on a :poly node" would be the first
;; do. The precedent is `symbol/problems`' refusal of `:trace`. ;; thing a person saw, and it says nothing about what to do. The precedent
;; is `symbol/problems`' refusal of `:trace`.
(into (when valid (into (when valid
(for [[path _] (:channels n) (for [[path _] (:channels n)
:when (and (not (contains? valid path)) :when (and (not (contains? valid path))

View file

@ -25,25 +25,22 @@
`[ring pos]`: the same drawing about the origin, and the position that puts it `[ring pos]`: the same drawing about the origin, and the position that puts it
back exactly where it was. back exactly where it was.
THE ORIGIN OF A SHAPE IS THE MIDDLE OF WHAT IT DRAWS, and that is the one THE ORIGIN OF A SHAPE IS THE MIDDLE OF WHAT IT DRAWS, so a shape needs no
invariant the pivot rests on. `node/local!` turns and scales about the node's pivot of its own: `[:xform :pivot]` defaults to the node's origin, and for a
OWN ORIGIN and nothing else — deliberately, since `[:xform :anchor]` was drawing that IS the middle of the drawing. A stroke stored exactly as it was
deleted — so a node whose origin is nowhere near its content turns about drawn would have its origin at the SYMBOL's origin, which on the stage is the
nowhere near its content. A stroke stored exactly as it was drawn has its top-left corner — 126 px away on a 320x200 stage, an orbit wider than the
origin at the SYMBOL's origin, which on the stage is the top-left corner, so stage — and that is what every drawing would turn about with a default pivot
every drawing anyone made turned about the corner of the stage: 126 px away on and no centring here.
a 320x200 stage, which is an orbit wider than the stage.
It could not be seen while a drag was the only way to turn something, because IT IS WORTH DOING ANYWAY, NOW THAT THERE IS A PIVOT, because this is the half
`gesture/about` solved for the `pos` that holds the chosen pivot still and nobody has to choose. A pivot is a stored choice and a default has to be a good
wrote it alongside the rotation — exactly right on the frame of the drag. But one: with the origin on the content the default is already right, a turn writes
that solution is `p' = c + R(θ)(p − c)`, an ARC, and `pos` is interpolated `rot` alone, and nothing is stored on the node for anybody to have to look at.
along the chord, so a keyed turn held its pivot on its keys and nowhere A symbol instance is the case that cannot do this — its origin is its symbol's,
between: a 360° spin returned to the right place having gone right off the and moving a symbol's origin would move every drawing inside it out from under
stage in the middle of the turn, since 0° and 360° are the only frames where everything that reads them — so it gets a pivot written where it is placed;
the error vanishes. With the origin on the content there is nothing to solve see `clip/place-symbol`.
and nothing to interpolate — a turn writes `rot` alone, and `pos` goes back to
being the motion path it reads as.
Hand-authored scenes have always been written this way — `demo/scene.edn`'s Hand-authored scenes have always been written this way — `demo/scene.edn`'s
card is `[-44 -30 44 -30 44 30 -44 30]` with its place in `pos` — so this is card is `[-44 -30 44 -30 44 30 -44 30]` with its place in `pos` — so this is

View file

@ -164,11 +164,16 @@
symbol to BUILD and a lookup to RUN. Asking frame by frame through a fresh one symbol to BUILD and a lookup to RUN. Asking frame by frame through a fresh one
is a resolver per frame. is a resolver per frame.
WHAT A SELECTION BOX IS DRAWN FROM, and now also what a gesture pivots about — WHAT A SELECTION BOX IS DRAWN FROM, and what a pivot DEFAULTS to: a node
the two were the same quantity all along, computed in two places at two times. nobody has pivoted turns about the middle of these bounds, and the first turn
The box came from here on every render and the pivot from a value stored at or scale writes that point down as its `[:xform :pivot]` — `gesture/pivot` and
creation, so they drifted apart the moment anything was edited, which is what `gesture/with-pivot`. So the box and the cross start out as one computation,
made a pivot on anything beyond one unchanging shape wrong. See `domain/gesture`." and ⌖ in the inspector brings a chosen pivot back to it (`gesture/centred`).
A DEFAULT, AND NOT WHERE THE PIVOT LIVES. Once a pivot is the node's own, this
is not consulted for it again: the pivot is a choice, and a choice that
silently followed the drawing would mean adding a shape to a symbol re-aimed
every keyed spin of every instance of it. See `domain/gesture`."
[document store sid n] [document store sid n]
(let [grow (fn [[x0 y0 x1 y1 :as b] x y] (let [grow (fn [[x0 y0 x1 y1 :as b] x y]
(if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y])) (if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y]))

View file

@ -34,15 +34,25 @@
different one — see `events/project` — so this is bumped by any change to what different one — see `events/project` — so this is bumped by any change to what
a leaf may contain. a leaf may contain.
7 deleted `[:xform :anchor]`. Nothing is converted, as in 6: every project is 8 added `[:xform :pivot]`, the point a node turns and scales about, which 7 had
marked 7, and one still carrying an anchor is refused when it is opened, by deleted as `[:xform :anchor]` on the theory that a peg could stand in for one.
name and with what to do about it — `node/problems`. Dropping one would in fact It cannot: a peg is a node and a turn about one is still `pos` solved per
be exact wherever rotation and scale are the identity, which is everywhere a frame, so a KEYED turn of anything whose origin was not its own middle orbited
freeze or a drop wrote one, since the anchor cancels out of `node/local!` that origin. See `node/xform-paths` and docs/animation-model.md.
there; it is NOT exact on anything since turned or scaled by hand, and that is
the case a silent conversion would quietly move. So it is refused rather than CONVERTED, AND THE ONLY VERSION SO FAR THAT IS, because this one is exact: a
guessed at." schema-7 node has no pivot, an absent pivot reads as `[0 0]` off
7) `node/defaults`, and `T(pos)·T(0)·M·T(-0)` is `T(pos)·M` to the bit. So every
stored document composes to the same matrices it did, and the migration only
restamps the version — `clips/migrations/0017`. That is the difference from 7,
which could not convert an anchor it had deleted: dropping one moved anything
since turned or scaled by hand, and tier-2 positions cannot be rewritten at
all. Adding a component with an identity default takes nothing away.
A pivot a schema-7 document never got to choose is still unchosen, and the
first turn or scale of such a node writes one — `gesture/with-pivot` — so the
conversion does not have to guess where anybody wanted it."
8)
(defn block-keys (defn block-keys
"Every tier-2 key a leaf map names, in a stable order." "Every tier-2 key a leaf map names, in a stable order."

View file

@ -624,7 +624,14 @@
Held rather than playing, and one frame rather than the length of what it Held rather than playing, and one frame rather than the length of what it
places: a clip's duration is the sequence's business — `extend-hold` and places: a clip's duration is the sequence's business — `extend-hold` and
`resize-out` are how it changes — and reading it off the content would make `resize-out` are how it changes — and reading it off the content would make
placing a ten-frame animation and holding its first drawing the same gesture." placing a ten-frame animation and holding its first drawing the same gesture.
NO PIVOT, unlike `clip/place-symbol`, and the difference is whether the content
EXISTS YET. Dropping a symbol onto the stage places a drawing somebody can see,
so the middle of it is known and is stored as the instance's pivot; a cel is
made to be drawn in, and the middle of an empty drawing is nothing to commit to.
So a cel's pivot stays unchosen until a turn or a scale chooses it, which is
`gesture/with-pivot`, from the bounds the drawing has by then."
[id drawing-id at] [id drawing-id at]
{:id id :kind :instance :z (str "a-" id) {:id id :kind :instance :z (str "a-" id)
:span [0 1] :time {:at at :rate 1} :span [0 1] :time {:at at :rate 1}

View file

@ -359,16 +359,22 @@
(quot (if (vector? pts) (count pts) (.-length pts)) 2)) (quot (if (vector? pts) (count pts) (.-length pts)) 2))
(defn- xform-at (defn- xform-at
"The four transform components at the node's local frame, or nil when any of "The five transform components at the node's local frame, or nil when any of
them has no value on it." them has no value on it.
The pivot is read like the rest and is not special-cased to a default here:
`node/channels` has already filled in `[0 0]` for a node that stores none, and
a node whose pivot is absent on a frame it is otherwise on has the same nothing
to be drawn at as one whose position is."
[rd] [rd]
(let [pos (rd [:xform :pos]) (let [pos (rd [:xform :pos])
piv (rd [:xform :pivot])
rot (rd [:xform :rot]) rot (rd [:xform :rot])
scl (rd [:xform :scale]) scl (rd [:xform :scale])
skw (rd [:xform :skew])] skw (rd [:xform :skew])]
(when-not (or (ch/nothing? pos) (ch/nothing? rot) (ch/nothing? scl) (when-not (or (ch/nothing? pos) (ch/nothing? piv) (ch/nothing? rot)
(ch/nothing? skw)) (ch/nothing? scl) (ch/nothing? skw))
[pos rot scl skw]))) [pos piv rot scl skw])))
(defn- visible? (defn- visible?
"Is the node switched on this frame? "Is the node switched on this frame?
@ -422,10 +428,10 @@
plf (node/local-frame n ppf) plf (node/local-frame n ppf)
rd (fn [path] (read id path (get chs path) lf plf))] rd (fn [path] (read id path (get chs path) lf plf))]
(when (visible? id (rd [:vis])) (when (visible? id (rd [:vis]))
(when-let [[pos rot scl skw] (xform-at rd)] (when-let [[pos piv rot scl skw] (xform-at rd)]
;; dest aliases `local` here, which mul! allows: it reads both ;; dest aliases `local` here, which mul! allows: it reads both
;; operands fully before writing either. ;; operands fully before writing either.
(let [m (node/local! (mat-for id) pos rot scl skw)] (let [m (node/local! (mat-for id) pos piv rot scl skw)]
{:m (node/world! m (:m parent) (pinv-for id) m scratch) {:m (node/world! m (:m parent) (pinv-for id) m scratch)
:f lf :f lf
:pre plf :pre plf

View file

@ -28,6 +28,7 @@
[arthur.domain.outline :as outline] [arthur.domain.outline :as outline]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.pick :as pick]
[arthur.domain.span :as span] [arthur.domain.span :as span]
[arthur.events.edit :as edit] [arthur.events.edit :as edit]
[arthur.domain.paint :as paint] [arthur.domain.paint :as paint]
@ -1050,32 +1051,26 @@
A DEFAULT, set once: nothing keeps it fitted afterwards. A DEFAULT, set once: nothing keeps it fitted afterwards.
THIS RESCALES, SO IT HAS TO RE-PLACE, and that is work the stored anchor used THE SCALE ALONE, SINCE A NODE HAS A PIVOT. `clip/place-symbol` has just put the
to do silently. `clip/place-symbol` has just put the picture's middle where it picture's middle where it belongs and stored that same middle as the
belongs — under the pointer, or wherever it was — at the IDENTITY scale, and placement's `[:xform :pivot]`, and `node/local!` composes the scale about it —
then this changes the scale. With an anchor on the middle, `T(a)·S(k)·T(-a)` `T(pos)·T(a)·S(k)·T(-a)` leaves `a` exactly where it is for every `k` — so
left that point alone for any `k` and there was nothing to fix. Without one, changing the scale here cannot move the picture off the point it was dropped
`T(pos)·S(k)` moves it by `(1-k)·middle`, so the middle has to be solved for on. This used to have to solve for the position again, the same equation
again — the same equation `gesture/about` solves per drag, with `middle` as the `gesture/about` solves per drag, because there was no pivot to compose about
point to hold. and `T(pos)·S(k)` moves the middle by `(1-k)·middle`.
A trace's own coordinates are its pixels, `[0 0 width height]`, so its middle A trace's own coordinates are its pixels, so a drop on the timeline still has
is half its size and `pos = where − k·middle` for both branches: `where` is a position to write: the picture's middle goes on the middle of the stage."
where `place-symbol` left the middle when a pointer chose it, and the stage's
own middle when nothing did — a 1920px still is otherwise six stages tall and
dropped off-centre as well."
[document sid uuid {:keys [width height]} point?] [document sid uuid {:keys [width height]} point?]
(let [[w h] (clip/stage document sid) (let [[w h] (clip/stage document sid)
k (min (/ w width) (/ h height)) k (min (/ w width) (/ h height))
mid [(/ width 2) (/ height 2)]] mid [(/ width 2) (/ height 2)]]
(update-in document [:symbols sid :nodes uuid :channels] (update-in document [:symbols sid :nodes uuid :channels]
(fn [chs] (fn [chs]
(let [where (if point? (cond-> (assoc chs [:xform :scale] (ch/framed [k k]))
(mapv + (:value (get chs [:xform :pos])) mid) (not point?)
[(/ w 2) (/ h 2)])] (assoc [:xform :pos] (ch/framed (mapv - [(/ w 2) (/ h 2)] mid))))))))
(assoc chs
[:xform :scale] (ch/framed [k k])
[:xform :pos] (ch/framed (mapv - where (mapv * [k k] mid)))))))))
(rf/reg-event-db (rf/reg-event-db
::drop-symbol ::drop-symbol
@ -1383,10 +1378,26 @@
(rf/reg-event-db (rf/reg-event-db
::repivot ::repivot
;; Put the pivot of the peg at `path` on stage point `point`, moving nothing. ;; Put the pivot of the node at `path` on stage point `point`, moving nothing.
;; One edit at the end of the drag rather than per pointermove: a repivot moves ;; One edit at the end of the drag rather than per pointermove: a repivot moves
;; nothing on screen by construction, so there is no intermediate state worth ;; nothing on screen by construction, so there is no intermediate state worth
;; drawing — `ui/stage` follows the pointer with the cross alone. ;; drawing — `ui/stage` follows the pointer with the cross alone.
;;
;; ANY NODE, not just a peg. `[:xform :pivot]` is a channel every node has, so
;; this is the same two writes — the pivot, and the position that leaves the
;; picture where it is — on a drawing, an instance, a peg or a measured part
;; alike. It used to be `nest/repivot`, which moved a peg's ORIGIN and
;; compensated each child's `:pinv` to hold it still: the same gesture by way of
;; the parenting machinery, available on pegs alone, and refused outright on a
;; peg whose position was animated, because one stored parent-inverse cannot
;; correct for a parent that moves. A pivot in the node's own transform has
;; nothing to correct in anybody else's.
;;
;; NOT REFUSED ON A MEASURED NODE, though a drag on one is. A pivot is authored
;; on every node — never dense, never regenerated — so a measured mouth can be
;; told where to turn about without a peg and with nothing a regenerate would
;; throw away. The compensating position goes through `apply-values`, which puts
;; it in a correction layer over the measurement, exactly as a hand move does.
(fn [db [_ path point]] (fn [db [_ path point]]
(let [{clip :clip st :store} (store/entry (:clip/current db)) (let [{clip :clip st :store} (store/entry (:clip/current db))
open (get-in db [:ui :open]) open (get-in db [:ui :open])
@ -1396,11 +1407,39 @@
(when-let [inv (node/invert (:parent pl))] (when-let [inv (node/invert (:parent pl))]
(node/apply-pt! out 0 inv (first point) (second point)) (node/apply-pt! out 0 inv (first point) (second point))
[(aget out 0) (aget out 1)]))) [(aget out 0) (aget out 1)])))
r (when c (nest/repivot clip st open (vec path) f c))] vs (when c (gesture/repivot
(gesture/values (get-in clip [:symbols (:sid pl) :nodes (:id pl)])
(:frame pl) st)
c))]
(cond (cond
(nil? r) db (nil? vs) db
(:refused r) (refused db (:refused r)) :else (edit/edit db #(gesture/apply-values % (:sid pl) (:id pl) (:frame pl)
:else (edit/edit db (constantly (:clip r))))))) vs false st))))))
(rf/reg-event-db
::centre-pivot
;; Put the pivot of the node at `path` back on the middle of what it draws NOW,
;; moving nothing. The inspector's ⌖, beside the pivot row.
;;
;; THE WAY BACK FROM A STORED PIVOT, and what makes storing one safe. A pivot is
;; a choice and does not follow the drawing — a keyed spin must not be re-aimed
;; by somebody drawing one more shape inside the symbol — but drawings do grow,
;; and re-centring by hand means reading a bounding box off the screen and typing
;; two numbers that also have to move `pos` to avoid shifting the picture. One
;; button, `gesture/centred`, at exactly the point an untouched node's cross
;; would already be.
(fn [db [_ path]]
(let [{clip :clip st :store} (store/entry (:clip/current db))
open (get-in db [:ui :open])
f (editing-frame db clip)
pl (nest/placement clip st open (vec path) f)
n (when pl (get-in clip [:symbols (:sid pl) :nodes (:id pl)]))
vs (when n (gesture/centred
(gesture/values n (:frame pl) st)
((pick/bounds-of clip st (:sid pl) n) (:frame pl))))]
(if (nil? vs)
(refused db "it is not on screen at this frame, so it has no middle to find")
(edit/edit db #(gesture/apply-values % (:sid pl) (:id pl) (:frame pl) vs false st))))))
(rf/reg-event-db (rf/reg-event-db
::edit-keyframes ::edit-keyframes

View file

@ -6,6 +6,7 @@
value, so clicking a swatch notifies the swatches and nothing else." value, so clicking a swatch notifies the swatches and nothing else."
(:require [arthur.domain.creation :as creation] (:require [arthur.domain.creation :as creation]
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.gesture :as gesture]
[arthur.domain.nest :as nest] [arthur.domain.nest :as nest]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.pick :as pick] [arthur.domain.pick :as pick]
@ -147,7 +148,14 @@
(when n (when n
(let [st (:store (store/entry clip-id))] (let [st (:store (store/entry clip-id))]
(when-let [pl (nest/placement clip st open (or path [id]) f)] (when-let [pl (nest/placement clip st open (or path [id]) f)]
(assoc pl :node n :bounds ((pick/bounds-of clip st (:sid pl) n) (:frame pl)))))))) (let [bounds ((pick/bounds-of clip st (:sid pl) n) (:frame pl))]
(assoc pl :node n :bounds bounds
;; WHERE THE CROSS GOES, in the space `:parent` takes to the
;; stage: the node's own pivot, or the middle of what it draws
;; for one nobody has pivoted. `gesture/pivot` is the one rule,
;; so the cross marks the point a drag will actually turn
;; about rather than a second guess at it.
:pivot (gesture/pivot (gesture/values n (:frame pl) st) bounds))))))))
(rf/reg-sub (rf/reg-sub
::selected-placements ::selected-placements

View file

@ -177,7 +177,7 @@
;; Rotation is shown in degrees. Between two keys, the gap after the one here ;; Rotation is shown in degrees. Between two keys, the gap after the one here
;; holds or tweens, as a drawing's does. ;; holds or tweens, as a drawing's does.
(defn- channel-control [sid id path ch frame auto-key?] (defn- channel-control [sid id path ch frame auto-key? node-path]
(let [keyed? (some? (:keys ch)) (let [keyed? (some? (:keys ch))
;; No store: the call site below hands this only channels that are not ;; No store: the call site below hands this only channels that are not
;; `:dense`, which are the only ones with anything in tier 2 to read. ;; `:dense`, which are the only ones with anything in tier 2 to read.
@ -206,6 +206,16 @@
(number? v) (field 0 v put) (number? v) (field 0 v put)
:else (doall (map-indexed (fn [i x] (field i x #(put (assoc (vec v) i %)))) :else (doall (map-indexed (fn [i x] (field i x #(put (assoc (vec v) i %))))
v))) v)))
;; THE WAY BACK TO THE MIDDLE, on the pivot row and nowhere else. A pivot is
;; a choice and does not follow the drawing, which is what keeps a keyed spin
;; from being re-aimed by somebody drawing one more shape inside the symbol —
;; so "put it back in the middle of what is there now" has to be something
;; you can ask for. `::ui/centre-pivot` moves nothing while it does it.
(when (and (= path [:xform :pivot]) (seq node-path))
[:button.key {:title "centre the pivot on the middle of what it draws now"
:aria-label "centre pivot"
:on-click #(rf/dispatch [::ui/centre-pivot node-path])}
"⌖"])
(when gap? [segment-select sid id path ch left])])) (when gap? [segment-select sid id path ch left])]))
(defn- color-control [sid id ch frame auto-key? palette] (defn- color-control [sid id ch frame auto-key? palette]
@ -267,6 +277,11 @@
(defn- node-section [[sid id n]] (defn- node-section [[sid id n]]
(let [[start end] (:span n) (let [[start end] (:span n)
;; The selected row path, for the one control that needs to name the node
;; the way a stage gesture does rather than by `[sid id]`: ⌖ re-centres a
;; pivot, and finding the middle of what a node draws means resolving it
;; where it is placed.
node-path (nth @(rf/subscribe [::sub/selection]) 3 nil)
auto-key? @(rf/subscribe [::sub/auto-key?]) auto-key? @(rf/subscribe [::sub/auto-key?])
clip @(rf/subscribe [::render/clip]) clip @(rf/subscribe [::render/clip])
placement @(rf/subscribe [::sub/selected-placement]) placement @(rf/subscribe [::sub/selected-placement])
@ -308,7 +323,7 @@
[color-control sid id ch frame auto-key? palette] [color-control sid id ch frame auto-key? palette]
(and (contains? (node/defaults-of n) path) (not (:dense ch))) (and (contains? (node/defaults-of n) path) (not (:dense ch)))
[channel-control sid id path ch frame auto-key?] [channel-control sid id path ch frame auto-key? node-path]
:else [:dd (channel-state ch)])]))])])) :else [:dd (channel-state ch)])]))])]))

View file

@ -158,13 +158,16 @@
(defn- begin! (defn- begin!
"Start dragging `kind` of the node at `path` from stage point `p`. "Start dragging `kind` of the node at `path` from stage point `p`.
THE PIVOT IS DERIVED HERE, once, and held for the drag. Once per pointerdown is THE PIVOT IS READ ONCE, when the pointer goes down, and held for the drag.
what makes deriving it affordable where a stored one was tempting — and holding `gesture/pivot` answers with the node's own `[:xform :pivot]` where it has one,
it for the drag is what keeps a turn steady: re-deriving per pointermove would and with the middle of what it draws where nobody has pivoted it yet — and
chase the box the turn is itself moving. holding that for the drag is what keeps a turn steady: re-deriving per
pointermove would chase the box the turn is itself moving.
On a drawing it comes out as the node's own origin, because that is where A DRAG ON A NODE WITH NO PIVOT OF ITS OWN WRITES ONE, as part of its first
`paint/centred` put it, and `gesture/turn` then writes `rot` alone." edit — `gesture/with-pivot`. From then on the node turns about a point of its
own, so `turn` writes `rot` alone and a keyed turn holds its pivot between its
keys as well as on them."
[{:keys [open f] :as ctx} kind path p] [{:keys [open f] :as ctx} kind path p]
(let [{document :clip st :store} (loaded ctx)] (let [{document :clip st :store} (loaded ctx)]
(when-let [{:keys [sid id frame] :as pl} (nest/placement document st open path f)] (when-let [{:keys [sid id frame] :as pl} (nest/placement document st open path f)]
@ -268,38 +271,32 @@
on the pivot. Dragging inside it moves it — that is the stage's own on the pivot. Dragging inside it moves it — that is the stage's own
pointerdown, which keeps a selection it lands inside. pointerdown, which keeps a selection it lands inside.
THE CROSS IS THE MIDDLE OF THE BOX, and that is an identity rather than a THE CROSS IS ON THE PIVOT, which is `[:xform :pivot]` through the node's
coincidence to keep up: both come from the same `bounds` on the same frame, so parent — the point `node/local!` composes the rotation and the scale about, and
the cross cannot drift off the box. On a node that draws nothing — a peg — there therefore the point a turn here will actually hold still, on this frame and
is no box and the cross marks its own origin, which is what it turns about. every frame between two keys. For a node nobody has pivoted yet it is the
middle of the selection box, from the same `bounds` the box is drawn from, and
the first turn or scale writes that down; `gesture/pivot` is the one rule and
`subs/ui` applies it, so the overlay cannot disagree with the gesture.
ON A DRAWING THOSE ARE THE SAME POINT, because `paint/centred` puts a shape's THE CROSS IS A HANDLE, ON EVERY NODE. ⌃/⌘-dragging it puts the pivot somewhere
origin on the middle of what it draws. So the cross marks the node's origin as else — `::ui/repivot`, which moves nothing — and dragging it plainly moves the
well as its box's middle, and a turn about it is `rot` alone rather than a node, as dragging inside the box does. This is After Effects' pan-behind and
rotation plus a position solved to place it. Harmony's pivot handle; it is how an arm comes to turn about its shoulder
rather than its middle, and it used to exist on pegs alone.
THE CROSS IS NOT A HANDLE FOR THE PIVOT here. A peg's cross IS draggable
(`::ui/repivot`), which is the general answer: a pivot that has to persist, be
keyed, or sit over a measured transform is a peg, which is a node, whose pivot
is its own origin.
A PEG GETS A ROSETTE INSTEAD OF A BOX, and it has to get something: it draws A PEG GETS A ROSETTE INSTEAD OF A BOX, and it has to get something: it draws
nothing, so it has no bounds to hang handles on — and the stage's other way in, nothing, so it has no bounds to hang handles on — and the stage's other way in,
dragging what is under the pointer, is `pick/choose` on the DRAWN ops, which dragging what is under the pointer, is `pick/choose` on the DRAWN ops, which
never returns a node that draws nothing. Without this a peg could be made and never returns a node that draws nothing. Without this a peg could be made and
then only be typed at in the inspector, which is not a pivot anybody would use. then only be typed at in the inspector. So the handles fall back to a
So the handles fall back to a fixed-size cluster about its own origin: the cross fixed-size cluster about its pivot: a knob turns it, four corners scale it.
moves it, a knob turns it, four corners scale it. FIXED SIZE, in stage pixels, FIXED SIZE, in stage pixels, because there is no drawing to be proportional to
because there is no drawing to be proportional to — the same reason the ghost's — the same reason the ghost's cross is always drawn at a fixed size."
cross is always drawn at a fixed size."
[ctx] [ctx]
(let [{:keys [world bounds]} @(rf/subscribe [::sub/selected-placement]) (let [{:keys [world parent bounds] piv :pivot} @(rf/subscribe [::sub/selected-placement])
[_ _ _ path] @(rf/subscribe [::sub/selection]) [_ _ _ path] @(rf/subscribe [::sub/selection])
[px py] (when world [px py] (when (and parent piv) (through parent piv))
(let [[x0 y0 x1 y1] bounds]
(through world (if bounds
[(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)]
[0 0]))))
grab (fn [kind] grab (fn [kind]
(fn [^js event] (fn [^js event]
(.stopPropagation event) (.stopPropagation event)
@ -307,8 +304,41 @@
(let [svg (.-ownerSVGElement (.-currentTarget event))] (let [svg (.-ownerSVGElement (.-currentTarget event))]
(.setPointerCapture svg (.-pointerId event)) (.setPointerCapture svg (.-pointerId event))
(begin! ctx kind path (xy svg event (:w ctx) (:h ctx))))))] (begin! ctx kind path (xy svg event (:w ctx) (:h ctx))))))]
(when world (when (and world px)
[:g.handles [:g.handles
;; FIRST, SO THE CORNERS AND THE KNOB WIN THE HIT TEST. The disc is 5
;; stage pixels across and some of the shapes here are a few pixels
;; across, so on a small drawing it covers its own box: a scale corner
;; painted after it is still grabbable, one painted before it is not. A
;; peg's rosette sits its corners 7 px out, spanning 5.2 to 8.8 from the
;; middle, which is the other reason the disc stops short of 5.2.
[:circle.pivot-grab
{:cx px :cy py :r 5
;; ⌃/⌘ moves the PIVOT instead of the node, which is the pan-behind
;; split: a plain drag writes `pos` and takes the drawing — and, on a
;; peg, everything hanging off it — with it, and that is a translate,
;; not a new pivot.
;;
;; NOT ⌥, which this was and which does not survive the trip. Most
;; Linux window managers grab Alt-drag to move the window, so the page
;; never sees the pointer at all and the gesture is simply missing — on
;; the machine it is missing from, with no error. ⌥ is still accepted
;; for anyone whose desktop leaves it alone, and the command key is the
;; one the rest of this stage already reaches for.
;;
;; ⇧ is deliberately NOT it: it means CONSTRAIN everywhere else here —
;; uniform scale, 15° turn steps — and it is what a snap to the
;; drawing's corners would want when this drag grows one.
:on-pointer-down
(fn [^js event]
(.stopPropagation event)
(.preventDefault event)
(let [svg (.-ownerSVGElement (.-currentTarget event))
p (xy svg event (:w ctx) (:h ctx))]
(.setPointerCapture svg (.-pointerId event))
(if (or (.-ctrlKey event) (.-metaKey event) (.-altKey event))
(reset! repivot {:path path :p p})
(begin! ctx :move path p))))}]
(if-let [[x0 y0 x1 y1] bounds] (if-let [[x0 y0 x1 y1] bounds]
(let [corners (partition 2 (through world [x0 y0 x1 y0 x1 y1 x0 y1])) (let [corners (partition 2 (through world [x0 y0 x1 y0 x1 y1 x0 y1]))
[cx cy tx ty] (through world [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2) (/ (+ x0 x1) 2) y0]) [cx cy tx ty] (through world [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2) (/ (+ x0 x1) 2) y0])
@ -326,35 +356,6 @@
;; A peg. Same three gestures, hung on nothing. ;; A peg. Same three gestures, hung on nothing.
(let [r 7] (let [r 7]
[:<> [:<>
;; BEFORE the corners, and smaller than the ring they sit on, or it
;; swallows the inner half of every one of them: a corner spans 5.2
;; to 8.8 from the middle, so the disc has to stop short of 5.2.
[:circle.peg-grab
{:cx px :cy py :r 5
;; ⌃/⌘ moves the PIVOT instead of the peg, which is the pan-behind
;; split: a plain drag writes `pos` and carries the children with
;; it, and that is a translate, not a new pivot.
;;
;; NOT ⌥, which this was and which does not survive the trip. Most
;; Linux window managers grab Alt-drag to move the window, so the
;; page never sees the pointer at all and the gesture is simply
;; missing — on the machine it is missing from, with no error. ⌥ is
;; still accepted for anyone whose desktop leaves it alone, and the
;; command key is the one the rest of this stage already reaches for.
;;
;; ⇧ is deliberately NOT it: it means CONSTRAIN everywhere else
;; here — uniform scale, 15° turn steps — and it is what a snap to
;; the child's corners would want when this drag grows one.
:on-pointer-down
(fn [^js event]
(.stopPropagation event)
(.preventDefault event)
(let [svg (.-ownerSVGElement (.-currentTarget event))
p (xy svg event (:w ctx) (:h ctx))]
(.setPointerCapture svg (.-pointerId event))
(if (or (.-ctrlKey event) (.-metaKey event) (.-altKey event))
(reset! repivot {:path path :p p})
(begin! ctx :move path p))))}]
[:line.knob-arm {:x1 px :y1 (- py r) :x2 px :y2 (- py r 8)}] [:line.knob-arm {:x1 px :y1 (- py r) :x2 px :y2 (- py r 8)}]
[:circle.knob {:cx px :cy (- py r 8) :r 2.2 :on-pointer-down (grab :turn)}] [:circle.knob {:cx px :cy (- py r 8) :r 2.2 :on-pointer-down (grab :turn)}]
(doall (doall
@ -363,7 +364,10 @@
^{:key i} ^{:key i}
[:rect.corner {:x (- x 1.8) :y (- y 1.8) :width 3.6 :height 3.6 [:rect.corner {:x (- x 1.8) :y (- y 1.8) :width 3.6 :height 3.6
:on-pointer-down (grab :scale)}]))])) :on-pointer-down (grab :scale)}]))]))
;; Where the pivot is going, while it is being put there. ;; The cross itself, last and unclickable: it marks the pivot, and while a
;; ⌃/⌘-drag is placing one it follows the pointer alone — nothing else
;; moves during a repivot, by construction, so there is nothing else to
;; redraw until the pointer goes up.
(let [[cx cy] (or (:p @repivot) [px py])] (let [[cx cy] (or (:p @repivot) [px py])]
[:path.pivot {:class (when @repivot "moving") [:path.pivot {:class (when @repivot "moving")
:d (str "M " (- cx 4) " " cy " H " (+ cx 4) :d (str "M " (- cx 4) " " cy " H " (+ cx 4)

View file

@ -125,6 +125,53 @@
(str "frame " f ": the middle of the drawing is at " (pr-str [x y]) (str "frame " f ": the middle of the drawing is at " (pr-str [x y])
", not on the straight line from (60, 150) to (160, 40)")))))) ", not on the straight line from (60, 150) to (160, 40)"))))))
(deftest a-keyed-turn-of-a-symbol-instance-holds-its-pivot-too
;; THE BUG AS IT WAS REPORTED THE SECOND TIME, and the case the drawing test
;; above could never have caught. A symbol is drawn ON THE STAGE, so its origin
;; is the stage's top-left corner and the middle of what it draws is a long way
;; from it — 161 px, on the document this came off, on a 320x200 stage. An
;; instance that turned about its origin therefore swung its drawing round the
;; corner of the stage on an orbit the size of the stage: the two keys looked
;; right, every frame between them was somewhere else entirely, and at frame 30
;; of 60 the drawing was off the left edge.
;;
;; Keyed here exactly as the stage keys it — `turn` through `apply-values` with
;; auto-key armed, twice, at two frames — and then checked ON EVERY FRAME, which
;; is the only way this is caught: a full turn is right at 0° and at 360°.
(let [;; a shape at the far side of the symbol from its origin, as a drawing
;; made on the stage is
c (-> (clip/blank)
(assoc-in [:symbols :box] {:id :box :frames 60 :nodes {}})
(paint/new-shape :box :shape 0 [100 80 140 80 140 110 100 110] :brow)
(clip/place-symbol nil :main :box 0 u nil))
node #(get-in % [:symbols :main :nodes u])
box #(let [n (node %)] ((pick/bounds-of % nil :main n) 0))
;; where the drawing's middle is, on the stage, at frame f
middle (fn [doc f]
(let [{:keys [world]} (nest/placement doc nil :main [u] f)
[x0 y0 x1 y1] (box doc)]
(at world [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)])))
;; turn it at frame 0, and again at frame 30, auto-keying both
spin (fn [doc f da]
(let [v (gesture/values (node doc) f nil)]
(gesture/apply-values doc :main u f
(gesture/turn v (gesture/pivot v (box doc)) da)
true)))
turned (-> c (spin 0 0.0) (spin 30 (* 2 js/Math.PI)))
was (middle c 0)]
(is (= [120 95] (:value (get-in c [:symbols :main :nodes u :channels [:xform :pivot]])))
"the placement pivots about the middle of what the symbol draws, which is
120 px and 95 px from the symbol's own origin — the orbit the drawing
used to be swung round")
(is (= #{0 30} (set (keys (get-in turned [:symbols :main :nodes u :channels [:xform :rot] :keys]))))
"one rotation channel, keyed twice")
(is (nil? (:keys (get-in turned [:symbols :main :nodes u :channels [:xform :pos]])))
"and NO position keys: a turn writes the rotation and nothing else")
(doseq [f (range 0 31)]
(is (near? was (middle turned f))
(str "frame " f ": the drawing's middle is at " (pr-str (middle turned f))
" rather than staying on " (pr-str was) " — it is orbiting, not turning")))))
(deftest scaling-takes-the-grabbed-point-to-the-pointer (deftest scaling-takes-the-grabbed-point-to-the-pointer
(let [c (two-down) (let [c (two-down)
path [u v :shape] path [u v :shape]

View file

@ -209,8 +209,8 @@
(let [k (ch/value-at scale f nil) (let [k (ch/value-at scale f nil)
;; peg · face, composed as the evaluator does ;; peg · face, composed as the evaluator does
m (node/mul! (node/mat) m (node/mul! (node/mat)
(node/local! (node/mat) (ch/value-at pos 0 nil) 0 k [0 0]) (node/local! (node/mat) (ch/value-at pos 0 nil) [0 0] 0 k [0 0])
(node/local! (node/mat) off 0 [1 1] [0 0])) (node/local! (node/mat) off [0 0] 0 [1 1] [0 0]))
out (js/Float64Array. 2)] out (js/Float64Array. 2)]
(node/apply-pt! out 0 m 160 100) (node/apply-pt! out 0 m 160 100)
(is (= [40 40] [(aget out 0) (aget out 1)]) (is (= [40 40] [(aget out 0) (aget out 1)])
@ -358,23 +358,39 @@
[:symbols :main :nodes u :channels]))] [:symbols :main :nodes u :channels]))]
(is (= [25 35] (clip/center c nil :box)) "the middle of the square") (is (= [25 35] (clip/center c nil :box)) "the middle of the square")
(is (= [160 100] (clip/center c nil :empty)) "nothing drawn: the stage's middle") (is (= [160 100] (clip/center c nil :empty)) "nothing drawn: the stage's middle")
(testing "a placement stores a position and no pivot at all" (testing "a placement stores the middle as its pivot"
(is (= [[:xform :pos]] (keys (placed c :box nil))) "one channel, and it is where it sits") ;; WHY IT IS STORED AND NOT DERIVED. An instance's own origin is its
;; SYMBOL's, and a symbol is drawn on the stage, so the middle of what it
;; draws is typically a hundred-odd pixels away from it — the corner of the
;; stage. `node/local!` composes about the pivot, so this one value is the
;; difference between an instance spinning in place and orbiting that
;; corner once per key.
(is (= [[:xform :pos] [:xform :pivot]] (keys (placed c :box nil)))
"where it sits, and what it turns about")
(is (= [25 35] (get-in (placed c :box nil) [[:xform :pivot] :value]))
"the middle of what the symbol draws, in the symbol's own coordinates")
(is (= [0 0] (get-in (placed c :box nil) [[:xform :pos] :value])) (is (= [0 0] (get-in (placed c :box nil) [[:xform :pos] :value]))
"dropped on the timeline: where it was drawn")) "dropped on the timeline: where it was drawn"))
(testing "dropped on a stage pixel, its middle goes there" (testing "dropped on a stage pixel, its middle goes there"
(is (= [75 65] (get-in (placed c :box [100 100]) [[:xform :pos] :value])))) (is (= [75 65] (get-in (placed c :box [100 100]) [[:xform :pos] :value])))
(testing "growing the symbol later moves where its instances pivot, and moves nothing on screen" (is (= [25 35] (get-in (placed c :box [100 100]) [[:xform :pivot] :value]))))
;; THE BUG, INVERTED. This used to assert the opposite — that the instance (testing "growing the symbol afterwards does not move what its instances pivot about"
;; kept pivoting about where the symbol's drawing had been — because the ;; THE RULE, and it is the user's: once something has been animated, adding
;; middle was copied into a stored anchor at drop time and nothing ever ;; to what it draws must not alter what it turns about. This test has
;; invalidated it. A pivot derived per drag follows the drawing instead, and ;; asserted both answers now. It began by asserting a stored pivot was kept
;; it cannot move anything on screen by doing so: there is no stored value ;; — then that was read as a cache going stale, the pivot was deleted and
;; for the composition to read, so adding `sq2` changes the pivot and not ;; derived per drag, and this asserted the opposite: that the pivot
;; one pixel of the picture. ;; FOLLOWED the drawing. That is the worse bug of the two, and a quieter
;; one: it means drawing one more shape inside a symbol silently re-aims
;; every keyed spin of every instance of it, with nothing on screen
;; changing at the moment it happens.
;;
;; A pivot is a CHOICE, like a drawing's origin, which `paint/centred`
;; chooses once when the stroke is made and never revisits. Storing it is
;; what makes it one. What the old anchor got wrong was being invisible and
;; unmovable — not being stored — and the cross on the stage is draggable.
(let [c (clip/place-symbol c nil :main :box 0 u nil) (let [c (clip/place-symbol c nil :main :box 0 u nil)
grown (assoc-in c [:symbols :box :nodes :sq2] (assoc (square 80 30) :id :sq2 :z "a2")) grown (assoc-in c [:symbols :box :nodes :sq2] (assoc (square 80 30) :id :sq2 :z "a2"))
;; what `gesture/pivot` derives for the instance, before and after
pivot-of (fn [doc] pivot-of (fn [doc]
(let [n (get-in doc [:symbols :main :nodes u])] (let [n (get-in doc [:symbols :main :nodes u])]
(gesture/pivot (gesture/values n 0 nil) (gesture/pivot (gesture/values n 0 nil)
@ -382,10 +398,33 @@
(is (= [25 35] (clip/center c nil :box)) "the symbol's middle") (is (= [25 35] (clip/center c nil :box)) "the symbol's middle")
(is (= [55 35] (clip/center grown nil :box)) "and it moved when the symbol grew") (is (= [55 35] (clip/center grown nil :box)) "and it moved when the symbol grew")
(is (= [25 35] (pivot-of c))) (is (= [25 35] (pivot-of c)))
(is (= [55 35] (pivot-of grown)) "the instance's pivot followed the drawing") (is (= [25 35] (pivot-of grown))
"but the instance still turns about the point it was placed on")
(is (= (get-in c [:symbols :main :nodes u :channels]) (is (= (get-in c [:symbols :main :nodes u :channels])
(get-in grown [:symbols :main :nodes u :channels])) (get-in grown [:symbols :main :nodes u :channels]))
"and not one channel of the instance changed, so nothing on screen moved"))))) "and not one channel of the instance changed, so nothing on screen moved")))
(testing "a node nobody has pivoted yet has no choice to keep"
;; The other half of the rule, and not a contradiction of it: a CEL is made
;; to be drawn in, so there is no middle to commit to when it is made —
;; `span/held` stores no pivot — and until a turn or a scale writes one, the
;; default follows the drawing. Nothing has been animated, so there is
;; nothing a moving default can spoil.
(let [cel {:id :cel :kind :instance :z "c1" :span [0 1]
:source {:symbol :box} :playback {:in 0 :speed 0 :end :stop}}
c (assoc-in c [:symbols :main :nodes :cel] cel)
grown (assoc-in c [:symbols :box :nodes :sq2] (assoc (square 80 30) :id :sq2 :z "a2"))
v #(gesture/values (get-in % [:symbols :main :nodes :cel]) 0 nil)
piv #(gesture/pivot (v %) ((pick/bounds-of % nil :main (get-in % [:symbols :main :nodes :cel])) 0))]
(is (= [25 35] (piv c)))
(is (= [55 35] (piv grown)) "the default is the middle of what it draws NOW")
;; And the first turn freezes it: the write is the pivot and the rotation.
(let [vs (gesture/turn (v grown) (piv grown) 0.4)
out (gesture/apply-values grown :main :cel 0 vs)]
(is (= #{[:xform :pivot] [:xform :pos] [:xform :rot]} (set (keys vs)))
"a first turn chooses the pivot in the same edit")
(is (= [55 35] (get-in out [:symbols :main :nodes :cel :channels [:xform :pivot] :value])))
(is (= [55 35] (gesture/pivot (gesture/values (get-in out [:symbols :main :nodes :cel]) 0 nil) nil))
"and from then on it is the node's own, whatever the symbol does next"))))))
(deftest palette-context-is-inherited-keyed-and-overridable (deftest palette-context-is-inherited-keyed-and-overridable
(let [palette (fn [id name a b] (let [palette (fn [id name a b]

View file

@ -439,8 +439,10 @@
(deftest a-peg-sits-on-the-pivot-so-it-turns-about-the-same-point (deftest a-peg-sits-on-the-pivot-so-it-turns-about-the-same-point
;; The peg lands where the cross was, so grabbing it turns about exactly the ;; The peg lands where the cross was, so grabbing it turns about exactly the
;; point a drag on the node would have. That is what makes it a REPLACEMENT for ;; point a drag on the node would have. Not because a peg stands in for the
;; the pivot rather than a second, differently-placed one. ;; node's pivot — it does not, the node has one — but because a peg made to
;; carry a SECOND transform should not also move what the first one turns
;; about.
(let [c (shaped) (let [c (shaped)
n (get-in c [:symbols :main :nodes :shape]) n (get-in c [:symbols :main :nodes :shape])
was (gesture/pivot (gesture/values n 0 nil) was (gesture/pivot (gesture/values n 0 nil)
@ -472,12 +474,13 @@
(is (near? (vec (radii c)) (vec (radii out))) (is (near? (vec (radii c)) (vec (radii out)))
"and every point kept its distance from the peg — so it TURNED about it"))) "and every point kept its distance from the peg — so it TURNED about it")))
(deftest a-peg-goes-over-a-measured-node-which-is-the-one-thing-an-anchor-could-not-do (deftest a-peg-goes-over-a-measured-node-which-is-what-a-peg-is-for
;; `gesture/refusal` turns a drag on a measured node away and tells you to put ;; `gesture/refusal` turns a drag on a measured node away and tells you to put
;; a peg over it, so this is that advice being true. An anchor could never have ;; a peg over it, so this is that advice being true: a peg's channels are its
;; been written here: `node/local!`'s translation is `pos − M·a`, and under the ;; own, so the hand transform composes outside the measurement and the next
;; head's measured similarity `M` is nowhere near the identity, so writing one ;; regenerate still owns what it owns. This is the row of the table a pivot
;; would have moved the whole face. A peg's channels are its own. ;; does NOT cover — the pivot says where a measured part turns about, the peg
;; says what turns it.
(let [{c :clip st :store} @take/frozen (let [{c :clip st :store} @take/frozen
head (get-in c [:symbols :face-1 :nodes :head])] head (get-in c [:symbols :face-1 :nodes :head])]
(is (node/measured? head)) (is (node/measured? head))
@ -495,43 +498,65 @@
(is (near? (drawn-at c st f) (drawn-at out st f)) (is (near? (drawn-at c st f) (drawn-at out st f))
(str "frame " f " moved when the peg appeared")))))) (str "frame " f " moved when the peg appeared"))))))
(deftest moving-a-pegs-pivot-moves-nothing-under-it (deftest moving-a-pivot-moves-nothing
;; THE OPERATION A PLAIN DRAG IS NOT. Dragging a peg writes its `pos`, and a ;; THE OPERATION A PLAIN DRAG IS NOT. Dragging a node writes its `pos` — and a
;; peg is a parent, so that carries its children with it — a translate. Putting ;; peg is a parent, so that carries its children with it. Putting the pivot
;; the pivot somewhere else has to leave the picture alone, and does, because ;; somewhere else has to leave the picture exactly alone, and does: `repivot`
;; only `local(peg) · pinv(child)` reaches the child and `repivot` preserves ;; writes the pivot and the position together so that `pos + a` lands on the
;; that product. ;; point chosen and `T(pos)·T(a)·M·T(-a)` is unchanged as a map.
(let [c (:clip (nest/peg (shaped) nil :main [:shape] 0 pg)) ;;
was (drawn-at c 0) ;; ON ANY NODE, which is the difference from what this used to be. It was
r (nest/repivot c nil :main [pg] 0 [10 90]) ;; `nest/repivot`, which moved a PEG's origin and rewrote each child's `:pinv`
out (:clip r)] ;; to compensate — available on pegs alone, and refused outright on a peg whose
(is (nil? (:refused r)) (:refused r)) ;; position was animated, since one stored parent-inverse cannot correct for a
(is (= [10 90] (:value (get-in out [:symbols :main :nodes pg :channels [:xform :pos]]))) ;; parent that moves. A pivot inside the node's own transform has nothing to
"the peg's origin is where it was put") ;; correct in anybody else's, so neither restriction survives.
(is (near? was (drawn-at out 0)) "and not one point of the child moved") (doseq [[what c path target]
(is (empty? (clip/problems out))) [["a peg" (:clip (nest/peg (shaped) nil :main [:shape] 0 pg)) [pg] pg]
;; And it is a REAL pivot afterwards: a turn about it keeps it fixed. ["a drawing" (shaped) [:shape] :shape]
(let [v0 (gesture/values (get-in out [:symbols :main :nodes pg]) 0 nil) ["a peg whose position is animated"
piv (gesture/pivot v0 nil) (assoc-in (:clip (nest/peg (shaped) nil :main [:shape] 0 pg))
spun (gesture/apply-values out :main pg 0 (gesture/turn v0 piv 0.5)) [:symbols :main :nodes pg :channels [:xform :pos]]
radii (fn [doc] (map (fn [[x y]] (js/Math.hypot (- x 10) (- y 90))) (ch/keyed {0 [0 0] 5 [40 10]} :linear))
(partition 2 (drawn-at doc 0))))] [pg] pg]]]
(is (near? [10 90] piv) "the derived pivot followed it") (let [was (drawn-at c 0)
(is (not (near? (drawn-at out 0) (drawn-at spun 0))) "the child turned") pl (nest/placement c nil :main path 0)
(is (near? (vec (radii out)) (vec (radii spun))) v0 (gesture/values (get-in c [:symbols :main :nodes target]) 0 nil)
"about the pivot's new home, keeping every distance from it")))) out (gesture/apply-values c :main target 0 (gesture/repivot v0 [10 90]))
v1 (gesture/values (get-in out [:symbols :main :nodes target]) 0 nil)]
(is (some? pl))
(is (near? [10 90] (gesture/pivot v1 nil))
(str what ": the pivot is not where it was put"))
(is (near? was (drawn-at out 0))
(str what ": the picture moved"))
(is (empty? (clip/problems out)))
;; And it is a REAL pivot afterwards: a turn about it keeps it fixed, and
;; keeps it fixed by writing `rot` and nothing else.
(let [spun (gesture/apply-values out :main target 0 (gesture/turn v1 nil 0.5))
radii (fn [doc] (map (fn [[x y]] (js/Math.hypot (- x 10) (- y 90)))
(partition 2 (drawn-at doc 0))))]
(is (= #{[:xform :rot]} (set (keys (gesture/turn v1 nil 0.5))))
(str what ": a turn about a chosen pivot writes more than the rotation"))
(is (not (near? (drawn-at out 0) (drawn-at spun 0))) (str what ": nothing turned"))
(is (near? (vec (radii out)) (vec (radii spun)))
(str what ": the turn was not about the pivot"))))))
(deftest a-pegs-pivot-cannot-be-moved-once-its-position-is-animated (deftest a-pivot-is-a-choice-and-editing-the-symbol-does-not-revise-it
;; Refused rather than quietly wrong: the compensation depends on the peg's own ;; THE RULE, and the reason the pivot is stored rather than derived per drag:
;; transform, so a keyed position needs a different `pinv` per frame and one ;; once something has been animated, adding to what it draws must not alter
;; stored matrix is not it. ;; what it turns about. A derived pivot followed the drawing, so drawing one
(let [c (:clip (nest/peg (shaped) nil :main [:shape] 0 pg)) ;; more shape inside a symbol silently moved every keyed spin of every instance
keyed (assoc-in c [:symbols :main :nodes pg :channels [:xform :pos]] ;; of it.
(ch/keyed {0 [10 10] 5 [40 10]} :linear)) (let [c (:clip (nest/peg (shaped) nil :main [:shape] 0 pg))
r (nest/repivot keyed nil :main [pg] 0 [10 90])] v0 (gesture/values (get-in c [:symbols :main :nodes pg]) 0 nil)
(is (string? (:refused r))) c (gesture/apply-values c :main pg 0 (gesture/repivot v0 [10 90]))
(is (re-find #"animated" (:refused r)) (:refused r)) ;; another shape in the same symbol, well away from the first
(is (re-find #"peg over it" (:refused r)) (:refused r))) grown (assoc-in c [:symbols :main :nodes :far]
(testing "and a node with nothing under it has no pivot to move" {:id :far :kind :poly :z "z9" :parent pg
(let [r (nest/repivot (shaped) nil :main [:shape] 0 [10 90])] :channels {[:geom :pts] (ch/framed [200 10 220 10 210 30])
(is (re-find #"nothing hangs off it" (:refused r)) (:refused r))))) [:style :color] (ch/framed :brow)}})
piv #(gesture/pivot (gesture/values (get-in % [:symbols :main :nodes pg]) 0 nil)
((pick/bounds-of % nil :main (get-in % [:symbols :main :nodes pg])) 0))]
(is (near? [10 90] (piv c)))
(is (near? [10 90] (piv grown))
"the pivot moved because something else was drawn, which is the bug")))

View file

@ -14,9 +14,9 @@
(defn- close? [a b] (< (js/Math.abs (- a b)) 1e-12)) (defn- close? [a b] (< (js/Math.abs (- a b)) 1e-12))
(defn- close-pt? [[ax ay] [bx by]] (and (close? ax bx) (close? ay by))) (defn- close-pt? [[ax ay] [bx by]] (and (close? ax bx) (close? ay by)))
(defn- local [& {:keys [pos rot scale skew] (defn- local [& {:keys [pos pivot rot scale skew]
:or {pos [0 0] rot 0 scale [1 1] skew [0 0]}}] :or {pos [0 0] pivot [0 0] rot 0 scale [1 1] skew [0 0]}}]
(node/local! (node/mat) pos rot scale skew)) (node/local! (node/mat) pos pivot rot scale skew))
;; ---- the transform, component by component ---- ;; ---- the transform, component by component ----
@ -28,19 +28,38 @@
(is (close-pt? [0 10] (pt (local :rot (/ js/Math.PI 2)) 10 0))) (is (close-pt? [0 10] (pt (local :rot (/ js/Math.PI 2)) 10 0)))
(is (= [20 21] (pt (local :scale [2 3]) 10 7)))) (is (= [20 21] (pt (local :scale [2 3]) 10 7))))
(deftest rotation-and-scale-happen-about-the-nodes-own-origin (deftest rotation-and-scale-happen-about-the-nodes-pivot
;; THERE IS NO :anchor, and this is the half of that which lives here: a local ;; With no pivot, the node's own origin:
;; transform turns and scales about [0 0] of the node's own space and nothing
;; else. Turning about any other point is `gesture/about`, which solves for the
;; `pos` that holds that point still — see `gesture-test`. The two together are
;; what the anchor used to be, with no stored pivot to fall out of step with the
;; drawing.
(let [m (local :rot (/ js/Math.PI 2))] (let [m (local :rot (/ js/Math.PI 2))]
(is (close-pt? [0 0] (pt m 0 0)) "the origin is the fixed point") (is (close-pt? [0 0] (pt m 0 0)) "the origin is the fixed point")
(is (close-pt? [0 10] (pt m 10 0)) "and the rest turns about it")) (is (close-pt? [0 10] (pt m 10 0)) "and the rest turns about it"))
(let [m (local :scale [2 2])] (let [m (local :scale [2 2])]
(is (close-pt? [0 0] (pt m 0 0))) (is (close-pt? [0 0] (pt m 0 0)))
(is (close-pt? [20 20] (pt m 10 10))))) (is (close-pt? [20 20] (pt m 10 10))))
;; And with one, that point, whatever else changes. THE WHOLE POINT OF THE
;; FIELD: the pivot is the fixed point of the composition for EVERY angle and
;; every scale, so an interpolated angle has an interpolated matrix that still
;; holds it — which is what a `pos` solved per frame cannot do between two keys.
(let [a [11 -6]]
(doseq [t (map #(* % (/ js/Math.PI 7)) (range 14))]
(is (close-pt? a (pt (local :pivot a :rot t) (nth a 0) (nth a 1)))
(str "the pivot moved at " t " radians")))
(doseq [k [0.25 1 2.5 9]]
(is (close-pt? a (pt (local :pivot a :scale [k k]) (nth a 0) (nth a 1)))
(str "the pivot moved at scale " k))
(is (close-pt? a (pt (local :pivot a :scale [k (/ 1 k)] :rot 0.3) (nth a 0) (nth a 1)))
"and under an uneven scale and a turn at once"))
(testing "a pivot turns what is around it about itself"
(is (close-pt? [11 4] (pt (local :pivot a :rot (/ js/Math.PI 2)) 21 -6))
"10 to the right of the pivot comes to 10 below it"))))
(deftest a-pivot-of-zero-composes-exactly-as-no-pivot-at-all
;; What makes schema 8 a conversion rather than a refusal: every schema-7 node
;; has no pivot, an absent one reads as [0 0], and T(pos)·T(0)·M·T(-0) is
;; T(pos)·M to the last bit — so no stored document moves by a float.
(let [with (local :pos [7 -3] :pivot [0 0] :rot 0.9 :scale [1.4 0.6] :skew [0.25 -0.1])
without (local :pos [7 -3] :rot 0.9 :scale [1.4 0.6] :skew [0.25 -0.1])]
(is (= (vec (array-seq without)) (vec (array-seq with))))))
(deftest skew-is-shear-factors-so-the-identity-is-zero (deftest skew-is-shear-factors-so-the-identity-is-zero
;; Stored as factors rather than angles: kx is x gained per unit y, so a ;; Stored as factors rather than angles: kx is x gained per unit y, so a
@ -51,13 +70,13 @@
(is (= [5 12] (pt (local :skew [0 1]) 5 7)) "ky adds x into y")) (is (= [5 12] (pt (local :skew [0 1]) 5 7)) "ky adds x into y"))
(deftest the-composition-order-is-the-one-the-model-specifies (deftest the-composition-order-is-the-one-the-model-specifies
;; local = T(pos) · R(rot) · K(skew) · S(scale) ;; local = T(pos) · T(piv) · R(rot) · K(skew) · S(scale) · T(-piv)
;; ;;
;; Asserted against the product of the four matrices built separately, so the ;; Asserted against the product of the four matrices built separately, so the
;; closed form in node/local! is checked rather than trusted. Every other order ;; closed form in node/local! is checked rather than trusted. Every other order
;; produces a transform that is right at the origin and wrong everywhere else, ;; produces a transform that is right at the origin and wrong everywhere else,
;; which is exactly the kind of wrong that survives inspection. ;; which is exactly the kind of wrong that survives inspection.
(let [pos [3 -4] rot 0.7 scale [1.5 0.5] skew [0.25 -0.1] (let [pos [3 -4] piv [6 2] rot 0.7 scale [1.5 0.5] skew [0.25 -0.1]
T (fn [x y] (js/Float64Array. #js [1 0 0 1 x y])) T (fn [x y] (js/Float64Array. #js [1 0 0 1 x y]))
R (fn [t] (js/Float64Array. #js [(js/Math.cos t) (js/Math.sin t) R (fn [t] (js/Float64Array. #js [(js/Math.cos t) (js/Math.sin t)
(- (js/Math.sin t)) (js/Math.cos t) 0 0])) (- (js/Math.sin t)) (js/Math.cos t) 0 0]))
@ -65,20 +84,27 @@
S (fn [[sx sy]] (js/Float64Array. #js [sx 0 0 sy 0 0])) S (fn [[sx sy]] (js/Float64Array. #js [sx 0 0 sy 0 0]))
step (fn [acc m] (node/mul! (node/mat) acc m)) step (fn [acc m] (node/mul! (node/mat) acc m))
want (reduce step (T (nth pos 0) (nth pos 1)) want (reduce step (T (nth pos 0) (nth pos 1))
[(R rot) (K skew) (S scale)]) [(T (nth piv 0) (nth piv 1)) (R rot) (K skew) (S scale)
got (local :pos pos :rot rot :scale scale :skew skew)] (T (- (nth piv 0)) (- (nth piv 1)))])
got (local :pos pos :pivot piv :rot rot :scale scale :skew skew)]
(is (every? (fn [i] (close? (aget want i) (aget got i))) (range 6)) (is (every? (fn [i] (close? (aget want i) (aget got i))) (range 6))
(str (vec (array-seq want)) " vs " (vec (array-seq got)))))) (str (vec (array-seq want)) " vs " (vec (array-seq got))))))
(deftest an-anchor-is-a-peg-written-inline (deftest a-pivot-is-a-peg-written-inline
;; The identity that makes the deletion safe rather than a trade: a node with ;; The identity that was read as an argument for having no pivot at all: a node
;; anchor `a` is exactly a peg at `pos + a` carrying the rotation and scale, ;; with pivot `a` is exactly a peg at `pos + a` carrying the rotation and scale,
;; parenting a child offset by `-a`. Same matrix, to the last bit of the ;; parenting a child offset by `-a`. Same matrix, to the last bit of the
;; mantissa — so everything the anchor could express, a parent already could, ;; mantissa — and still true, which is why a peg remains the way to SHARE a
;; and the parent can also be keyed, shared, and put over a measured channel. ;; pivot between nodes or put one over a measured transform.
;;
;; WHAT IT IS NOT AN ARGUMENT FOR is deleting the field, and that is the part
;; that cost a year of wrong spins. The equivalence is between a pivot and a
;; peg SOMEBODY HAS ALREADY MADE; it says nothing about what a node without one
;; does when you turn it, which is turn about its own origin — the corner of
;; the stage, for a symbol instance. `gesture-test` has the keyed case.
(let [pos [7 -3] rot 0.9 scale [1.4 0.6] a [11 -6] (let [pos [7 -3] rot 0.9 scale [1.4 0.6] a [11 -6]
;; what T(pos)·T(a)·R·K·S·T(-a) used to produce, built from the parts ;; T(pos)·T(a)·R·K·S·T(-a), built from the parts
anchored (reduce (fn [acc m] (node/mul! (node/mat) acc m)) conjugated (reduce (fn [acc m] (node/mul! (node/mat) acc m))
(js/Float64Array. #js [1 0 0 1 (nth pos 0) (nth pos 1)]) (js/Float64Array. #js [1 0 0 1 (nth pos 0) (nth pos 1)])
[(js/Float64Array. #js [1 0 0 1 (nth a 0) (nth a 1)]) [(js/Float64Array. #js [1 0 0 1 (nth a 0) (nth a 1)])
(local :rot rot :scale scale) (local :rot rot :scale scale)
@ -86,9 +112,13 @@
;; the same thing as a peg and a child ;; the same thing as a peg and a child
peg (local :pos (mapv + pos a) :rot rot :scale scale) peg (local :pos (mapv + pos a) :rot rot :scale scale)
child (local :pos (mapv - a)) child (local :pos (mapv - a))
world (node/world! (node/mat) peg nil child (node/mat))] world (node/world! (node/mat) peg nil child (node/mat))
(is (every? (fn [i] (close? (aget anchored i) (aget world i))) (range 6)) ;; and the field itself, which is the same matrix in one node
(str (vec (array-seq anchored)) " vs " (vec (array-seq world)))))) pivoted (local :pos pos :pivot a :rot rot :scale scale)]
(is (every? (fn [i] (close? (aget conjugated i) (aget world i))) (range 6))
(str (vec (array-seq conjugated)) " vs " (vec (array-seq world))))
(is (every? (fn [i] (close? (aget conjugated i) (aget pivoted i))) (range 6))
(str (vec (array-seq conjugated)) " vs " (vec (array-seq pivoted))))))
(deftest mul-may-write-into-either-operand (deftest mul-may-write-into-either-operand
;; Evaluation composes world := parent · local with dest aliasing local, so ;; Evaluation composes world := parent · local with dest aliasing local, so
@ -196,12 +226,17 @@
(doseq [k (disj node/implemented-kinds :audio)] (doseq [k (disj node/implemented-kinds :audio)]
(is (contains? (get node/valid-paths k) [:xform :skew]) (str k)))) (is (contains? (get node/valid-paths k) [:xform :skew]) (str k))))
(deftest there-is-no-anchor-anywhere-in-the-shape (deftest the-pivot-is-in-the-shape-and-the-old-name-for-it-is-not
;; The deletion, asserted rather than assumed. A document carrying one is not ;; The field is back, under the name the model uses for it everywhere else, and
;; migrated, it is invalid — `problems` rejects the channel on every kind — so ;; the schema-6 name is still refused — the composition around it CHANGED, from
;; there is no shape in which a stale stored pivot can come back. ;; T(pos)·M·T(-a) to T(pos)·T(a)·M·T(-a), so reading one as the other would
(doseq [k node/implemented-kinds] ;; move any node that had been turned or scaled.
(doseq [k (disj node/implemented-kinds :audio)]
(is (contains? (get node/valid-paths k) [:xform :pivot]) (str k))
(is (not (contains? (get node/valid-paths k) [:xform :anchor])) (str k))) (is (not (contains? (get node/valid-paths k) [:xform :anchor])) (str k)))
(is (contains? (set node/xform-paths) [:xform :pivot]))
(is (= [0.0 0.0] (ch/value-at (get node/defaults [:xform :pivot]) 0 nil))
"and its default is the node's own origin")
(is (not (contains? (set node/xform-paths) [:xform :anchor]))) (is (not (contains? (set node/xform-paths) [:xform :anchor])))
(is (not (contains? node/defaults [:xform :anchor]))) (is (not (contains? node/defaults [:xform :anchor])))
(let [ps (node/problems {:id :x :kind :poly :z "a1" (let [ps (node/problems {:id :x :kind :poly :z "a1"
@ -211,7 +246,7 @@
;; `:trace` — every schema-6 drawing and placement carried an anchor, so this ;; `:trace` — every schema-6 drawing and placement carried an anchor, so this
;; is the first thing a person opening an old project sees, and "not valid on ;; is the first thing a person opening an old project sees, and "not valid on
;; a :poly node" would tell them nothing. ;; a :poly node" would tell them nothing.
(is (some #(re-find #"peg" %) ps) (str "no named refusal: " (pr-str ps))) (is (some #(re-find #"pivot" %) ps) (str "no named refusal: " (pr-str ps)))
(is (not-any? #(re-find #"is not valid on a" %) ps) (is (not-any? #(re-find #"is not valid on a" %) ps)
(str "the generic path complaint should not also fire: " (pr-str ps))))) (str "the generic path complaint should not also fire: " (pr-str ps)))))

View file

@ -5,6 +5,7 @@
[arthur.demo.take :as take] [arthur.demo.take :as take]
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.creation :as creation] [arthur.domain.creation :as creation]
[arthur.domain.gesture :as gesture]
[arthur.domain.leaf :as leaf] [arthur.domain.leaf :as leaf]
[arthur.domain.nest :as nest] [arthur.domain.nest :as nest]
[arthur.domain.node :as node] [arthur.domain.node :as node]
@ -179,16 +180,20 @@
(defn- middle-on-stage (defn- middle-on-stage
"Where the picture's own middle lands, for a tracing placement `n`. "Where the picture's own middle lands, for a tracing placement `n`.
`pos + scale · middle`, because a trace's own coordinates are its pixels and Through the composition itself — `gesture/local-of` on the node's own channels,
the scaled middle moves with the scale. This used to be `pos + anchor`: with which is `node/local!` and therefore whatever the model says the transform is —
the anchor ON the middle, `T(a)·S(k)·T(-a)` left that point alone whatever `k` rather than through `pos + scale · middle` written out here. A trace's own
was, so the sum read correctly without the scale appearing in it at all. That coordinates are its pixels, so the middle is half its size, and where that
is the work `events/ui`'s `fitted` now does explicitly." lands is the only question. Hand-composing it is how this test came to assert a
composition of its own: it read `pos + scale·middle`, which is right only for a
node with no pivot, and the placement has one — on the middle, which is exactly
the point `fitted` no longer has to solve for."
[doc n] [doc n]
(let [{:keys [width height]} (clip/symbol doc (node/source n))] (let [{:keys [width height]} (clip/symbol doc (node/source n))
(mapv + (get-in n [:channels [:xform :pos] :value]) m (gesture/local-of (gesture/values n 0 nil))
(mapv * (get-in n [:channels [:xform :scale] :value]) out (js/Float64Array. 2)]
[(/ width 2) (/ height 2)])))) (node/apply-pt! out 0 m (/ width 2) (/ height 2))
[(aget out 0) (aget out 1)]))
(deftest a-dropped-still-lasts-the-rest-of-the-symbol-fits-it-and-is-reused (deftest a-dropped-still-lasts-the-rest-of-the-symbol-fits-it-and-is-reused
(let [id (store/install! {:clip (clip/blank) :store {}} "drop-tracing") (let [id (store/install! {:clip (clip/blank) :store {}} "drop-tracing")

View file

@ -135,12 +135,12 @@ try {
const dom = await evalJS(` const dom = await evalJS(`
const q = s => document.querySelector(s); const q = s => document.querySelector(s);
const g = q('.paint-overlay .handles'); const g = q('.paint-overlay .handles');
const grab = q('.paint-overlay .handles .peg-grab'); const grab = q('.paint-overlay .handles .pivot-grab');
const r = grab && grab.getBoundingClientRect(); const r = grab && grab.getBoundingClientRect();
return { return {
handles: !!g, handles: !!g,
pegGrab: !!grab, pivotGrab: !!grab,
pegGrabEvents: grab ? getComputedStyle(grab).pointerEvents : null, pivotGrabEvents: grab ? getComputedStyle(grab).pointerEvents : null,
knobs: document.querySelectorAll('.paint-overlay .handles .knob').length, knobs: document.querySelectorAll('.paint-overlay .handles .knob').length,
corners: document.querySelectorAll('.paint-overlay .handles .corner').length, corners: document.querySelectorAll('.paint-overlay .handles .corner').length,
boxes: document.querySelectorAll('.paint-overlay .handles .box').length, boxes: document.querySelectorAll('.paint-overlay .handles .box').length,
@ -149,8 +149,8 @@ try {
`); `);
console.log('dom:', JSON.stringify(dom)); console.log('dom:', JSON.stringify(dom));
ok(dom.handles, 'the stage draws handles for the selected peg'); ok(dom.handles, 'the stage draws handles for the selected peg');
ok(dom.pegGrab, 'there is a .peg-grab to drag it by'); ok(dom.pivotGrab, 'there is a .pivot-grab to drag it by');
ok(dom.pegGrabEvents && dom.pegGrabEvents !== 'none', 'and it accepts pointer events', String(dom.pegGrabEvents)); ok(dom.pivotGrabEvents && dom.pivotGrabEvents !== 'none', 'and it accepts pointer events', String(dom.pivotGrabEvents));
ok(dom.knobs === 1, 'a turn knob', String(dom.knobs)); ok(dom.knobs === 1, 'a turn knob', String(dom.knobs));
ok(dom.corners === 4, 'four scale corners', String(dom.corners)); ok(dom.corners === 4, 'four scale corners', String(dom.corners));
ok(dom.boxes === 0, 'and no box, because a peg draws nothing', String(dom.boxes)); ok(dom.boxes === 0, 'and no box, because a peg draws nothing', String(dom.boxes));
@ -185,7 +185,7 @@ try {
} }
// ⌥-drag the cross: the PIVOT moves and the child must not. // ⌥-drag the cross: the PIVOT moves and the child must not.
const dom2 = await evalJS(` const dom2 = await evalJS(`
const q = document.querySelector('.paint-overlay .handles .peg-grab'); const q = document.querySelector('.paint-overlay .handles .pivot-grab');
const r = q && q.getBoundingClientRect(); const r = q && q.getBoundingClientRect();
const corner = document.querySelector('.paint-overlay .handles .corner'); const corner = document.querySelector('.paint-overlay .handles .corner');
const cr = corner && corner.getBoundingClientRect(); const cr = corner && corner.getBoundingClientRect();
@ -218,11 +218,12 @@ try {
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')), const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null); arthur.domain.palette.index_of, null);
return {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))), return {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value'))))}; pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value')))),
piv: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pivot')), k('value'))) ?? v(0,0))};
`); `);
// Where the cross is NOW, since the previous modifier already moved it. // Where the cross is NOW, since the previous modifier already moved it.
const g = await evalJS(` const g = await evalJS(`
const q = document.querySelector('.paint-overlay .handles .peg-grab'); const q = document.querySelector('.paint-overlay .handles .pivot-grab');
const r = q && q.getBoundingClientRect(); const r = q && q.getBoundingClientRect();
return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null; return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null;
`); `);
@ -246,10 +247,19 @@ try {
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')), const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null); arthur.domain.palette.index_of, null);
return {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))), return {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value'))))}; pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value')))),
piv: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pivot')), k('value'))) ?? v(0,0))};
`); `);
const drift = Math.max(...pre.pts.map((b, i) => Math.abs(b - post.pts[i]))); const drift = Math.max(...pre.pts.map((b, i) => Math.abs(b - post.pts[i])));
const moved = Math.hypot(post.pos[0] - pre.pos[0], post.pos[1] - pre.pos[1]); // WHERE THE PIVOT IS, which is `pos + pivot` — the node's own pivot point.
// This used to read the peg's `pos` alone, because a repivot moved a peg's
// ORIGIN and rewrote each child's `:pinv` to compensate. Now every node
// has an `[:xform :pivot]` and the conjugated composition holds the
// picture still by itself, so on a peg with no rotation or scale the write
// is the pivot and `pos` does not move at all.
const where = p => [p.pos[0] + p.piv[0], p.pos[1] + p.piv[1]];
const [ax, ay] = where(pre), [bx, by] = where(post);
const moved = Math.hypot(bx - ax, by - ay);
ok(drift < 1e-6, `${name}-drag places the pivot and moves NOTHING under the peg`, ok(drift < 1e-6, `${name}-drag places the pivot and moves NOTHING under the peg`,
`drift ${drift}`); `drift ${drift}`);
ok(moved > 2, `${name}-drag actually moved the pivot`, `by ${moved.toFixed(2)}`); ok(moved > 2, `${name}-drag actually moved the pivot`, `by ${moved.toFixed(2)}`);
@ -258,7 +268,7 @@ try {
// And a plain drag still translates, which is the other half of the split. // And a plain drag still translates, which is the other half of the split.
{ {
const g = await evalJS(` const g = await evalJS(`
const q = document.querySelector('.paint-overlay .handles .peg-grab'); const q = document.querySelector('.paint-overlay .handles .pivot-grab');
const r = q && q.getBoundingClientRect(); const r = q && q.getBoundingClientRect();
return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null; return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null;
`); `);

View file

@ -1425,9 +1425,11 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.paint-overlay .handles .knob { fill: #161820; stroke: #fff1be; stroke-width: 0.6; cursor: grab; } .paint-overlay .handles .knob { fill: #161820; stroke: #fff1be; stroke-width: 0.6; cursor: grab; }
.paint-overlay .handles .corner { fill: #fff1be; stroke: #161820; stroke-width: 0.5; cursor: nwse-resize; } .paint-overlay .handles .corner { fill: #fff1be; stroke: #161820; stroke-width: 0.5; cursor: nwse-resize; }
.paint-overlay .handles .pivot { stroke: #fff1be; stroke-width: 0.6; pointer-events: none; } .paint-overlay .handles .pivot { stroke: #fff1be; stroke-width: 0.6; pointer-events: none; }
/* A peg draws nothing, so the cross is the only thing to grab it by. */ /* The cross is a handle on every node: a plain drag moves it, ctrl/cmd puts the
.paint-overlay .handles .peg-grab { fill: transparent; stroke: none; cursor: move; } pivot somewhere else. On a peg, which draws nothing, it is the only thing
/* While an alt-drag is placing it. */ there is to grab. */
.paint-overlay .handles .pivot-grab { fill: transparent; stroke: none; cursor: move; }
/* While a ctrl/cmd-drag is placing it. */
.paint-overlay .handles .pivot.moving { stroke: #7ad7a0; stroke-width: 0.9; } .paint-overlay .handles .pivot.moving { stroke: #7ad7a0; stroke-width: 0.9; }
.paint-overlay .marquee { fill: rgba(230, 202, 139, 0.12); stroke: #e6ca8b; stroke-width: 0.6; stroke-dasharray: 2 1; pointer-events: none; } .paint-overlay .marquee { fill: rgba(230, 202, 139, 0.12); stroke: #e6ca8b; stroke-width: 0.6; stroke-dasharray: 2 1; pointer-events: none; }