A drawing's origin is the middle of what it draws

A shape keyed from the bottom left to the top centre with a 360° turn on the way
left the stage completely in the middle of the spin and came back. The keys were
right and every frame between them was wrong, which is the signature of a wrong
pivot and a full turn: 0° and 360° are the only two frames where a wrong pivot
cannot be seen at all.

`paint/new-shape` stored a stroke EXACTLY AS DRAWN, in the containing symbol's
coordinates, and wrote no `pos`. So a drawing's origin was the SYMBOL's origin —
on the stage, its top-left corner. `node/local!` turns and scales about the
node's own origin and nothing else, deliberately, since `[:xform :anchor]` was
deleted in 925c12f. A node whose origin is nowhere near its content therefore
turns about nowhere near its content: the reported shape orbited at a radius of
126 px on a 320x200 stage.

It could not be seen while a drag was the only way to turn something, because
`gesture/about` solves for the `pos` that holds the chosen pivot still and
`turn` wrote it alongside the rotation — exactly right on the frame of the drag.
But that solution is `p' = c + R(θ)(p − c)`, an ARC, and `pos` interpolates along
the CHORD. Right on a drag, right on a key, wrong on every frame between two.

So `paint/centred` splits a stroke into a ring about its own middle and the `pos`
that puts it back, and `new-shape` is the one place every drawing is born — the
pen, the brush, and each piece the eraser leaves. The pivot rule is unchanged,
the middle of what the node draws; for a drawing that point is now its ORIGIN, so
`gesture/at-origin?` holds, `turn` writes `rot` alone, `scale` writes `scale`
alone, and a keyed turn is right on every frame. `pos` goes back to being the
motion path it reads as. Hand-authored scenes were always written this way:
`demo/scene.edn`'s card is `[-44 -30 44 -30 44 30 -44 30]` with its place in
`pos`.

NOT the universal rule, and `a-face-part-scales-about-its-own-middle` is why. A
measured part's points and position are dense tier-2 geometry in the footage's
space and cannot be re-originated, so its pivot is not its origin and `about` is
the only thing that will hold it; the same is true of an instance, whose origin
IS its symbol's coordinate system. Both still drag correctly about their middle,
both are inexact if that drag is keyed, and for both a pivot that has to persist
or be keyed is a peg — which is a node, so its pivot is its own origin, so it
collapses again one level up.

`cut/erase` took EVERY leftover piece back through `world⁻¹`, the cut shape's own
coordinates, and handed the offcuts to `new-shape`, which gives them a fresh
identity transform. Those two spaces coincide only while a shape has `pos [0 0]`,
which was every shape, so erasing anything that had been moved already scattered
its offcuts, silently. The kept piece comes back through `world⁻¹` and the new
ones through `parent⁻¹`, the space a node's `pos` lives in.

And `::adjust-last` re-traces the same stroke from stage pixels, so it goes
through `paint/place-points` rather than writing symbol-space points into a node
that now has a position of its own.

No schema change: the same fields, better values. An existing document keeps
evaluating exactly as it does now.

`a-keyed-turn-holds-its-pivot-between-its-keys` checks all 31 frames of the
tween. Checking the keys is what let this through. 604 CLJS tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-10-06 10:37:31 -04:00
parent 2e021a13eb
commit e7f5f82845
11 changed files with 344 additions and 104 deletions

View file

@ -370,11 +370,40 @@ q = M⁻¹(c − p) the material point under c
p' = c − M'·q = c − M'·M⁻¹(c − p)
```
so a turn writes `pos` **and** `rot`. Nothing is cached, so nothing can go stale:
the stored anchor was the centre of what the node drew, captured once at creation,
while the box beside it was recomputed every render — so on anything edited since
it was made, the cross and the box visibly disagreed and the pivot was wrong.
A symbol with more than one node diverged on the first edit.
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**:
| | 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.
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 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
@ -383,7 +412,7 @@ three things a derived pivot cannot do:
| want | why a derived pivot cannot | what the peg does |
| --- | --- | --- |
| a pivot that persists — an arm turning about its shoulder | a gesture's pivot is the middle of the drawing and lives for one drag | the peg's `pos`, static, nowhere near the middle |
| a pivot that 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 |

View file

@ -49,26 +49,40 @@
The shape keeps the biggest piece, on a key at that frame — made there if
the frame had none, so the keys either side keep their points. Every other
piece is a new shape of the same colour, its id the next of `ids`. A shape
cut away entirely is deleted."
cut away entirely is deleted.
TWO SPACES COME BACK OUT, and they are not the same one. The piece the shape
KEEPS is written into that shape's own geometry, so it comes back through
`world⁻¹`, the node's own coordinates. Every other piece becomes a NEW node
beside it, whose points are read against its own fresh transform, so those come
back through `parent⁻¹` — the space a node's `pos` lives in, which is what
`paint/new-shape` takes and centres.
Both were `world⁻¹` before, and that was wrong for the new pieces by exactly
the cut shape's own transform. It could not be seen while every drawing had an
identity transform, which was true of all of them for as long as a stroke was
stored exactly as drawn: the two spaces coincided, so erasing a shape nobody had
moved worked, and erasing one somebody had moved scattered the offcuts."
[clip store open f paths cutters ids]
(first
(reduce
(fn [[clip ids] path]
(let [{:keys [sid id frame world]} (nest/placement clip store open path f)
(let [{:keys [sid id frame world parent]} (nest/placement clip store open path f)
n (get-in clip [:symbols sid :nodes id])
geom (get-in n [:channels paint/geometry])
inv (when world (node/invert world))
left (when (and inv geom)
up (when parent (node/invert parent))
left (when (and inv up geom)
(cut (through world (channel/value-at geom frame store)) cutters))]
(cond
(nil? left) [clip ids]
(empty? left) [(nest/delete-node clip sid id) ids]
:else
(let [[keep & more] (map #(through inv %) left)
(let [[keep & more] left
colour (channel/value-at (get-in n [:channels [:style :color]]) frame store)
kept (-> (if (contains? (:keys geom) frame) clip (paint/add-key clip sid id frame))
(paint/set-points sid id frame keep))]
[(reduce (fn [c [nid pts]] (paint/new-shape c sid nid frame pts colour))
(paint/set-points sid id frame (through inv keep)))]
[(reduce (fn [c [nid pts]] (paint/new-shape c sid nid frame (through up pts) colour))
kept (map vector ids more))
(drop (count more) ids)]))))
[clip ids] paths)))

View file

@ -7,20 +7,32 @@
is in the placement's matrices, so a shape five symbols down moves under the
pointer like one on top.
A PIVOT IS A FACT ABOUT A DRAG, NOT A FIELD ON A NODE, and that is the whole
reason `[:xform :anchor]` is gone. Turning about a point other than the node's
origin needs no stored anchor: it is one equation, `about` below, for the `pos`
that holds the chosen point still — and a turn therefore writes `pos` AND
`rot`, where it used to write `rot` alone and lean on a stored anchor to place
the result. Nothing is cached, so nothing goes stale when the drawing is
edited, which is exactly what the stored anchor could not manage: it was the
centre of what the node drew, captured once at creation, and the stage drew the
selection box from the live centre right beside it.
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.
WHERE THE PIVOT COMES FROM is the caller's, and `ui/stage` has one rule: the
middle of what the node draws, or its own origin when it draws nothing. A peg
draws nothing, so a peg turns about where it was put — which is what makes a
peg the way to keep a pivot, key one, or have one over a measured transform.
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.
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.
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.
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
@ -93,9 +105,13 @@
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`.
This is also why an anchor was never needed: `T(p)·T(a)·M·T(-a)` is this same
identity with `a` as the pivot, solved once at creation and then stored. Solving
it per drag costs one matrix inverse and keeps no state to be invalidated."
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."
[v v' c]
(let [m (linear v)]
(when-let [inv (node/invert m)]
@ -107,22 +123,34 @@
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 AND PEGS ALIKE, which is what makes a peg the answer for a
pivot rather than a second mechanism beside one. A `:group` draws nothing, so
`bounds` is nil, so this is its `pos`: a peg turns about where it was put. That
is Toon Boom's rule, and it is the whole of why a peg can hold a pivot that an
anchor could not — the peg's `pos` is an ordinary channel, so it can be keyed,
dragged, and sit above a measured transform.
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.
A shape turns about the middle of the box the stage draws round it, and that is
the same `pick/bounds-of` on the same frame rather than a value that agrees with
it by hand: the box and the cross cannot drift apart, because they are one
computation."
FOR A DRAWING THIS IS ITS ORIGIN, and that is not a coincidence to keep up
either — `paint/centred` puts a shape's origin on the middle of what it draws
when it is drawn. Which is what makes the ordinary case free: `at-origin?`
holds, so a turn writes `rot` alone and a keyed turn is right between its keys.
A `:group` draws nothing, so `bounds` is nil and this is its `pos`, which is
the same statement — a peg's origin is where it was put."
[v bounds]
(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?
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."
[v c]
(let [[dx dy] (mapv - c (:pos v))]
(< (js/Math.hypot dx dy) 1e-6)))
(defn move
"The node's position with the drag carried from stage point `p0` to `p1`."
[{:keys [parent]} {:keys [pos]} p0 p1]
@ -139,23 +167,36 @@
(defn turn
"The node turned by `da` radians about parent-space point `c`: the rotation,
and the position that holds `c` still.
and — only if `c` is not the node's own origin — the position that holds `c`
still.
TWO CHANNELS, where this used to write one. The second is not a correction
applied afterwards — it is what makes the turn happen about `c` at all, and
with no stored anchor there is nothing else for it to come out of."
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.
`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."
[v c da]
(let [v' (update v :rot + da)]
(cond-> {[:xform :rot] (:rot v')}
c (into (when-let [p (about v v' c)] {[:xform :pos] p})))))
(and c (not (at-origin? v c)))
(into (when-let [p (about v v' c)] {[:xform :pos] p})))))
(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 the position that holds `c` still.
both when `uniform?` — and, only if `c` is not the node's own origin, the
position that holds `c` still.
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."
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."
[{:keys [world]} v c p0 p1 uniform?]
(when-let [winv (node/invert world)]
(when-let [minv (node/invert (linear v))]
@ -170,7 +211,8 @@
s' (if r (mapv #(* r %) s) (mapv * s (map k a b)))
v' (assoc v :scale s')]
(cond-> {[:xform :scale] s'}
c (into (when-let [p (about v v' c)] {[:xform :pos] p})))))))
(and c (not (at-origin? v c)))
(into (when-let [p (about v v' c)] {[:xform :pos] p})))))))
(defn scale-by
"The node scaled by factor `k` on both axes about parent-space point `c`, and

View file

@ -655,9 +655,10 @@
restrictions. It is the answer to all three things a derived pivot cannot do:
A PIVOT THAT PERSISTS. A gesture's pivot is the middle of what the node draws
and lives for one drag. An arm that turns about its shoulder wants a pivot
that outlasts the drag and is nowhere near the middle, and it wants it on
every frame, not re-derived per drag.
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 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

View file

@ -1,11 +1,58 @@
(ns arthur.domain.paint
"Small authored polygon operations, each on a named symbol. Paint nodes read
their symbol's frames directly; a roto instance's exposure and picture sampling
must not quantise a hand edit."
must not quantise a hand edit.
A SHAPE'S ORIGIN IS THE MIDDLE OF WHAT IT DRAWS. Points arrive here in the
space the node's `[:xform :pos]` lives in — that is what `nest/drawn-inside`
hands over — and `centred` splits them into a ring about the origin and the
`pos` that puts it back where it was drawn. See `centred`: it is the invariant
the whole pivot story rests on, and this namespace is where it is established."
(:require [arthur.domain.channel :as channel]))
(def geometry [:geom :pts])
(defn middle
"The middle of the box round flat points `pts`, in their own space."
[pts]
(let [xs (take-nth 2 pts)
ys (take-nth 2 (rest pts))]
[(/ (+ (apply min xs) (apply max xs)) 2)
(/ (+ (apply min ys) (apply max ys)) 2)]))
(defn centred
"Flat points `pts`, given in the space a node's `pos` lives in, as
`[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.
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.
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
the paint tool joining the convention rather than a new one."
[pts]
(let [[cx cy] (middle pts)]
[(into [] (map-indexed (fn [i v] (- v (if (even? i) cx cy)))) pts)
[cx cy]]))
(defn shapes [clip sid]
(->> (get-in clip [:symbols sid :nodes])
(filter (fn [[_ node]] (:paint? node)))
@ -16,22 +63,28 @@
(let [frames (sort (keys (:keys ch)))]
(or (last (take-while #(<= % frame) frames)) (first frames))))
(defn new-shape [clip sid id frame points color]
(defn new-shape
"`clip` with a shape drawn at `points` — in the space the new node's `pos` will
live in, which is the symbol's own coordinates — on frame `frame` of `sid`.
THE RING IS CENTRED AND THE MIDDLE GOES IN `pos`, which is the whole of
`centred`: the shape's origin is what it draws, so it turns and scales about
itself with nothing stored and nothing solved. This is the one place every
drawing is born — the pen, the brush, and each piece the eraser leaves — so it
is the one place the invariant has to be established."
[clip sid id frame points color]
(let [end (get-in clip [:symbols sid :frames])
z (str "z" (js/Date.now) "-" (name id))]
(if (and (<= 0 frame) (< frame end) (>= (count points) 6)
(even? (count points)))
(let [[ring pos] (centred points)]
(assoc-in clip [:symbols sid :nodes id]
{:id id :name (str "shape " (inc (count (shapes clip sid))))
:kind :poly :paint? true :parent nil :z z
:span [frame end]
;; No pivot is written, and none is needed: a drawing turns
;; and scales about the middle of what it draws NOW, which
;; `ui/stage` derives per drag. The anchor this used to store
;; was the middle of the FIRST key's points, so a drawing that
;; travelled or was redrawn pivoted about where it had once been.
:channels {geometry (channel/keyed {frame points} :hold)
[:style :color] (channel/framed color)}})
:channels {geometry (channel/keyed {frame ring} :hold)
[:xform :pos] (channel/framed pos)
[:style :color] (channel/framed color)}}))
clip)))
(defn add-key [clip sid id frame]
@ -91,10 +144,35 @@
pts))))
(defn set-points
"Key `key-frame` of the shape is `points`, whatever it held: a stroke being
re-simplified to another count."
"Key `key-frame` of the shape is `points`, whatever it held, IN THE NODE'S OWN
COORDINATES: a cut writing back the piece it kept.
The node's origin is left where it is, which is why this is the form a cut
uses. Re-centring would have to move `pos` to compensate, and `pos` can be
keyed and the drawing can be keyed, so there is no one middle to move it to —
the only exact moment for that is while the transform is static, which is what
`place-points` is for."
[clip sid id key-frame points]
(let [path [:symbols sid :nodes id :channels geometry :keys key-frame]]
(if (get-in clip path)
(assoc-in clip path points)
clip)))
(defn place-points
"Key `key-frame` of the shape is `points`, GIVEN IN THE SPACE ITS `pos` LIVES
IN: centred as `new-shape` centres them, with `pos` moved to put them back.
What a re-fit writes. Adjusting a brush stroke's fit re-traces the same stroke
from the stage pixels it was painted in, so its points arrive in the same space
they did when the shape was made, and writing them as the node's own would move
the drawing by its own position. Only ever used on a shape a stroke has just
made, which is why moving `pos` is exact here: nothing is keyed yet."
[clip sid id key-frame points]
(let [path [:symbols sid :nodes id :channels geometry :keys key-frame]]
(if (get-in clip path)
(let [[ring pos] (centred points)]
(-> clip
(assoc-in path ring)
(assoc-in [:symbols sid :nodes id :channels [:xform :pos]]
(channel/framed pos))))
clip)))

View file

@ -797,7 +797,7 @@
(erasing db before pieces fit paths ids)
(let [at (landing-into db down (stroke-points pieces fit))]
(edit/transaction
db (fn [c] (reduce (fn [c [id pts]] (paint/set-points c sid id frame pts))
db (fn [c] (reduce (fn [c [id pts]] (paint/place-points c sid id frame pts))
c (map vector ids (:rings at)))))))]
(update-in db [:ui :last] assoc :fit fit :after (saved db)))))))

View file

@ -85,7 +85,7 @@
(defn- ghost
"Where a drag out of the pool would land: the outline of its first frame,
dashed, and a cross on the middle it will pivot about, which goes under the
dashed, and a cross on the middle it will land on, which goes under the
pointer. Drawn from the drag's own outline rather than by resolving anything,
so hovering costs one re-render and no evaluation. The cross is always drawn: a
face symbol is a fraction of a pixel until what places it scales it up, and
@ -161,7 +161,10 @@
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."
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."
[{: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)]
@ -267,15 +270,18 @@
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. It used to be drawn from the stored
`[:xform :anchor]` while the box was recomputed every render, which is what
made the pivot visibly wrong on anything that had been edited since it was
made. 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 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 NOT A HANDLE FOR THE PIVOT. There is nothing to drag it to: no
pivot is stored, so moving it could only mean something for the next drag and
would be gone by the one after. A pivot that has to persist is a peg.
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.
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,

View file

@ -12,6 +12,19 @@
(let [b (:buf (r/fill-poly-buf! (r/make 60 60) ring (quot (count ring) 2) 1))]
(fn [x y] (aget b (+ x (* y 60))))))
(defn- placed
"Node `id`'s drawing on frame 0 back in the space it was drawn in: its own
points plus its `pos`.
A shape's points are about its OWN ORIGIN and its `pos` says where that origin
is, so the ring to rasterise in stage pixels is the sum — `paint/centred`. The
cut is still checked in the pixels it was made in; it is read back out through
the transform the shape now has, rather than by assuming it has none."
[nodes id]
(let [[cx cy] (channel/value-at (get-in nodes [id :channels [:xform :pos]]) 0 nil)]
(into [] (map-indexed (fn [i v] (+ v (if (even? i) cx cy))))
(channel/value-at (get-in nodes [id :channels paint/geometry]) 0 nil))))
(deftest a-cut-off-the-side-keeps-the-rest-with-the-cut-edge-as-its-points
(let [[left & more] (cut/cut square [[[40 0 60 0 60 60 40 60]]])]
(is (empty? more))
@ -46,10 +59,13 @@
(let [clip (-> (clip/blank) (paint/new-shape :main :s 0 square 3))
out (cut/erase clip nil :main 0 [[:s]] [[[25 0 30 0 30 60 25 60]]] [:t :u])
nodes (get-in out [:symbols :main :nodes])
pts #(channel/value-at (get-in nodes [% :channels paint/geometry]) 0 nil)]
pts #(placed nodes %)]
(is (= #{:s :t} (set (keys nodes))))
(is (= 1 ((ink (pts :s)) 40 30)))
(is (= 1 ((ink (pts :t)) 15 30)))
(is (= 1 ((ink (pts :t)) 15 30))
"the offcut is a new shape of its own, and is where it was cut from — its
points come back through the space its `pos` lives in, not through the
transform of the shape it was cut out of")
(is (= 3 (channel/value-at (get-in nodes [:t :channels [:style :color]]) 0 nil)))
(is (= {} (get-in (cut/erase clip nil :main 0 [[:s]] [[[0 0 60 0 60 60 0 60]]] [])
[:symbols :main :nodes]))
@ -60,4 +76,5 @@
out (cut/erase clip nil :main 5 [[:s]] [[[40 0 60 0 60 60 40 60]]] [])
ks (get-in out [:symbols :main :nodes :s :channels paint/geometry :keys])]
(is (= #{0 5 10} (set (keys ks))))
(is (= square (ks 0) (ks 10)))))
(is (= (first (paint/centred square)) (ks 0) (ks 10))
"the keys either side keep the shape's own points, untouched")))

View file

@ -43,6 +43,9 @@
[c st sid id frame v0]
(gesture/pivot v0 ((pick/bounds-of c st sid (get-in c [:symbols sid :nodes id])) frame)))
(defn- at [m p] (let [out (js/Float64Array. 2)]
(vec (array-seq (node/apply-pt! out 0 m (first p) (second p))))))
(defn- dragged [c path f vs-fn]
(let [{:keys [sid id frame] :as pl} (nest/placement c nil :main path 16)
v0 (gesture/values (get-in c [:symbols sid :nodes id]) frame nil)]
@ -71,20 +74,56 @@
(get-in out [:symbols :main :nodes :shape :channels [:xform :pos] :interp])))))
(deftest turning-keeps-the-pivot-where-it-is
;; The shape's geometry is [0 0 10 0 5 10], so the middle of what it draws is
;; (5, 5) in its own coordinates. NOTHING STORES THAT: the turn solves for the
;; `pos` that holds it still, which is the whole of why the rest goes round it.
;; The shape was drawn at [0 0 10 0 5 10], so the middle of what it draws is
;; (5, 5) of the space it was drawn in — and `paint/centred` made that the
;; node's OWN ORIGIN, which is (0, 0) in its own coordinates. So the pivot is
;; the origin, and the turn is one channel: there is no `pos` to solve for,
;; because `node/local!` already turns about exactly this point.
(let [c (two-down)
path [u v :shape]
{:keys [world]} (nest/placement c nil :main path 16)
pivot #(let [out (js/Float64Array. 2)]
(vec (array-seq (node/apply-pt! out 0 (:world (nest/placement % nil :main path 16)) 5 5))))
(vec (array-seq (node/apply-pt! out 0 (:world (nest/placement % nil :main path 16)) 0 0))))
turned (dragged c path 16 #(gesture/turn %2 %3 0.7))]
(is (some? world))
(is (near? (pivot c) (pivot turned)) "the middle of the drawing stays put")
(is (not (near? (drawn c path) (drawn turned path))) "and the rest goes round it")
(is (contains? (get-in turned [:symbols :box :nodes :shape :channels]) [:xform :pos])
"a turn writes pos as well as rot — that is what makes it happen about a point")))
(is (= #{[:xform :rot]} (set (keys (gesture/turn (gesture/values
(get-in c [:symbols :box :nodes :shape]) 4 nil)
(pivot-of c nil :box :shape 4
(gesture/values
(get-in c [:symbols :box :nodes :shape]) 4 nil))
0.7))))
"a turn about the node's own origin writes rot ALONE — a pos written
beside it would be solved for this frame and interpolated along the
chord of an arc between keys, which is a keyed spin leaving the stage")))
(deftest a-keyed-turn-holds-its-pivot-between-its-keys
;; THE BUG, as it was reported: a shape keyed from the bottom left to the top
;; centre with a 360° turn on the way flew right off the stage in the middle of
;; the spin and came back. 0° and 360° are the two frames where a wrong pivot
;; cannot be seen, so the keys looked right and every frame between them did
;; not. Here the whole tween is checked, which is the only way this is caught.
(let [drawn-at [100 80 140 80 140 110 100 110]
c (-> (clip/blank)
(paint/new-shape :main :shape 0 drawn-at :brow)
;; Keyed by hand, as the inspector keys: bottom left to top
;; centre, one full turn on the way.
(assoc-in [:symbols :main :nodes :shape :channels [:xform :pos]]
(ch/keyed {0 [60 150] 30 [160 40]} :linear))
(assoc-in [:symbols :main :nodes :shape :channels [:xform :rot]]
(ch/keyed {0 0 30 (* 2 js/Math.PI)} :linear)))
middle (fn [f]
(let [{:keys [world]} (nest/placement c nil :main [:shape] f)
[x0 y0 x1 y1] ((pick/bounds-of c nil :main
(get-in c [:symbols :main :nodes :shape])) f)]
(at world [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)])))]
(doseq [f (range 0 31)]
(let [[x y] (middle f)
t (/ f 30)]
(is (near? [x y] [(+ 60 (* t 100)) (- 150 (* t 110))])
(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 scaling-takes-the-grabbed-point-to-the-pointer
(let [c (two-down)
@ -92,13 +131,15 @@
{:keys [world]} (nest/placement c nil :main path 16)
out (js/Float64Array. 2)
at #(vec (array-seq (node/apply-pt! out 0 %1 %2 %3)))
p0 (at world 10 0)
;; (5, -5) of the shape's own coordinates is the point drawn at (10, 0):
;; the ring is centred, so what was drawn is offset by its own middle.
p0 (at world 5 -5)
p1 [(+ (first p0) 3) (- (second p0) 5)]
grown (dragged c path 16 #(gesture/scale %1 %2 %3 p0 p1 false))
w2 (:world (nest/placement grown nil :main path 16))]
(is (near? p1 (at w2 10 0))
(is (near? p1 (at w2 5 -5))
"the corner grabbed is under the pointer, through a turned, unevenly scaled parent")
(is (near? (at world 5 5) (at w2 5 5)) "about the middle of the drawing")))
(is (near? (at world 0 0) (at w2 0 0)) "about the middle of the drawing, which is its origin")))
;; ---------------------------------------------------------------------------
;; which way a corner drag goes
@ -117,14 +158,12 @@
;; None of it is stored any more, so none of it can be absent or stale. These
;; tests used to need a freeze pass to have written the pivots they check.
(defn- at [m p] (let [out (js/Float64Array. 2)]
(vec (array-seq (node/apply-pt! out 0 m (first p) (second p))))))
(defn- handles
"Where the stage would draw this node's box and pivot: its own bounds through
its `:world`, and the MIDDLE OF THOSE BOUNDS, which is what `ui/stage`'s
`handles` does. One `bounds-of` feeding both is the point — the cross cannot
drift off the box, because it is the box's own middle."
drift off the box, because it is the box's own middle. On a drawing it is also
the node's own origin, because that is where `paint/centred` put it."
[c st open path f]
(let [{:keys [sid id world frame]} (nest/placement c st open path f)
n (get-in c [:symbols sid :nodes id])
@ -340,5 +379,7 @@
(deftest an-instances-box-is-what-its-symbol-draws
(let [c (two-down)
{:keys [frame]} (nest/placement c nil :main [u v] 16)]
(is (= [0 0 10 10] ((pick/bounds-of c nil :mid (get-in c [:symbols :mid :nodes v])) frame)))
(is (= [0 0 10 10] ((pick/bounds-of c nil :box (get-in c [:symbols :box :nodes :shape])) 4)))))
(is (= [0 0 10 10] ((pick/bounds-of c nil :mid (get-in c [:symbols :mid :nodes v])) frame))
"an instance's box is what its symbol draws, in the symbol's space")
(is (= [-5 -5 5 5] ((pick/bounds-of c nil :box (get-in c [:symbols :box :nodes :shape])) 4))
"and the shape's own box is about its own origin, which is its middle")))

View file

@ -85,8 +85,11 @@
((clip/resolver % :main nil pal/index-of nil) 16))))))
{:keys [frame matrix time]} (nest/inside c nil :main [u v :shape] 16)
out (js/Float64Array. 2)
;; THE SHAPE'S OWN COORDINATES, which is the centred ring `new-shape`
;; stored and not the points handed to it: a drawing's origin is the
;; middle of what it draws, and `matrix` is the map out of that space.
seen (mapcat (fn [[x y]] (vec (array-seq (node/apply-pt! out 0 matrix x y))))
(partition 2 [0 0 10 0 5 10]))
(partition 2 (first (paint/centred [0 0 10 0 5 10]))))
[x y] (array-seq (node/apply-pt! out 0 (node/invert matrix) 7 3))
moved (paint/set-vertex c :box :shape frame 0 [x y])
keyed (paint/add-key c :box :shape (:frame (nest/inside c nil :main [u v :shape] 20)))]

View file

@ -12,22 +12,30 @@
(get-in clip [:symbols :main :nodes :paint-test :channels paint/geometry]))
(deftest drawing-keys-hold-and-tween-on-the-symbol-clock
;; `a` is what was drawn, `ring` is what the node holds: the same drawing about
;; its own middle, with the middle in `pos`. Every point below is in the node's
;; own coordinates, which is the space `set-vertex` writes in.
(let [a [10 10 30 10 20 30]
ring (first (paint/centred a))
c0 (paint/new-shape demo/clip :main :paint-test 3 a :brow)
c1 (paint/add-key c0 :main :paint-test 9)
c2 (paint/set-vertex c1 :main :paint-test 9 0 [22 10])
c2 (paint/set-vertex c1 :main :paint-test 9 0 [2 -10])
c3 (paint/add-key c2 :main :paint-test 15)
c4 (paint/set-vertex c3 :main :paint-test 15 0 [34 10])
c4 (paint/set-vertex c3 :main :paint-test 15 0 [14 -10])
held (geometry c4)
mixed-clip (update-in c4 [:symbols :main :nodes :paint-test]
node/set-segment-interp paint/geometry 9 :linear)
mixed (geometry mixed-clip)]
(is (= [3 229] (get-in c2 [:symbols :main :nodes :paint-test :span])))
(is (= a (channel/value-at held 8 nil)))
(is (= 10 (first (channel/value-at held 8 nil))))
(is (= 22 (first (channel/value-at held 9 nil))))
(is (= 10 (first (channel/value-at mixed 6 nil))) "the first gap cuts")
(is (= 28 (first (channel/value-at mixed 12 nil))) "the second gap tweens")
(is (= [-10 -10 10 -10 0 10] ring) "centred on the middle of its own box")
(is (= [20 20] (channel/value-at (get-in c0 [:symbols :main :nodes :paint-test
:channels [:xform :pos]]) 3 nil))
"and placed back where it was drawn")
(is (= ring (channel/value-at held 8 nil)))
(is (= -10 (first (channel/value-at held 8 nil))))
(is (= 2 (first (channel/value-at held 9 nil))))
(is (= -10 (first (channel/value-at mixed 6 nil))) "the first gap cuts")
(is (= 8 (first (channel/value-at mixed 12 nil))) "the second gap tweens")
(is (empty? (channel/problems mixed)))
;; The demo's root is exposed on 2s. Paint at frame 3 must still appear at 3.
(is (some #(= :paint-test (:node %))
@ -35,12 +43,13 @@
(is (= mixed-clip (leaf/clip :c1 (leaf/leaves :c1 mixed-clip))))))
(deftest a-point-is-added-and-taken-away-on-every-key
;; `[0 0 10 0 10 10]` drawn is `[-5 -5 5 -5 5 5]` held, about its own middle.
(let [c0 (-> (paint/new-shape demo/clip :main :p 0 [0 0 10 0 10 10] :brow)
(paint/add-key :main :p 6)
(paint/set-vertex :main :p 6 1 [20 0]))
(paint/set-vertex :main :p 6 1 [15 -5]))
c1 (paint/insert-vertex c0 :main :p 0 0.5)
ks #(get-in % [:symbols :main :nodes :p :channels paint/geometry :keys])]
(is (= {0 [0 0 5 0 10 0 10 10] 6 [0 0 10 0 20 0 10 10]} (ks c1))
(is (= {0 [-5 -5 0 -5 5 -5 5 5] 6 [-5 -5 5 -5 15 -5 5 5]} (ks c1))
"half way along the same edge of each key")
(is (= (ks c0) (ks (paint/delete-vertex c1 :main :p 1))))
(is (= (ks c0) (ks (paint/delete-vertex c0 :main :p 0))) "a triangle keeps its three")))