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

@ -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")))