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
|
|
@ -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")
|
||||
(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))
|
||||
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 (near? (vec (radii out)) (vec (radii spun)))
|
||||
"about the pivot's new home, keeping every distance from it"))))
|
||||
(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, and
|
||||
;; keeps it fixed by writing `rot` and nothing else.
|
||||
(let [spun (gesture/apply-values out :main target 0 (gesture/turn v1 nil 0.5))
|
||||
radii (fn [doc] (map (fn [[x y]] (js/Math.hypot (- x 10) (- y 90)))
|
||||
(partition 2 (drawn-at doc 0))))]
|
||||
(is (= #{[:xform :rot]} (set (keys (gesture/turn v1 nil 0.5))))
|
||||
(str what ": a turn about a chosen pivot writes more than the rotation"))
|
||||
(is (not (near? (drawn-at out 0) (drawn-at spun 0))) (str what ": nothing turned"))
|
||||
(is (near? (vec (radii out)) (vec (radii spun)))
|
||||
(str what ": the turn was not about the pivot"))))))
|
||||
|
||||
(deftest a-pegs-pivot-cannot-be-moved-once-its-position-is-animated
|
||||
;; 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.
|
||||
(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)))))
|
||||
(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))
|
||||
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;
|
||||
`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue