Move a peg's pivot without moving what hangs off it

Two bugs in the handles, and the second one meant a peg still had no movable
pivot — which is the complaint pegs exist to answer.

`.peg-grab` was drawn LAST, on top of the four scale corners, and at r=7 against
corners spanning 5.2 to 8.8 from the middle it swallowed the inner half of each
one. It is drawn first now, at r=5, so it stops short of them.

And dragging a peg's cross is a TRANSLATE, not a new pivot. It writes the peg's
`pos`, and a peg is a parent, so everything under it comes along — `peg.mjs` said
so all along: "dragging the peg cross moved its child — dx=18.000". That is the
right behaviour for the drag and no way to say "put the pivot here", so a peg
could be made with its pivot wherever `nest/peg` happened to put it and never
moved again.

⌥-drag now relocates it, which is After Effects' pan-behind split. `nest/repivot`
moves the peg and solves

    pinv(child)' = local'⁻¹ · local · pinv(child)

for each child, because only `local(peg) · pinv(child)` reaches a child, so
preserving that product holds the picture exactly still — verified as a drift of
0, both in `nest-test` and through real ⌥-pointer events in `peg.mjs`. The
child's own channels are never touched, so this works over a measured child,
which is the case the peg exists for.

Refused on a peg whose position is animated, rather than quietly wrong: the
compensation depends on the peg's own transform, so a keyed position wants a
different `pinv` on every frame and one stored matrix is not it. The message says
to put a peg over it instead.

One edit at the end of the drag, not per pointermove: a repivot moves nothing on
screen by construction, so only the cross needs to follow the pointer.

603 CLJS tests, and peg.mjs and onion.mjs pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
This commit is contained in:
Your Name 2026-10-05 19:25:04 -04:00
parent 00b8ed34ee
commit 357ff1f3b9
6 changed files with 227 additions and 9 deletions

View file

@ -491,3 +491,44 @@
(doseq [f [0 7 20]]
(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 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)))))

View file

@ -183,7 +183,64 @@ try {
ok(dx > 2, 'dragging the peg cross moved its child', `dx=${dx.toFixed(3)}`);
ok(Math.abs(dy) < 1.5, 'and only horizontally, as dragged', `dy=${dy.toFixed(3)}`);
}
console.log(failures ? `\n${failures} FAILED` : '\nPASS: a peg can be made, selected, and dragged on the stage');
// ⌥-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 r = q && q.getBoundingClientRect();
const corner = document.querySelector('.paint-overlay .handles .corner');
const cr = corner && corner.getBoundingClientRect();
return {
grab: r ? {x: r.x + r.width/2, y: r.y + r.height/2, w: r.width} : null,
// The corner's own middle must hit the CORNER, not the move disc over it.
cornerHit: cr ? (document.elementFromPoint(cr.x + cr.width/2, cr.y + cr.height/2) || {}).className
?.baseVal ?? null : null,
};
`);
console.log('dom2:', JSON.stringify(dom2));
ok(dom2.cornerHit === 'corner', 'a scale corner is on top of the move disc, not under it',
String(dom2.cornerHit));
if (dom2.grab) {
const before2 = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const doc = c.get(e, k('clip'));
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
return Array.from(c.get(c.first(res.call(null, 0)), k('pts')));
`);
const { x, y } = dom2.grab;
const steps = [['mousePressed', 0, 0], ['mouseMoved', -40, 25], ['mouseMoved', -80, 50],
['mouseReleased', -80, 50]];
for (const [type, dx, dy] of steps) {
await send('Input.dispatchMouseEvent', {
type, x: x + dx, y: y + dy, button: 'left', buttons: 1, clickCount: 1,
pointerType: 'mouse', modifiers: 1, // alt
});
await sleep(90);
}
await sleep(500);
const after2 = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const doc = c.get(e, k('clip'));
const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
const pegId = c.get(c.get(nodes, k('shape')), k('parent'));
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
return {pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value')))),
pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
refusal: c.clj__GT_js(c.get_in(db, v(k('ui'), k('refusal'))))};
`);
const drift = Math.max(...before2.map((b, i) => Math.abs(b - after2.pts[i])));
console.log('after alt-drag: peg pos', JSON.stringify(after2.pos), 'child drift', drift);
ok(drift < 1e-6, 'alt-dragging the cross moved NOTHING under the peg', `drift ${drift}`);
ok(Math.abs(after2.pos[0] - made.pegPos[0]) > 2, 'but the pivot did move',
`${JSON.stringify(made.pegPos)} -> ${JSON.stringify(after2.pos)}`);
}
console.log(failures ? `\n${failures} FAILED` : '\nPASS: a peg can be made, moved, and have its pivot placed');
} finally {
if (ws) ws.close();
chrome.kill();