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:
parent
e7f5f82845
commit
ddef5c6bfd
23 changed files with 983 additions and 565 deletions
45
clips/migrations/0017_a_node_has_a_pivot.py
Normal file
45
clips/migrations/0017_a_node_has_a_pivot.py
Normal 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),
|
||||
]
|
||||
|
|
@ -250,7 +250,7 @@ class Project(models.Model):
|
|||
settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects",
|
||||
)
|
||||
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)
|
||||
palette = models.CharField(max_length=64, default="arthur/default")
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
|
|
|||
|
|
@ -333,7 +333,7 @@ entries is meaningless — a rotation tweened through its matrix shears on the w
|
|||
Composition, per node:
|
||||
|
||||
```
|
||||
local = T(pos) · R(rot) · K(skew) · S(scale)
|
||||
local = T(pos) · T(piv) · R(rot) · K(skew) · S(scale) · T(-piv)
|
||||
world = world(parent) · pinv · local
|
||||
```
|
||||
|
||||
|
|
@ -341,99 +341,132 @@ world = world(parent) · pinv · local
|
|||
child does not jump when it acquires a parent. Small, and its absence is the kind
|
||||
of thing that makes a parenting feature feel broken.
|
||||
|
||||
### There is no `:anchor`, because an anchor is a peg
|
||||
### A node has a `:pivot`, and a peg is still a peg
|
||||
|
||||
Rotation and scale happen about the node's **own origin**. There is no
|
||||
registration point in the decomposition, and that is a deletion rather than a
|
||||
gap, because
|
||||
Rotation and scale happen about the node's **pivot**, `[:xform :pivot]`, a point
|
||||
in its own coordinates:
|
||||
|
||||
```
|
||||
local = T(pos) · T(piv) · R(rot) · K(skew) · S(scale) · T(-piv)
|
||||
= T(pos + piv - M·piv) · M
|
||||
```
|
||||
|
||||
Toon Boom gives every layer and every peg a pivot, Flash gives every instance a
|
||||
transformation point, After Effects calls it the anchor point. All three store
|
||||
it, and the reason is one sentence: **a turn has to be a turn on every frame**,
|
||||
and the only way to keep a point still through an interpolated angle is for the
|
||||
angle to be composed about that point.
|
||||
|
||||
This was deleted in schema 7 and restored in schema 8, and the argument for
|
||||
deleting it was *not wrong*, which is why it is worth writing down. It was:
|
||||
|
||||
```
|
||||
T(pos) · T(a) · R·K·S · T(-a) ≡ peg at pos+a carrying R·K·S, child at -a
|
||||
```
|
||||
|
||||
to the last bit of the mantissa — `node-test` asserts it. `T(a)·M·T(-a)` is `M`
|
||||
conjugated by a translation, which is "do `M` in a frame shifted by `a`", and a
|
||||
**parent already is a shifted frame**. So an anchor was a peg that could not be
|
||||
selected, could not be keyed, could not be shared between nodes, and could not be
|
||||
put above a measured channel. Same expressive content, strictly less reach.
|
||||
to the last bit of the mantissa — `node-test` asserts it, still. `T(a)·M·T(-a)`
|
||||
is `M` conjugated by a translation, which is "do `M` in a frame shifted by `a`",
|
||||
and a **parent already is a shifted frame**. So an anchor was a peg written
|
||||
inline, and a peg can be selected, keyed, shared between nodes and put above a
|
||||
measured channel. Same expressive content, strictly more reach.
|
||||
|
||||
What it did, two mechanisms now do, split along who owns the pivot:
|
||||
|
||||
**A pivot nobody chose is derived per drag and never stored.** `gesture/pivot` is
|
||||
the middle of what the node draws — `pick/bounds-of`, the same call the stage
|
||||
draws the selection box from, on the same frame — or the node's own origin when it
|
||||
draws nothing. `gesture/about` then solves for the position that holds that point
|
||||
still:
|
||||
**What that identity does not say is what a node turns about when nobody has
|
||||
made a peg.** It is an equivalence between a pivot and a peg *that already
|
||||
exists*; it is silent on the default, and the default is what a person meets.
|
||||
With no pivot in the composition, a turn about any point that is not the node's
|
||||
own origin has to be paid for by writing `pos` as well — `gesture/about` solves
|
||||
for it:
|
||||
|
||||
```
|
||||
q = M⁻¹(c − p) the material point under c
|
||||
p' = c − M'·q = c − M'·M⁻¹(c − p)
|
||||
q = M⁻¹(c − t) the material point under c
|
||||
p' = c − M'·q
|
||||
```
|
||||
|
||||
so a turn about a point that is **not** the node's origin writes `pos` as well as
|
||||
`rot`. Nothing is cached, so nothing can go stale: the stored anchor was the
|
||||
centre of what the node drew, captured once at creation, while the box beside it
|
||||
was recomputed every render — so on anything edited since it was made, the cross
|
||||
and the box visibly disagreed and the pivot was wrong. A symbol with more than one
|
||||
node diverged on the first edit.
|
||||
|
||||
**And a drawing's origin is the middle of what it draws**, from the moment it is
|
||||
drawn — `paint/centred`, which splits a stroke into a ring about its own middle
|
||||
and the `pos` that puts it back. This is what keeps the paragraph above from being
|
||||
the whole story, because the `pos` that `about` solves for is an **arc** in the
|
||||
angle and `pos` interpolates along the **chord**:
|
||||
and that solution is an **arc** in the angle while `pos` interpolates along the
|
||||
**chord**:
|
||||
|
||||
| | pivot = origin | pivot ≠ origin |
|
||||
| --- | --- | --- |
|
||||
| one drag | right | right |
|
||||
| between two keys | right | **wrong**, by the sagitta of the arc |
|
||||
|
||||
A 360° turn is where that is unmissable and was first seen: 0° and 360° are the
|
||||
only two frames where a wrong pivot cannot be seen at all, so the keys looked
|
||||
right and every frame between them was wrong — a shape keyed bottom-left to
|
||||
top-centre with one full turn on the way left the stage completely in the middle
|
||||
of the spin, orbiting its origin at a radius of 126 px on a 320×200 stage, because
|
||||
a stroke used to be stored exactly as drawn and its origin was therefore the
|
||||
**symbol's** origin, the top-left corner of the stage.
|
||||
A 360° turn is where that is unmissable: 0° and 360° are the only two frames
|
||||
where a wrong pivot cannot be seen at all, so the keys look right and every
|
||||
frame between them is wrong.
|
||||
|
||||
With the origin on the content there is nothing to solve: `gesture/at-origin?`
|
||||
holds, `turn` writes `rot` alone, `scale` writes `scale` alone, and a keyed turn is
|
||||
right on every frame. `about` is then needed only where the pivot genuinely is not
|
||||
any node's origin — a multi-selection about its shared box, a measured part, or a
|
||||
drawing whose points have been edited away from their own middle — and in each of
|
||||
those a pivot that has to be **keyed** is a peg, below. Hand-authored scenes were
|
||||
always written this way: `demo/scene.edn`'s card is
|
||||
`[-44 -30 44 -30 44 30 -44 30]` with its place in `pos`.
|
||||
**A drawing escaped it. A symbol instance could not.** `paint/centred` puts a
|
||||
shape's origin on the middle of what it draws the moment it is drawn, so for a
|
||||
drawing the pivot *is* the origin, `about` has nothing to do, and a keyed turn is
|
||||
right between its keys. An instance's origin is its **symbol's**, and a symbol is
|
||||
drawn on the stage, so its origin is the stage's top-left corner. Measured from
|
||||
the document this was reported on: a symbol holding six drawn shapes had its
|
||||
content centred at (99, 127), 161 px from its own origin, on a 320×200 stage. One
|
||||
instance of it, keyed `rot` 0 → 60 and dragged round by hand, put the drawing at
|
||||
(115, 116) on frame 0 and (241, 104) on frame 60 — both where they were put — and
|
||||
at (−88, 121) on frame 30, a stage and a half from either. The answer on offer
|
||||
was "make a peg first", for wanting to spin a drawing.
|
||||
|
||||
**A pivot somebody chose is a peg** — an ordinary `:group` parent, `nest/peg`,
|
||||
with `:pinv` captured so nothing moves when it appears. Toon Boom's peg, Fusion's
|
||||
separate Transform node, Harmony's peg-over-the-drawing. It is the answer to the
|
||||
three things a derived pivot cannot do:
|
||||
So the pivot is back, with the default and the escape hatch spelled out, because
|
||||
a stored pivot without either is the field that was deleted:
|
||||
|
||||
| want | why a derived pivot cannot | what the peg does |
|
||||
| | what | where |
|
||||
| --- | --- | --- |
|
||||
| a pivot that persists — an arm turning about its shoulder | a gesture's pivot is the middle of the drawing and lives for one drag, and the drawing's own origin cannot be moved there without moving its points out from under everything that reads them | the peg's `pos`, static, nowhere near the middle |
|
||||
| a pivot that travels — a foot roll | an anchor could only be keyed against `pos`, interpolated in the same breath, the two obliged to agree frame for frame | the peg's `pos` is an ordinary channel, so key it |
|
||||
| a hand transform over a **measured** one | impossible: `local`'s translation is `pos − M·a`, and under a measured `M` writing `a` moves the thing it was meant to leave alone | the peg's channels are its own, so the hand transform composes outside the measurement, which stays regenerable |
|
||||
| **the default, for a node nobody has pivoted** | the middle of what it draws — `pick/bounds-of`, the same call the selection box comes from, so the cross starts out on the middle of the box | `gesture/pivot` |
|
||||
| **choosing it, invisibly** | the first turn or scale writes that middle down, in the same edit, with the `pos` that holds the picture still | `gesture/with-pivot` |
|
||||
| **choosing it, by hand** | ⌃/⌘-drag the cross on the stage: the pivot goes under the pointer and nothing moves | `gesture/repivot`, `::ui/repivot` |
|
||||
| **a placement** | `clip/place-symbol` stores the middle of what the symbol draws as the instance's pivot, so an instance turns about its drawing from the moment it is dropped | `clip/place-symbol` |
|
||||
| **putting it back** | ⌖ beside the pivot row in the inspector: back to the middle of what the node draws *now*, moving nothing | `gesture/centred`, `::ui/centre-pivot` |
|
||||
|
||||
That last row is why the rotoscoped parts were worst. `flow/freeze` used to run a
|
||||
`pivoted` pass writing a default anchor onto everything it had made, and it
|
||||
**skipped every `node/measured?` node** — correctly, for the reason in the table.
|
||||
So the traced mouth, lids and brows got no pivot at all and turned about the
|
||||
origin of head-local space, which is the top-left corner of the *footage*: on a
|
||||
320×200 stage the mouth pivoted about (−234, −395), off the stage by more than a
|
||||
stage. The pass is gone; there is no node a derived pivot can be missing from.
|
||||
**A pivot is a choice, and does not follow the drawing.** Once it is the node's
|
||||
own, the derived middle is never consulted for it again. This is the half the
|
||||
old stored anchor got right and the derived pivot got wrong: adding a shape
|
||||
inside a symbol must not re-aim every keyed spin of every instance of it, and a
|
||||
pivot that tracked the content did exactly that, silently, with nothing changing
|
||||
on screen at the moment it happened. The cross is visible and draggable and ⌖
|
||||
puts it back, which is what the anchor was missing — it was never the storing
|
||||
that was wrong.
|
||||
|
||||
A peg is also how `demo/stage` places its seven faces, and that is the case that
|
||||
makes the pair necessary rather than tidy: `:scale` is **keyed** — the faces pulse
|
||||
— and the source's middle has to stay on its authored centre throughout. A static
|
||||
`pos` cannot do it alone, since `T(pos)·S(k(f))` moves that point whenever `k`
|
||||
changes. `T(center)·S(k(f))·T(-origin)` does, for every `k`, with nothing keyed
|
||||
that was not keyed before.
|
||||
**A peg is an ordinary `:group` parent, `nest/peg`, with `:pinv` captured so
|
||||
nothing moves when it appears.** It is no longer the answer to "this turns about
|
||||
the wrong point", and it is still the answer to three things a node's own pivot
|
||||
is not:
|
||||
|
||||
`gesture/refusal` still turns a hand edit on a measured channel away, because the
|
||||
next regenerate would discard it — but it can now name a way through, and the way
|
||||
is a peg.
|
||||
| want | why the node's own pivot is not it | what the peg does |
|
||||
| --- | --- | --- |
|
||||
| a pivot **shared** between nodes — an arm and a forearm about one shoulder | two pivots that have to agree frame for frame are not one pivot | one transform, two children hanging off it |
|
||||
| a **second** transform on one node — a drawing spinning about its middle while the limb swings about the shoulder | a node has one `rot` | stack them, as Harmony does |
|
||||
| a hand transform over a **measured** one | `gesture/refusal` turns a drag on a measured channel away, because the next regenerate would discard it | the peg's channels are its own, so the hand transform composes outside the measurement, which stays regenerable |
|
||||
|
||||
The pivot of a measured node is *not* in that table: `[:xform :pivot]` is
|
||||
authored on every node alike, never dense and never regenerated, so a traced
|
||||
mouth can be told to turn about its own middle without a peg and with nothing a
|
||||
regenerate will throw away. That is the row that used to be impossible — writing
|
||||
an anchor under a measured `M` moved the thing it was meant to leave alone,
|
||||
because the old composition was `T(pos)·M·T(-a)` and `pos` was the measurement's.
|
||||
The conjugated form has no such problem: `T(a)·M·T(-a)` is the identity at `a`
|
||||
whatever `M` is.
|
||||
|
||||
`demo/stage` places its seven faces on pegs, and that is now one way of writing
|
||||
something a pivot says directly: the faces' `:scale` is **keyed** — they pulse —
|
||||
and the source's middle has to stay on its authored centre throughout, which a
|
||||
static `pos` cannot do since `T(pos)·S(k(f))` moves that point whenever `k`
|
||||
changes. `T(center)·S(k(f))·T(-origin)` does, for every `k`, and so does one
|
||||
instance with its pivot on the middle. The demo is left as it is, pegs and all:
|
||||
it is a hand-authored scene that renders correctly and `instance-test` asserts
|
||||
its structure, and a peg carrying a keyed scale is a perfectly good thing to
|
||||
have written.
|
||||
|
||||
`gesture/about` survives for the one gesture whose pivot belongs to no node: a
|
||||
**multi-selection** scaling about the middle of its shared box, where every
|
||||
member has to move to keep the arrangement. Nobody keys that.
|
||||
|
||||
Schema 8 is the first version that **converts** rather than refusing. A schema-7
|
||||
node has no pivot, an absent pivot reads as `[0 0]`, and `T(pos)·T(0)·M·T(-0)` is
|
||||
`T(pos)·M` to the bit — so every stored document composes to exactly the matrices
|
||||
it did, dense tier-2 transforms included, and the migration only restamps the
|
||||
version. What a converted document does not get is a pivot anybody chose; its
|
||||
nodes still turn about their origins until the first turn writes one or the cross
|
||||
is dragged.
|
||||
|
||||
**The similarity fit already produces a decomposition.** `fitSimilarity` returns
|
||||
`{s θ tx ty}`, which drops straight into `[:xform :scale]`, `[:xform :rot]` and
|
||||
|
|
|
|||
|
|
@ -589,13 +589,17 @@
|
|||
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.
|
||||
|
||||
WHERE A DROP LANDS, and nothing else any more. It used to be copied into each
|
||||
new instance's anchor as a pivot default, which made it a cache of a derived
|
||||
value that nothing invalidated — so a symbol edited afterwards kept its
|
||||
instances pivoting about where its drawing had been. Pivots are derived per
|
||||
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
|
||||
`ui/drag`'s ghost draws the cross there so a drop lands where it was aimed."
|
||||
WHERE A DROP LANDS, AND WHAT THE INSTANCE TURNS ABOUT. `place-symbol` puts this
|
||||
point under the pointer and stores it as the instance's `[:xform :pivot]`, and
|
||||
`ui/drag`'s ghost draws the cross there so a drop lands where it was aimed —
|
||||
one point, one meaning, three uses.
|
||||
|
||||
IT IS A DEFAULT AND NOT A CACHE, which is the distinction the stored anchor got
|
||||
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]
|
||||
(let [resolve (resolver clip sid store pal/index-of {:grid-fps (fps clip sid)})
|
||||
bounds (fn [[x0 y0 x1 y1 :as b] x y]
|
||||
|
|
@ -618,12 +622,19 @@
|
|||
(defn place-symbol
|
||||
"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
|
||||
sits in its own coordinates, and `pos` is set so that point lands on `point`, a
|
||||
stage pixel; without one — a drop on the timeline — the drawing stays where it
|
||||
was drawn. No pivot is stored: an instance turns about the middle of what it
|
||||
draws at the moment it is dragged, so editing the symbol afterwards cannot
|
||||
leave a pivot behind. See `domain/gesture`.
|
||||
THE MIDDLE GOES UNDER THE POINTER, AND IS WHAT THE INSTANCE TURNS ABOUT.
|
||||
`center` says where the symbol's drawing sits in its own coordinates; `pos` is
|
||||
set so that point lands on `point`, a stage pixel — without one, a drop on the
|
||||
timeline, the drawing stays where it was drawn — and the same point is stored as
|
||||
the instance's `[:xform :pivot]`, so a turn or a scale happens about the middle
|
||||
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
|
||||
map — it is what `:linked-to`, an export target and a saved leaf all name — so
|
||||
|
|
@ -657,7 +668,8 @@
|
|||
:source {:symbol sid}
|
||||
:playback {:in 0 :speed 1 :end :stop}
|
||||
: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
|
||||
"Place a sound at host frame `frame`. Its span uses `source`'s fps when
|
||||
|
|
|
|||
|
|
@ -2,37 +2,43 @@
|
|||
"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
|
||||
the node's `[:xform :pos]`, `[:xform :rot]` or `[:xform :scale]`, given its
|
||||
`nest/placement`. Whatever is above the node — instances, parents, a `:pinv` —
|
||||
is in the placement's matrices, so a shape five symbols down moves under the
|
||||
pointer like one on top.
|
||||
the node's `[:xform :pos]`, `[:xform :rot]`, `[:xform :scale]` or
|
||||
`[:xform :pivot]`, given its `nest/placement`. Whatever is above the node —
|
||||
instances, parents, a `:pinv` — is in the placement's matrices, so a shape five
|
||||
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
|
||||
when it draws nothing. One rule, for a drawing, a peg and a measured face part
|
||||
alike — `pivot` below.
|
||||
A GESTURE TURNS AND SCALES ABOUT THE NODE'S OWN PIVOT, and nothing here solves
|
||||
for a position to fake one with. `node/local!` composes the rotation and the
|
||||
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
|
||||
`paint/centred` exists. `node/local!` turns and scales about the node's own
|
||||
origin and nothing else, so a pivot anywhere else has to be paid for by writing
|
||||
`pos` as well — `about` solves for it — and that solution is an ARC in the
|
||||
angle while `pos` interpolates along the CHORD. It is therefore exact on the
|
||||
frame it is written and nowhere between two keys, which is fine for a drag and
|
||||
is not a thing to key: a keyed 360° turn came back to the right place having
|
||||
gone right off the stage in the middle, because 0° and 360° are the only frames
|
||||
where the error vanishes.
|
||||
A PIVOT NOBODY HAS CHOSEN IS THE MIDDLE OF WHAT THE NODE DRAWS, and the first
|
||||
turn or scale WRITES IT DOWN — `pivot` derives it from `pick/bounds-of` on the
|
||||
frame the drag starts, and `with-pivot` turns that into a `[:xform :pivot]` and
|
||||
the `[:xform :pos]` that leaves the picture exactly where it is. After that it
|
||||
is an ordinary stored, keyable, draggable channel, and the derived value is
|
||||
never consulted again: a pivot is a CHOICE, and re-deriving it per drag means
|
||||
editing a symbol silently moves what its instances turn about.
|
||||
|
||||
So a drawing's origin is put on the middle of what it draws the moment it is
|
||||
drawn, and then the pivot IS the origin, `about` has nothing to do, and a turn
|
||||
writes `rot` alone — right on every frame, keyed or not. `turn` and `scale`
|
||||
check for that and write the one channel.
|
||||
WHAT THIS REPLACES, because it was a deletion that cost a feature. There used
|
||||
to be no pivot in the decomposition at all: a drag derived the middle of the
|
||||
drawing, and `about` solved for the `pos` that holds that point still under the
|
||||
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
|
||||
to be: a MEASURED part, whose points and position are dense tier-2 geometry in
|
||||
the footage's space, and a drawing whose points have been edited far enough to
|
||||
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.
|
||||
`about` is still here and is still that equation, for the one gesture that
|
||||
genuinely has a pivot belonging to no node: a MULTI-SELECTION scaling about the
|
||||
middle of its shared box. Nobody keys that.
|
||||
|
||||
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
|
||||
|
|
@ -54,7 +60,15 @@
|
|||
[n 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)])]
|
||||
{: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]
|
||||
(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
|
||||
second closed form, so there is one place the decomposition is written out."
|
||||
[{: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
|
||||
"The node's whole local transform, `T(pos)·R·K·S` — what takes a point in the
|
||||
node's own coordinates to its parent's."
|
||||
[{:keys [pos rot scale skew]}]
|
||||
(node/local! (node/mat) pos rot scale skew))
|
||||
"The node's whole local transform, `T(pos)·T(piv)·R·K·S·T(-piv)` — what takes a
|
||||
point in the node's own coordinates to its parent's."
|
||||
[{:keys [pos pivot rot scale skew]}]
|
||||
(node/local! (node/mat) pos pivot rot scale skew))
|
||||
|
||||
(defn about
|
||||
"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
|
||||
nothing has no point under `c` to hold.
|
||||
|
||||
THE ONE EQUATION a pivot needs, and it replaces the stored anchor outright.
|
||||
Local is `T(p)·M`. The material point sitting under `c` is `q = M⁻¹(c − p)`,
|
||||
and holding it there under the new `M'` is
|
||||
FOR A PIVOT THAT IS NO NODE'S, which since the pivot went into the
|
||||
decomposition is one gesture and only one: a multi-selection scaling about the
|
||||
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,
|
||||
`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`.
|
||||
p' = t' − a + M'·a = c − M'·(q − a) − a
|
||||
|
||||
WHAT IT COSTS is in the namespace docstring, and it is the reason `turn` and
|
||||
`scale` would rather not call this at all: `p'` is an arc in the angle and `pos`
|
||||
interpolates along the chord, so a pivot that is not the node's own origin is
|
||||
held exactly on the frame it is written and nowhere between two keys. Needed
|
||||
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."
|
||||
Checkable at both ends it has to be right at: with `c` the node's own pivot
|
||||
point, `c = pos + a`, so `q = a` and `p' = c − a = pos` — turning about your own
|
||||
pivot never moves you. And with no pivot at all, `a = 0`, it is the familiar
|
||||
`p' = c − M'·M⁻¹(c − p)`."
|
||||
[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)]
|
||||
(let [q (through inv (mapv - c (:pos v)))]
|
||||
(mapv - c (through (linear v') q))))))
|
||||
(let [q (through inv (mapv - c t))]
|
||||
(mapv - (mapv - c (through (linear v') (mapv - q a))) a)))))
|
||||
|
||||
(defn pivot
|
||||
"The parent-space point a drag on this node turns and scales about: the middle
|
||||
of `bounds` — what it draws, in its own coordinates — through its own
|
||||
transform, or its own origin when it draws nothing.
|
||||
|
||||
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."
|
||||
(defn middle
|
||||
"The middle of `bounds` — what the node draws, in its own coordinates — through
|
||||
its own transform, so a parent-space point; or its own origin when it draws
|
||||
nothing. What a node nobody has pivoted yet turns about."
|
||||
[v bounds]
|
||||
(let [[x0 y0 x1 y1] bounds]
|
||||
(through (local-of v)
|
||||
(if bounds [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)] [0 0]))))
|
||||
|
||||
(defn at-origin?
|
||||
"Is parent-space point `c` the node's own origin?
|
||||
(defn pivot
|
||||
"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
|
||||
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
|
||||
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
|
||||
gesture in which a millionth of a pixel is a pivot somewhere else."
|
||||
coordinates. A point that close to the pivot IS the pivot — there is no gesture
|
||||
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]
|
||||
(let [[dx dy] (mapv - c (:pos v))]
|
||||
(let [[dx dy] (mapv - c (mapv + (:pos v) (:pivot v)))]
|
||||
(< (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
|
||||
"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]
|
||||
(when-let [inv (node/invert parent)]
|
||||
{[:xform :pos] (mapv + pos (mapv - (through inv p1) (through inv p0)))}))
|
||||
|
|
@ -166,41 +266,42 @@
|
|||
(js/Math.atan2 y x))))
|
||||
|
||||
(defn turn
|
||||
"The node turned by `da` radians about parent-space point `c`: the rotation,
|
||||
and — only if `c` is not the node's own origin — the position that holds `c`
|
||||
still.
|
||||
"The node turned by `da` radians about parent-space point `c`: the rotation, and
|
||||
— only on the first turn of a node nobody has pivoted — the pivot and position
|
||||
that put its pivot on `c` without moving it.
|
||||
|
||||
ONE CHANNEL WHENEVER IT CAN BE, which for a drawing is always, because
|
||||
`paint/centred` put its origin on the middle of what it draws. `about` would
|
||||
return the position unchanged here, to within the rounding of its own matrix
|
||||
inverse; not calling it is the difference between a turn that writes `rot` and
|
||||
one that writes `rot` and a `pos` key that is a hair off the one already there.
|
||||
The second is the one that sends a keyed spin off the stage, since `pos` tweens
|
||||
along the chord of an arc it has no way to know about.
|
||||
ONE CHANNEL, ONCE THE PIVOT IS ITS OWN, and that is the whole reason the pivot
|
||||
is in `node/local!` rather than solved for here. `rot` is the only thing a turn
|
||||
changes, so a keyed turn interpolates ONE number and the matrix is composed
|
||||
about the pivot on every frame of it: the pivot is held exactly between two keys
|
||||
and not merely at them. A `pos` written beside the rotation — which is what
|
||||
`about` solves for, and what this used to do for anything whose origin was not
|
||||
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
|
||||
drawing edited away from its middle — and is then exact on this frame alone."
|
||||
`c` IS A DEFAULT, NOT A TARGET. A node with its own pivot ignores it and turns
|
||||
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]
|
||||
(let [v' (update v :rot + da)]
|
||||
(cond-> {[:xform :rot] (:rot v')}
|
||||
(and c (not (at-origin? v c)))
|
||||
(into (when-let [p (about v v' c)] {[:xform :pos] p})))))
|
||||
(let [[v vs] (with-pivot v c)]
|
||||
(merge vs {[:xform :rot] (+ (:rot v) da)})))
|
||||
|
||||
(defn scale
|
||||
"The node's scale with the point under stage `p0` taken to `p1`, about
|
||||
parent-space pivot `c`, along the node's own axes — or by the same factor on
|
||||
both when `uniform?` — and, only if `c` is not the node's own origin, the
|
||||
position that holds `c` still.
|
||||
"The node's scale with the point under stage `p0` taken to `p1`, about its own
|
||||
pivot, along the node's own axes — or by the same factor on both when
|
||||
`uniform?` — and, on the first scale of a node nobody has pivoted, the pivot and
|
||||
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
|
||||
corner drag track the pointer on a node that has been turned. On a drawing the
|
||||
pivot is the origin, so `q` is the origin too and the pointer offsets are
|
||||
already measured from it — and, as in `turn`, the position is left alone rather
|
||||
than rewritten to a hair off itself."
|
||||
corner drag track the pointer on a node that has been turned, and they are
|
||||
measured FROM THE PIVOT `q`: scaling by `k` takes `q + d` to `q + k·d`, so the
|
||||
factor a corner wants is the ratio of its offsets from the pivot before and
|
||||
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?]
|
||||
(let [[v vs] (with-pivot v c)]
|
||||
(when-let [winv (node/invert world)]
|
||||
(when-let [minv (node/invert (linear v))]
|
||||
(let [q (through minv (mapv - c (:pos v)))
|
||||
(let [q (:pivot v)
|
||||
a (mapv - (through winv p0) q)
|
||||
b (mapv - (through winv p1) q)
|
||||
k (fn [a b] (if (< (js/Math.abs a) 1e-6) 1 (/ b a)))
|
||||
|
|
@ -208,11 +309,8 @@
|
|||
(let [aa (reduce + (map * a a))]
|
||||
(if (< aa 1e-9) 1 (/ (reduce + (map * a b)) aa))))
|
||||
s (:scale v)
|
||||
s' (if r (mapv #(* r %) s) (mapv * s (map k a b)))
|
||||
v' (assoc v :scale s')]
|
||||
(cond-> {[:xform :scale] s'}
|
||||
(and c (not (at-origin? v c)))
|
||||
(into (when-let [p (about v v' c)] {[:xform :pos] p})))))))
|
||||
s' (if r (mapv #(* r %) s) (mapv * s (map k a b)))]
|
||||
(merge vs {[:xform :scale] s'})))))
|
||||
|
||||
(defn scale-by
|
||||
"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
|
||||
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
|
||||
out of the pointer in the node's own axes."
|
||||
for its own factors. The point belongs to the box and not to any node in it, so
|
||||
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]
|
||||
(let [v' (update v :scale #(mapv (partial * k) %))]
|
||||
(cond-> {[:xform :scale] (:scale v')}
|
||||
|
|
|
|||
|
|
@ -647,33 +647,31 @@
|
|||
whatever it hangs off now, sitting on the pivot the node has at frame `f`.
|
||||
`{:clip :sid :id}` or `{:refused why}`.
|
||||
|
||||
A PEG IS WHAT AN ANCHOR WAS, REACHABLE. `T(a)·M·T(-a)` — a transform conjugated
|
||||
by a translation — is \"do M in a frame shifted by `a`\", and a parent already IS
|
||||
a shifted frame; `node-test` asserts the two produce the same matrix. So the
|
||||
stored anchor was a peg that could not be selected, keyed, shared, or placed
|
||||
over a measured channel, and this is the same capability with none of those
|
||||
restrictions. It is the answer to all three things a derived pivot cannot do:
|
||||
WHAT A PEG IS FOR, NOW THAT A NODE HAS ITS OWN PIVOT. `[:xform :pivot]` is how
|
||||
ONE node turns about a point of its own — that is `node/local!`, it needs no
|
||||
parent, and it is right between two keys. A peg is for the three things that
|
||||
are about MORE THAN ONE NODE, or about a node whose channels are not yours to
|
||||
write:
|
||||
|
||||
A PIVOT THAT PERSISTS. A gesture's pivot is the middle of what the node draws
|
||||
and lives for one drag. An arm that turns about its SHOULDER wants a pivot
|
||||
nowhere near that middle, and it wants it on every frame, not re-derived per
|
||||
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 SHARED BETWEEN NODES. An arm and a forearm turning about one shoulder
|
||||
is one transform driving two drawings, and two pivots that have to agree
|
||||
frame for frame are not that. The peg is the shoulder, and they hang off it.
|
||||
|
||||
A PIVOT THAT IS KEYED. The peg's `pos` is an ordinary channel, so a pivot
|
||||
that travels — a foot roll — is a keyed position. An anchor could only have
|
||||
been keyed against `pos`, which is interpolated in the same breath, and the
|
||||
two would have had to agree frame for frame.
|
||||
A SECOND TRANSFORM ON ONE NODE. A drawing turning about its own middle while
|
||||
the whole limb swings about the shoulder is two rotations, and a node has one
|
||||
`rot`. Toon Boom stacks pegs for exactly this.
|
||||
|
||||
A HAND TRANSFORM OVER A MEASURED ONE. This is the one that was impossible.
|
||||
`gesture/refusal` turns a drag on a measured node away because the next
|
||||
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
|
||||
A HAND TRANSFORM OVER A MEASURED ONE. `gesture/refusal` turns a drag on a
|
||||
measured node away because the next regenerate would discard it. A peg's
|
||||
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
|
||||
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
|
||||
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
|
||||
|
|
@ -682,7 +680,8 @@
|
|||
... · 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
|
||||
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
|
||||
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
|
||||
:parent uuid
|
||||
: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"})))))
|
||||
|
|
|
|||
|
|
@ -33,17 +33,29 @@
|
|||
stored transform, so it is in the shape and in the composition order from the
|
||||
start.
|
||||
|
||||
THERE IS NO :anchor, and that is a deletion rather than an omission. An anchor
|
||||
is `T(a)·M·T(-a)` — the conjugation of a transform by a translation, which is
|
||||
to say \"do M in a frame shifted by a\" — and a PARENT ALREADY IS A SHIFTED
|
||||
FRAME. So an anchor is a peg that cannot be addressed, cannot be keyed, cannot
|
||||
be shared between nodes, and cannot be put above a measured channel, which is
|
||||
exactly where a pivot is most needed. Same expressive content, strictly less
|
||||
reach. What it used to do, two mechanisms now do properly: `domain/gesture`
|
||||
turns and scales about a point computed at the moment of the drag and stores
|
||||
nothing, and a peg — an ordinary `:group` parent — carries a pivot that has to
|
||||
persist, be keyed, or sit over a measured transform. See docs/animation-model.md."
|
||||
[[:xform :pos] [:xform :rot] [:xform :scale] [:xform :skew]])
|
||||
:pivot IS THE POINT ROTATION AND SCALE HAPPEN ABOUT, in the node's own
|
||||
coordinates, and it is in the decomposition because THERE IS NOWHERE ELSE IT
|
||||
CAN BE. Toon Boom gives every layer and peg a pivot; Flash gives every instance
|
||||
a transformation point; After Effects calls it the anchor point. All three store
|
||||
it, and all three are right to, for one reason: 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. Solving for the `pos` that holds
|
||||
a point still — `gesture/about` — is exact on the frame it is solved and wrong
|
||||
between two keys, because the solution is an ARC in the angle and `pos` tweens
|
||||
along the CHORD. See docs/animation-model.md, which has the measurements off the
|
||||
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
|
||||
"The set of valid channel paths follows from the node's :kind, and that is a
|
||||
|
|
@ -73,8 +85,18 @@
|
|||
|
||||
(def defaults
|
||||
"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 :pivot] (ch/framed [0.0 0.0])
|
||||
[:xform :rot] (ch/framed 0.0)
|
||||
[:xform :scale] (ch/framed [1.0 1.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
|
||||
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
|
||||
regenerated. Nothing has to be written onto the measured node at all, which is
|
||||
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
|
||||
was meant to leave alone."
|
||||
regenerated. Nothing has to be written onto the measured node at all.
|
||||
|
||||
ITS PIVOT IS STILL ITS OWN, and that is the one part of its transform a hand
|
||||
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]
|
||||
(boolean (some #(let [c (get-in n [:channels [:xform %]])]
|
||||
(or (:dense c) (:generated c)))
|
||||
|
|
@ -363,11 +389,17 @@
|
|||
dest))
|
||||
|
||||
(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
|
||||
per node per frame and the four products would each allocate. The derivation,
|
||||
so the constants are checkable rather than trusted:
|
||||
The transform CONJUGATED BY ITS PIVOT, which is to say: do the rotation, skew
|
||||
and scale in a frame shifted to `piv`, so the point `piv` of the node's own
|
||||
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 |
|
||||
| s c | | ky 1 | | 0 sy |
|
||||
|
|
@ -378,28 +410,36 @@
|
|||
R·K·S = | sx(c - s·ky) sy(c·kx - s) |
|
||||
| sx(s + c·ky) sy(s·kx + c) |
|
||||
|
||||
and the translation is pos itself, because rotation and scale happen about the
|
||||
node's OWN ORIGIN and nothing else. Turning about any other point is
|
||||
`gesture/about`, which solves for the `pos` that holds the chosen point still
|
||||
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
|
||||
has to outlive a drag is a peg: see `xform-paths`.
|
||||
which is the linear part, UNTOUCHED BY THE PIVOT — a conjugation by a
|
||||
translation cannot change it, which is why a pivot is free to move without
|
||||
reshaping anything. All of it lands in the translation:
|
||||
|
||||
T(p)·T(a)·M·T(-a) = T(p + a - M·a) · M
|
||||
|
||||
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
|
||||
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)
|
||||
s (js/Math.sin rot)
|
||||
sx (ch/component scale 0)
|
||||
sy (ch/component scale 1)
|
||||
kx (ch/component skew 0)
|
||||
ky (ch/component skew 1)]
|
||||
(aset dest 0 (* sx (- c (* s ky))))
|
||||
(aset dest 1 (* sx (+ s (* c ky))))
|
||||
(aset dest 2 (* sy (- (* c kx) s)))
|
||||
(aset dest 3 (* sy (+ (* s kx) c)))
|
||||
(aset dest 4 (ch/component pos 0))
|
||||
(aset dest 5 (ch/component pos 1))
|
||||
ky (ch/component skew 1)
|
||||
ax (ch/component piv 0)
|
||||
ay (ch/component piv 1)
|
||||
a (* sx (- c (* s ky)))
|
||||
b (* sx (+ s (* c ky)))
|
||||
c* (* sy (- (* c kx) s))
|
||||
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))
|
||||
|
||||
(defn pinv
|
||||
|
|
@ -487,9 +527,10 @@
|
|||
(pos? (get-in n [:time :rate])))))
|
||||
(conj ":time :rate must be positive")
|
||||
(contains? (:channels n) [:xform :anchor])
|
||||
(conj (str ":anchor is gone — a pivot nobody chose is derived from what "
|
||||
"the node draws, and a pivot to keep is a peg over it; "
|
||||
"edit > add peg, then turn and key that"))
|
||||
(conj (str ":anchor is now [:xform :pivot], and it means the same point "
|
||||
"in the same coordinates — but the composition around it "
|
||||
"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")
|
||||
(and (:span n) (not (and (vector? (:span n)) (= 2 (count (:span n)))
|
||||
(every? finite-number? (:span n))
|
||||
|
|
@ -504,9 +545,10 @@
|
|||
|
||||
;; `[: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
|
||||
;; schema-6 drawing and placement had one — so "not valid on a :poly node"
|
||||
;; would be the first thing a person saw, and it says nothing about what to
|
||||
;; do. The precedent is `symbol/problems`' refusal of `:trace`.
|
||||
;; schema-6 drawing and placement had one, and schema 7 refused them all
|
||||
;; rather than convert — so "not valid on a :poly node" would be the first
|
||||
;; thing a person saw, and it says nothing about what to do. The precedent
|
||||
;; is `symbol/problems`' refusal of `:trace`.
|
||||
(into (when valid
|
||||
(for [[path _] (:channels n)
|
||||
:when (and (not (contains? valid path))
|
||||
|
|
|
|||
|
|
@ -25,25 +25,22 @@
|
|||
`[ring pos]`: the same drawing about the origin, and the position that puts it
|
||||
back exactly where it was.
|
||||
|
||||
THE ORIGIN OF A SHAPE IS THE MIDDLE OF WHAT IT DRAWS, and that is the one
|
||||
invariant the pivot rests on. `node/local!` turns and scales about the node's
|
||||
OWN ORIGIN and nothing else — deliberately, since `[:xform :anchor]` was
|
||||
deleted — so a node whose origin is nowhere near its content turns about
|
||||
nowhere near its content. A stroke stored exactly as it was drawn has its
|
||||
origin at the SYMBOL's origin, which on the stage is the top-left corner, so
|
||||
every drawing anyone made turned about the corner of the stage: 126 px away on
|
||||
a 320x200 stage, which is an orbit wider than the stage.
|
||||
THE ORIGIN OF A SHAPE IS THE MIDDLE OF WHAT IT DRAWS, so a shape needs no
|
||||
pivot of its own: `[:xform :pivot]` defaults to the node's origin, and for a
|
||||
drawing that IS the middle of the drawing. A stroke stored exactly as it was
|
||||
drawn would have its origin at the SYMBOL's origin, which on the stage is the
|
||||
top-left corner — 126 px away on a 320x200 stage, an orbit wider than the
|
||||
stage — and that is what every drawing would turn about with a default pivot
|
||||
and no centring here.
|
||||
|
||||
It could not be seen while a drag was the only way to turn something, because
|
||||
`gesture/about` solved for the `pos` that holds the chosen pivot still and
|
||||
wrote it alongside the rotation — exactly right on the frame of the drag. But
|
||||
that solution is `p' = c + R(θ)(p − c)`, an ARC, and `pos` is interpolated
|
||||
along the chord, so a keyed turn held its pivot on its keys and nowhere
|
||||
between: a 360° spin returned to the right place having gone right off the
|
||||
stage in the middle of the turn, since 0° and 360° are the only frames where
|
||||
the error vanishes. With the origin on the content there is nothing to solve
|
||||
and nothing to interpolate — a turn writes `rot` alone, and `pos` goes back to
|
||||
being the motion path it reads as.
|
||||
IT IS WORTH DOING ANYWAY, NOW THAT THERE IS A PIVOT, because this is the half
|
||||
nobody has to choose. A pivot is a stored choice and a default has to be a good
|
||||
one: with the origin on the content the default is already right, a turn writes
|
||||
`rot` alone, and nothing is stored on the node for anybody to have to look at.
|
||||
A symbol instance is the case that cannot do this — its origin is its symbol's,
|
||||
and moving a symbol's origin would move every drawing inside it out from under
|
||||
everything that reads them — so it gets a pivot written where it is placed;
|
||||
see `clip/place-symbol`.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -164,11 +164,16 @@
|
|||
symbol to BUILD and a lookup to RUN. Asking frame by frame through a fresh one
|
||||
is a resolver per frame.
|
||||
|
||||
WHAT A SELECTION BOX IS DRAWN FROM, and now also what a gesture pivots about —
|
||||
the two were the same quantity all along, computed in two places at two times.
|
||||
The box came from here on every render and the pivot from a value stored at
|
||||
creation, so they drifted apart the moment anything was edited, which is what
|
||||
made a pivot on anything beyond one unchanging shape wrong. See `domain/gesture`."
|
||||
WHAT A SELECTION BOX IS DRAWN FROM, and what a pivot DEFAULTS to: a node
|
||||
nobody has pivoted turns about the middle of these bounds, and the first turn
|
||||
or scale writes that point down as its `[:xform :pivot]` — `gesture/pivot` and
|
||||
`gesture/with-pivot`. So the box and the cross start out as one computation,
|
||||
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]
|
||||
(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]))
|
||||
|
|
|
|||
|
|
@ -34,15 +34,25 @@
|
|||
different one — see `events/project` — so this is bumped by any change to what
|
||||
a leaf may contain.
|
||||
|
||||
7 deleted `[:xform :anchor]`. Nothing is converted, as in 6: every project is
|
||||
marked 7, and one still carrying an anchor is refused when it is opened, by
|
||||
name and with what to do about it — `node/problems`. Dropping one would in fact
|
||||
be exact wherever rotation and scale are the identity, which is everywhere a
|
||||
freeze or a drop wrote one, since the anchor cancels out of `node/local!`
|
||||
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
|
||||
guessed at."
|
||||
7)
|
||||
8 added `[:xform :pivot]`, the point a node turns and scales about, which 7 had
|
||||
deleted as `[:xform :anchor]` on the theory that a peg could stand in for one.
|
||||
It cannot: a peg is a node and a turn about one is still `pos` solved per
|
||||
frame, so a KEYED turn of anything whose origin was not its own middle orbited
|
||||
that origin. See `node/xform-paths` and docs/animation-model.md.
|
||||
|
||||
CONVERTED, AND THE ONLY VERSION SO FAR THAT IS, because this one is exact: a
|
||||
schema-7 node has no pivot, an absent pivot reads as `[0 0]` off
|
||||
`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
|
||||
"Every tier-2 key a leaf map names, in a stable order."
|
||||
|
|
|
|||
|
|
@ -624,7 +624,14 @@
|
|||
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
|
||||
`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 id :kind :instance :z (str "a-" id)
|
||||
:span [0 1] :time {:at at :rate 1}
|
||||
|
|
|
|||
|
|
@ -359,16 +359,22 @@
|
|||
(quot (if (vector? pts) (count pts) (.-length pts)) 2))
|
||||
|
||||
(defn- xform-at
|
||||
"The four transform components at the node's local frame, or nil when any of
|
||||
them has no value on it."
|
||||
"The five transform components at the node's local frame, or nil when any of
|
||||
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]
|
||||
(let [pos (rd [:xform :pos])
|
||||
piv (rd [:xform :pivot])
|
||||
rot (rd [:xform :rot])
|
||||
scl (rd [:xform :scale])
|
||||
skw (rd [:xform :skew])]
|
||||
(when-not (or (ch/nothing? pos) (ch/nothing? rot) (ch/nothing? scl)
|
||||
(ch/nothing? skw))
|
||||
[pos rot scl skw])))
|
||||
(when-not (or (ch/nothing? pos) (ch/nothing? piv) (ch/nothing? rot)
|
||||
(ch/nothing? scl) (ch/nothing? skw))
|
||||
[pos piv rot scl skw])))
|
||||
|
||||
(defn- visible?
|
||||
"Is the node switched on this frame?
|
||||
|
|
@ -422,10 +428,10 @@
|
|||
plf (node/local-frame n ppf)
|
||||
rd (fn [path] (read id path (get chs path) lf plf))]
|
||||
(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
|
||||
;; 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)
|
||||
:f lf
|
||||
:pre plf
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
[arthur.domain.outline :as outline]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.pick :as pick]
|
||||
[arthur.domain.span :as span]
|
||||
[arthur.events.edit :as edit]
|
||||
[arthur.domain.paint :as paint]
|
||||
|
|
@ -1050,32 +1051,26 @@
|
|||
|
||||
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
|
||||
to do silently. `clip/place-symbol` has just put the picture's middle where it
|
||||
belongs — under the pointer, or wherever it was — at the IDENTITY scale, and
|
||||
then this changes the scale. With an anchor on the middle, `T(a)·S(k)·T(-a)`
|
||||
left that point alone for any `k` and there was nothing to fix. Without one,
|
||||
`T(pos)·S(k)` moves it by `(1-k)·middle`, so the middle has to be solved for
|
||||
again — the same equation `gesture/about` solves per drag, with `middle` as the
|
||||
point to hold.
|
||||
THE SCALE ALONE, SINCE A NODE HAS A PIVOT. `clip/place-symbol` has just put the
|
||||
picture's middle where it belongs and stored that same middle as the
|
||||
placement's `[:xform :pivot]`, and `node/local!` composes the scale about it —
|
||||
`T(pos)·T(a)·S(k)·T(-a)` leaves `a` exactly where it is for every `k` — so
|
||||
changing the scale here cannot move the picture off the point it was dropped
|
||||
on. This used to have to solve for the position again, the same equation
|
||||
`gesture/about` solves per drag, because there was no pivot to compose about
|
||||
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
|
||||
is half its size and `pos = where − k·middle` for both branches: `where` is
|
||||
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."
|
||||
A trace's own coordinates are its pixels, so a drop on the timeline still has
|
||||
a position to write: the picture's middle goes on the middle of the stage."
|
||||
[document sid uuid {:keys [width height]} point?]
|
||||
(let [[w h] (clip/stage document sid)
|
||||
k (min (/ w width) (/ h height))
|
||||
mid [(/ width 2) (/ height 2)]]
|
||||
(update-in document [:symbols sid :nodes uuid :channels]
|
||||
(fn [chs]
|
||||
(let [where (if point?
|
||||
(mapv + (:value (get chs [:xform :pos])) mid)
|
||||
[(/ w 2) (/ h 2)])]
|
||||
(assoc chs
|
||||
[:xform :scale] (ch/framed [k k])
|
||||
[:xform :pos] (ch/framed (mapv - where (mapv * [k k] mid)))))))))
|
||||
(cond-> (assoc chs [:xform :scale] (ch/framed [k k]))
|
||||
(not point?)
|
||||
(assoc [:xform :pos] (ch/framed (mapv - [(/ w 2) (/ h 2)] mid))))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::drop-symbol
|
||||
|
|
@ -1383,10 +1378,26 @@
|
|||
|
||||
(rf/reg-event-db
|
||||
::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
|
||||
;; nothing on screen by construction, so there is no intermediate state worth
|
||||
;; 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]]
|
||||
(let [{clip :clip st :store} (store/entry (:clip/current db))
|
||||
open (get-in db [:ui :open])
|
||||
|
|
@ -1396,11 +1407,39 @@
|
|||
(when-let [inv (node/invert (:parent pl))]
|
||||
(node/apply-pt! out 0 inv (first point) (second point))
|
||||
[(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
|
||||
(nil? r) db
|
||||
(:refused r) (refused db (:refused r))
|
||||
:else (edit/edit db (constantly (:clip r)))))))
|
||||
(nil? vs) db
|
||||
:else (edit/edit db #(gesture/apply-values % (:sid pl) (:id pl) (:frame pl)
|
||||
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
|
||||
::edit-keyframes
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
value, so clicking a swatch notifies the swatches and nothing else."
|
||||
(:require [arthur.domain.creation :as creation]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.gesture :as gesture]
|
||||
[arthur.domain.nest :as nest]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.pick :as pick]
|
||||
|
|
@ -147,7 +148,14 @@
|
|||
(when n
|
||||
(let [st (:store (store/entry clip-id))]
|
||||
(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
|
||||
::selected-placements
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@
|
|||
;; Rotation is shown in degrees. Between two keys, the gap after the one here
|
||||
;; 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))
|
||||
;; 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.
|
||||
|
|
@ -206,6 +206,16 @@
|
|||
(number? v) (field 0 v put)
|
||||
:else (doall (map-indexed (fn [i x] (field i x #(put (assoc (vec v) i %))))
|
||||
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])]))
|
||||
|
||||
(defn- color-control [sid id ch frame auto-key? palette]
|
||||
|
|
@ -267,6 +277,11 @@
|
|||
|
||||
(defn- node-section [[sid id 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?])
|
||||
clip @(rf/subscribe [::render/clip])
|
||||
placement @(rf/subscribe [::sub/selected-placement])
|
||||
|
|
@ -308,7 +323,7 @@
|
|||
[color-control sid id ch frame auto-key? palette]
|
||||
|
||||
(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)])]))])]))
|
||||
|
||||
|
|
|
|||
|
|
@ -158,13 +158,16 @@
|
|||
(defn- begin!
|
||||
"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
|
||||
what makes deriving it affordable where a stored one was tempting — and holding
|
||||
it for the drag is what keeps a turn steady: re-deriving per pointermove would
|
||||
chase the box the turn is itself moving.
|
||||
THE PIVOT IS READ ONCE, when the pointer goes down, and held for the drag.
|
||||
`gesture/pivot` answers with the node's own `[:xform :pivot]` where it has one,
|
||||
and with the middle of what it draws where nobody has pivoted it yet — and
|
||||
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
|
||||
`paint/centred` put it, and `gesture/turn` then writes `rot` alone."
|
||||
A DRAG ON A NODE WITH NO PIVOT OF ITS OWN WRITES ONE, as part of its first
|
||||
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]
|
||||
(let [{document :clip st :store} (loaded ctx)]
|
||||
(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
|
||||
pointerdown, which keeps a selection it lands inside.
|
||||
|
||||
THE CROSS IS THE MIDDLE OF THE BOX, and that is an identity rather than a
|
||||
coincidence to keep up: both come from the same `bounds` on the same frame, so
|
||||
the cross cannot drift off the box. On a node that draws nothing — a peg — there
|
||||
is no box and the cross marks its own origin, which is what it turns about.
|
||||
THE CROSS IS ON THE PIVOT, which is `[:xform :pivot]` through the node's
|
||||
parent — the point `node/local!` composes the rotation and the scale about, and
|
||||
therefore the point a turn here will actually hold still, on this frame and
|
||||
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
|
||||
origin on the middle of what it draws. So the cross marks the node's origin as
|
||||
well as its box's middle, and a turn about it is `rot` alone rather than a
|
||||
rotation plus a position solved to place it.
|
||||
|
||||
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.
|
||||
THE CROSS IS A HANDLE, ON EVERY NODE. ⌃/⌘-dragging it puts the pivot somewhere
|
||||
else — `::ui/repivot`, which moves nothing — and dragging it plainly moves the
|
||||
node, as dragging inside the box does. This is After Effects' pan-behind and
|
||||
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.
|
||||
|
||||
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,
|
||||
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
|
||||
then only be typed at in the inspector, which is not a pivot anybody would use.
|
||||
So the handles fall back to a fixed-size cluster about its own origin: the cross
|
||||
moves it, a knob turns it, four corners scale it. FIXED SIZE, in stage pixels,
|
||||
because there is no drawing to be proportional to — the same reason the ghost's
|
||||
cross is always drawn at a fixed size."
|
||||
then only be typed at in the inspector. So the handles fall back to a
|
||||
fixed-size cluster about its pivot: a knob turns it, four corners scale it.
|
||||
FIXED SIZE, in stage pixels, because there is no drawing to be proportional to
|
||||
— the same reason the ghost's cross is always drawn at a fixed size."
|
||||
[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])
|
||||
[px py] (when world
|
||||
(let [[x0 y0 x1 y1] bounds]
|
||||
(through world (if bounds
|
||||
[(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)]
|
||||
[0 0]))))
|
||||
[px py] (when (and parent piv) (through parent piv))
|
||||
grab (fn [kind]
|
||||
(fn [^js event]
|
||||
(.stopPropagation event)
|
||||
|
|
@ -307,8 +304,41 @@
|
|||
(let [svg (.-ownerSVGElement (.-currentTarget event))]
|
||||
(.setPointerCapture svg (.-pointerId event))
|
||||
(begin! ctx kind path (xy svg event (:w ctx) (:h ctx))))))]
|
||||
(when world
|
||||
(when (and world px)
|
||||
[: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]
|
||||
(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])
|
||||
|
|
@ -326,35 +356,6 @@
|
|||
;; A peg. Same three gestures, hung on nothing.
|
||||
(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)}]
|
||||
[:circle.knob {:cx px :cy (- py r 8) :r 2.2 :on-pointer-down (grab :turn)}]
|
||||
(doall
|
||||
|
|
@ -363,7 +364,10 @@
|
|||
^{:key i}
|
||||
[:rect.corner {:x (- x 1.8) :y (- y 1.8) :width 3.6 :height 3.6
|
||||
: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])]
|
||||
[:path.pivot {:class (when @repivot "moving")
|
||||
:d (str "M " (- cx 4) " " cy " H " (+ cx 4)
|
||||
|
|
|
|||
|
|
@ -125,6 +125,53 @@
|
|||
(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)"))))))
|
||||
|
||||
(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
|
||||
(let [c (two-down)
|
||||
path [u v :shape]
|
||||
|
|
|
|||
|
|
@ -209,8 +209,8 @@
|
|||
(let [k (ch/value-at scale f nil)
|
||||
;; peg · face, composed as the evaluator does
|
||||
m (node/mul! (node/mat)
|
||||
(node/local! (node/mat) (ch/value-at pos 0 nil) 0 k [0 0])
|
||||
(node/local! (node/mat) off 0 [1 1] [0 0]))
|
||||
(node/local! (node/mat) (ch/value-at pos 0 nil) [0 0] 0 k [0 0])
|
||||
(node/local! (node/mat) off [0 0] 0 [1 1] [0 0]))
|
||||
out (js/Float64Array. 2)]
|
||||
(node/apply-pt! out 0 m 160 100)
|
||||
(is (= [40 40] [(aget out 0) (aget out 1)])
|
||||
|
|
@ -358,23 +358,39 @@
|
|||
[:symbols :main :nodes u :channels]))]
|
||||
(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")
|
||||
(testing "a placement stores a position and no pivot at all"
|
||||
(is (= [[:xform :pos]] (keys (placed c :box nil))) "one channel, and it is where it sits")
|
||||
(testing "a placement stores the middle as its pivot"
|
||||
;; 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]))
|
||||
"dropped on the timeline: where it was drawn"))
|
||||
(testing "dropped on a stage pixel, its middle goes there"
|
||||
(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"
|
||||
;; THE BUG, INVERTED. This used to assert the opposite — that the instance
|
||||
;; kept pivoting about where the symbol's drawing had been — because the
|
||||
;; middle was copied into a stored anchor at drop time and nothing ever
|
||||
;; invalidated it. A pivot derived per drag follows the drawing instead, and
|
||||
;; it cannot move anything on screen by doing so: there is no stored value
|
||||
;; for the composition to read, so adding `sq2` changes the pivot and not
|
||||
;; one pixel of the picture.
|
||||
(is (= [75 65] (get-in (placed c :box [100 100]) [[:xform :pos] :value])))
|
||||
(is (= [25 35] (get-in (placed c :box [100 100]) [[:xform :pivot] :value]))))
|
||||
(testing "growing the symbol afterwards does not move what its instances pivot about"
|
||||
;; THE RULE, and it is the user's: once something has been animated, adding
|
||||
;; to what it draws must not alter what it turns about. This test has
|
||||
;; asserted both answers now. It began by asserting a stored pivot was kept
|
||||
;; — then that was read as a cache going stale, the pivot was deleted and
|
||||
;; derived per drag, and this asserted the opposite: that the pivot
|
||||
;; 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)
|
||||
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]
|
||||
(let [n (get-in doc [:symbols :main :nodes u])]
|
||||
(gesture/pivot (gesture/values n 0 nil)
|
||||
|
|
@ -382,10 +398,33 @@
|
|||
(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 (= [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])
|
||||
(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
|
||||
(let [palette (fn [id name a b]
|
||||
|
|
|
|||
|
|
@ -439,8 +439,10 @@
|
|||
|
||||
(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
|
||||
;; point a drag on the node would have. That is what makes it a REPLACEMENT for
|
||||
;; the pivot rather than a second, differently-placed one.
|
||||
;; point a drag on the node would have. Not because a peg stands in for the
|
||||
;; 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)
|
||||
n (get-in c [:symbols :main :nodes :shape])
|
||||
was (gesture/pivot (gesture/values n 0 nil)
|
||||
|
|
@ -472,12 +474,13 @@
|
|||
(is (near? (vec (radii c)) (vec (radii out)))
|
||||
"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
|
||||
;; a peg over it, so this is that advice being true. An anchor could never have
|
||||
;; been written here: `node/local!`'s translation is `pos − M·a`, and under the
|
||||
;; head's measured similarity `M` is nowhere near the identity, so writing one
|
||||
;; would have moved the whole face. A peg's channels are its own.
|
||||
;; a peg over it, so this is that advice being true: a peg's channels are its
|
||||
;; own, so the hand transform composes outside the measurement and the next
|
||||
;; regenerate still owns what it owns. This is the row of the table a pivot
|
||||
;; 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
|
||||
head (get-in c [:symbols :face-1 :nodes :head])]
|
||||
(is (node/measured? head))
|
||||
|
|
@ -495,43 +498,65 @@
|
|||
(is (near? (drawn-at c st f) (drawn-at out st f))
|
||||
(str "frame " f " moved when the peg appeared"))))))
|
||||
|
||||
(deftest moving-a-pegs-pivot-moves-nothing-under-it
|
||||
;; THE OPERATION A PLAIN DRAG IS NOT. Dragging a peg writes its `pos`, and a
|
||||
;; peg is a parent, so that carries its children with it — a translate. Putting
|
||||
;; the pivot somewhere else has to leave the picture alone, and does, because
|
||||
;; only `local(peg) · pinv(child)` reaches the child and `repivot` preserves
|
||||
;; that product.
|
||||
(let [c (:clip (nest/peg (shaped) nil :main [:shape] 0 pg))
|
||||
was (drawn-at c 0)
|
||||
r (nest/repivot c nil :main [pg] 0 [10 90])
|
||||
out (:clip r)]
|
||||
(is (nil? (:refused r)) (:refused r))
|
||||
(is (= [10 90] (:value (get-in out [:symbols :main :nodes pg :channels [:xform :pos]])))
|
||||
"the peg's origin is where it was put")
|
||||
(is (near? was (drawn-at out 0)) "and not one point of the child moved")
|
||||
(deftest moving-a-pivot-moves-nothing
|
||||
;; 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. Putting the pivot
|
||||
;; somewhere else has to leave the picture exactly alone, and does: `repivot`
|
||||
;; writes the pivot and the position together so that `pos + a` lands on the
|
||||
;; point chosen and `T(pos)·T(a)·M·T(-a)` is unchanged as a map.
|
||||
;;
|
||||
;; ON ANY NODE, which is the difference from what this used to be. It was
|
||||
;; `nest/repivot`, which moved a PEG's origin and rewrote each child's `:pinv`
|
||||
;; to compensate — available on pegs alone, and refused outright on a peg whose
|
||||
;; position was animated, since one stored parent-inverse cannot correct for a
|
||||
;; parent that moves. A pivot inside the node's own transform has nothing to
|
||||
;; correct in anybody else's, so neither restriction survives.
|
||||
(doseq [[what c path target]
|
||||
[["a peg" (:clip (nest/peg (shaped) nil :main [:shape] 0 pg)) [pg] pg]
|
||||
["a drawing" (shaped) [:shape] :shape]
|
||||
["a peg whose position is animated"
|
||||
(assoc-in (:clip (nest/peg (shaped) nil :main [:shape] 0 pg))
|
||||
[:symbols :main :nodes pg :channels [:xform :pos]]
|
||||
(ch/keyed {0 [0 0] 5 [40 10]} :linear))
|
||||
[pg] pg]]]
|
||||
(let [was (drawn-at c 0)
|
||||
pl (nest/placement c nil :main path 0)
|
||||
v0 (gesture/values (get-in c [:symbols :main :nodes target]) 0 nil)
|
||||
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.
|
||||
(let [v0 (gesture/values (get-in out [:symbols :main :nodes pg]) 0 nil)
|
||||
piv (gesture/pivot v0 nil)
|
||||
spun (gesture/apply-values out :main pg 0 (gesture/turn v0 piv 0.5))
|
||||
;; 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 (near? [10 90] piv) "the derived pivot followed it")
|
||||
(is (not (near? (drawn-at out 0) (drawn-at spun 0))) "the child turned")
|
||||
(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)))
|
||||
"about the pivot's new home, keeping every distance from it"))))
|
||||
(str what ": the turn was not about the pivot"))))))
|
||||
|
||||
(deftest a-pegs-pivot-cannot-be-moved-once-its-position-is-animated
|
||||
;; Refused rather than quietly wrong: the compensation depends on the peg's own
|
||||
;; transform, so a keyed position needs a different `pinv` per frame and one
|
||||
;; stored matrix is not it.
|
||||
(deftest a-pivot-is-a-choice-and-editing-the-symbol-does-not-revise-it
|
||||
;; THE RULE, and the reason the pivot is stored rather than derived per drag:
|
||||
;; once something has been animated, adding to what it draws must not alter
|
||||
;; what it turns about. A derived pivot followed the drawing, so drawing one
|
||||
;; more shape inside a symbol silently moved every keyed spin of every instance
|
||||
;; of it.
|
||||
(let [c (:clip (nest/peg (shaped) nil :main [:shape] 0 pg))
|
||||
keyed (assoc-in c [:symbols :main :nodes pg :channels [:xform :pos]]
|
||||
(ch/keyed {0 [10 10] 5 [40 10]} :linear))
|
||||
r (nest/repivot keyed nil :main [pg] 0 [10 90])]
|
||||
(is (string? (:refused r)))
|
||||
(is (re-find #"animated" (:refused r)) (:refused r))
|
||||
(is (re-find #"peg over it" (:refused r)) (:refused r)))
|
||||
(testing "and a node with nothing under it has no pivot to move"
|
||||
(let [r (nest/repivot (shaped) nil :main [:shape] 0 [10 90])]
|
||||
(is (re-find #"nothing hangs off it" (:refused r)) (:refused r)))))
|
||||
v0 (gesture/values (get-in c [:symbols :main :nodes pg]) 0 nil)
|
||||
c (gesture/apply-values c :main pg 0 (gesture/repivot v0 [10 90]))
|
||||
;; another shape in the same symbol, well away from the first
|
||||
grown (assoc-in c [:symbols :main :nodes :far]
|
||||
{:id :far :kind :poly :z "z9" :parent pg
|
||||
:channels {[:geom :pts] (ch/framed [200 10 220 10 210 30])
|
||||
[: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")))
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@
|
|||
(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- local [& {:keys [pos rot scale skew]
|
||||
:or {pos [0 0] rot 0 scale [1 1] skew [0 0]}}]
|
||||
(node/local! (node/mat) pos rot scale skew))
|
||||
(defn- local [& {:keys [pos pivot rot scale skew]
|
||||
:or {pos [0 0] pivot [0 0] rot 0 scale [1 1] skew [0 0]}}]
|
||||
(node/local! (node/mat) pos pivot rot scale skew))
|
||||
|
||||
;; ---- the transform, component by component ----
|
||||
|
||||
|
|
@ -28,19 +28,38 @@
|
|||
(is (close-pt? [0 10] (pt (local :rot (/ js/Math.PI 2)) 10 0)))
|
||||
(is (= [20 21] (pt (local :scale [2 3]) 10 7))))
|
||||
|
||||
(deftest rotation-and-scale-happen-about-the-nodes-own-origin
|
||||
;; THERE IS NO :anchor, and this is the half of that which lives here: a local
|
||||
;; 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.
|
||||
(deftest rotation-and-scale-happen-about-the-nodes-pivot
|
||||
;; With no pivot, the node's own origin:
|
||||
(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 10] (pt m 10 0)) "and the rest turns about it"))
|
||||
(let [m (local :scale [2 2])]
|
||||
(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
|
||||
;; 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"))
|
||||
|
||||
(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
|
||||
;; 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,
|
||||
;; 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]))
|
||||
R (fn [t] (js/Float64Array. #js [(js/Math.cos t) (js/Math.sin t)
|
||||
(- (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]))
|
||||
step (fn [acc m] (node/mul! (node/mat) acc m))
|
||||
want (reduce step (T (nth pos 0) (nth pos 1))
|
||||
[(R rot) (K skew) (S scale)])
|
||||
got (local :pos pos :rot rot :scale scale :skew skew)]
|
||||
[(T (nth piv 0) (nth piv 1)) (R rot) (K skew) (S scale)
|
||||
(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))
|
||||
(str (vec (array-seq want)) " vs " (vec (array-seq got))))))
|
||||
|
||||
(deftest an-anchor-is-a-peg-written-inline
|
||||
;; The identity that makes the deletion safe rather than a trade: a node with
|
||||
;; anchor `a` is exactly a peg at `pos + a` carrying the rotation and scale,
|
||||
(deftest a-pivot-is-a-peg-written-inline
|
||||
;; The identity that was read as an argument for having no pivot at all: a node
|
||||
;; 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
|
||||
;; mantissa — so everything the anchor could express, a parent already could,
|
||||
;; and the parent can also be keyed, shared, and put over a measured channel.
|
||||
;; mantissa — and still true, which is why a peg remains the way to SHARE a
|
||||
;; 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]
|
||||
;; what T(pos)·T(a)·R·K·S·T(-a) used to produce, built from the parts
|
||||
anchored (reduce (fn [acc m] (node/mul! (node/mat) acc m))
|
||||
;; T(pos)·T(a)·R·K·S·T(-a), built from the parts
|
||||
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 a 0) (nth a 1)])
|
||||
(local :rot rot :scale scale)
|
||||
|
|
@ -86,9 +112,13 @@
|
|||
;; the same thing as a peg and a child
|
||||
peg (local :pos (mapv + pos a) :rot rot :scale scale)
|
||||
child (local :pos (mapv - a))
|
||||
world (node/world! (node/mat) peg nil child (node/mat))]
|
||||
(is (every? (fn [i] (close? (aget anchored i) (aget world i))) (range 6))
|
||||
(str (vec (array-seq anchored)) " vs " (vec (array-seq world))))))
|
||||
world (node/world! (node/mat) peg nil child (node/mat))
|
||||
;; and the field itself, which is the same matrix in one node
|
||||
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
|
||||
;; Evaluation composes world := parent · local with dest aliasing local, so
|
||||
|
|
@ -196,12 +226,17 @@
|
|||
(doseq [k (disj node/implemented-kinds :audio)]
|
||||
(is (contains? (get node/valid-paths k) [:xform :skew]) (str k))))
|
||||
|
||||
(deftest there-is-no-anchor-anywhere-in-the-shape
|
||||
;; The deletion, asserted rather than assumed. A document carrying one is not
|
||||
;; migrated, it is invalid — `problems` rejects the channel on every kind — so
|
||||
;; there is no shape in which a stale stored pivot can come back.
|
||||
(doseq [k node/implemented-kinds]
|
||||
(deftest the-pivot-is-in-the-shape-and-the-old-name-for-it-is-not
|
||||
;; The field is back, under the name the model uses for it everywhere else, and
|
||||
;; the schema-6 name is still refused — the composition around it CHANGED, from
|
||||
;; T(pos)·M·T(-a) to T(pos)·T(a)·M·T(-a), so reading one as the other would
|
||||
;; 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 (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? node/defaults [:xform :anchor])))
|
||||
(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
|
||||
;; is the first thing a person opening an old project sees, and "not valid on
|
||||
;; 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)
|
||||
(str "the generic path complaint should not also fire: " (pr-str ps)))))
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
[arthur.demo.take :as take]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.creation :as creation]
|
||||
[arthur.domain.gesture :as gesture]
|
||||
[arthur.domain.leaf :as leaf]
|
||||
[arthur.domain.nest :as nest]
|
||||
[arthur.domain.node :as node]
|
||||
|
|
@ -179,16 +180,20 @@
|
|||
(defn- middle-on-stage
|
||||
"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
|
||||
the scaled middle moves with the scale. This used to be `pos + anchor`: with
|
||||
the anchor ON the middle, `T(a)·S(k)·T(-a)` left that point alone whatever `k`
|
||||
was, so the sum read correctly without the scale appearing in it at all. That
|
||||
is the work `events/ui`'s `fitted` now does explicitly."
|
||||
Through the composition itself — `gesture/local-of` on the node's own channels,
|
||||
which is `node/local!` and therefore whatever the model says the transform is —
|
||||
rather than through `pos + scale · middle` written out here. A trace's own
|
||||
coordinates are its pixels, so the middle is half its size, and where that
|
||||
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]
|
||||
(let [{:keys [width height]} (clip/symbol doc (node/source n))]
|
||||
(mapv + (get-in n [:channels [:xform :pos] :value])
|
||||
(mapv * (get-in n [:channels [:xform :scale] :value])
|
||||
[(/ width 2) (/ height 2)]))))
|
||||
(let [{:keys [width height]} (clip/symbol doc (node/source n))
|
||||
m (gesture/local-of (gesture/values n 0 nil))
|
||||
out (js/Float64Array. 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
|
||||
(let [id (store/install! {:clip (clip/blank) :store {}} "drop-tracing")
|
||||
|
|
|
|||
|
|
@ -135,12 +135,12 @@ try {
|
|||
const dom = await evalJS(`
|
||||
const q = s => document.querySelector(s);
|
||||
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();
|
||||
return {
|
||||
handles: !!g,
|
||||
pegGrab: !!grab,
|
||||
pegGrabEvents: grab ? getComputedStyle(grab).pointerEvents : null,
|
||||
pivotGrab: !!grab,
|
||||
pivotGrabEvents: grab ? getComputedStyle(grab).pointerEvents : null,
|
||||
knobs: document.querySelectorAll('.paint-overlay .handles .knob').length,
|
||||
corners: document.querySelectorAll('.paint-overlay .handles .corner').length,
|
||||
boxes: document.querySelectorAll('.paint-overlay .handles .box').length,
|
||||
|
|
@ -149,8 +149,8 @@ try {
|
|||
`);
|
||||
console.log('dom:', JSON.stringify(dom));
|
||||
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.pegGrabEvents && dom.pegGrabEvents !== 'none', 'and it accepts pointer events', String(dom.pegGrabEvents));
|
||||
ok(dom.pivotGrab, 'there is a .pivot-grab to drag it by');
|
||||
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.corners === 4, 'four scale corners', String(dom.corners));
|
||||
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.
|
||||
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 corner = document.querySelector('.paint-overlay .handles .corner');
|
||||
const cr = corner && corner.getBoundingClientRect();
|
||||
|
|
@ -218,11 +218,12 @@ try {
|
|||
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
|
||||
arthur.domain.palette.index_of, null);
|
||||
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.
|
||||
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();
|
||||
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')),
|
||||
arthur.domain.palette.index_of, null);
|
||||
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 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`,
|
||||
`drift ${drift}`);
|
||||
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.
|
||||
{
|
||||
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();
|
||||
return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null;
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -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 .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; }
|
||||
/* A peg draws nothing, so the cross is the only thing to grab it by. */
|
||||
.paint-overlay .handles .peg-grab { fill: transparent; stroke: none; cursor: move; }
|
||||
/* While an alt-drag is placing it. */
|
||||
/* The cross is a handle on every node: a plain drag moves it, ctrl/cmd puts the
|
||||
pivot somewhere else. On a peg, which draws nothing, it is the only thing
|
||||
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 .marquee { fill: rgba(230, 202, 139, 0.12); stroke: #e6ca8b; stroke-width: 0.6; stroke-dasharray: 2 1; pointer-events: none; }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue