Give a peg handles on the stage
A peg was reachable and not usable. It is a `:group`, so it draws nothing, and
both of the stage's ways in key off a DRAWN op: `pick/choose` hit-tests the ops,
so a click never found it, and `handles` hangs its box, corners and turn knob off
`pick/bounds-of`, so it got only the pivot cross — which is `pointer-events:
none`. So the pivot `gesture/refusal` tells you to make could be made, and then
only typed at in the inspector, and reselected only from a timeline row.
Every assertion about the maths passed throughout, which is the point: nothing
under `domain/` can see this.
So the handles fall back to a fixed-size rosette about the peg's own origin —
cross to move, knob to turn, four corners to scale. Fixed size, in stage pixels,
because there is no drawing for it to be proportional to. The cross gets a
transparent `.peg-grab` disc rather than taking the handler itself, since
`.pivot` must stay `pointer-events: none` everywhere else: it is a mark on the
picture and must not eat a click meant for the shape under it.
`test/browser/peg.mjs` asserts the handles as DOM and drags through them with
real pointer events, because that is the half a unit test cannot reach: a peg
appears with no drift at all, is selected, renders one knob, four corners and no
box, and dragging its cross moves its child by exactly the drag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
2026-10-05 19:17:59 -04:00
|
|
|
// Browser smoke test for pegs: a peg is made over a shape, nothing moves, and
|
|
|
|
|
// the stage gives it handles that actually drag.
|
|
|
|
|
//
|
|
|
|
|
// WHAT A UNIT TEST CANNOT SEE. A peg is a `:group`, so it draws nothing, and
|
|
|
|
|
// both of the stage's ways in key off a DRAWN op: `pick/choose` hit-tests the
|
|
|
|
|
// ops, and `handles` hangs its box, corners and knob off `pick/bounds-of`. So a
|
|
|
|
|
// peg was selectable only from a timeline row and had nothing to grab — the
|
|
|
|
|
// pivot that `gesture/refusal` tells you to make was unusable on the stage, and
|
|
|
|
|
// every assertion about the maths still passed. Hence this: the handles are
|
|
|
|
|
// asserted as DOM, with real pointer events through them.
|
|
|
|
|
//
|
|
|
|
|
// Uses an in-memory fixture and performs no server-side writes.
|
|
|
|
|
import { spawn } from 'node:child_process';
|
|
|
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
|
|
import { tmpdir } from 'node:os';
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
|
|
|
|
|
const url = process.env.ARTHUR_URL ?? 'http://localhost:8778/';
|
|
|
|
|
const profile = mkdtempSync(join(tmpdir(), 'arthur-peg-'));
|
|
|
|
|
const port = 9341;
|
|
|
|
|
const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [
|
|
|
|
|
'--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run',
|
|
|
|
|
'--no-default-browser-check', '--mute-audio', '--window-size=1440,1000',
|
|
|
|
|
`--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, url,
|
|
|
|
|
], { stdio: 'ignore' });
|
|
|
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
|
|
|
let ws, id = 0;
|
|
|
|
|
const pending = new Map();
|
|
|
|
|
const send = (method, params) => new Promise(res => {
|
|
|
|
|
const n = ++id; pending.set(n, res);
|
|
|
|
|
ws.send(JSON.stringify({ id: n, method, params }));
|
|
|
|
|
});
|
|
|
|
|
const evalJS = async expr => {
|
|
|
|
|
const r = await send('Runtime.evaluate',
|
|
|
|
|
{ expression: `(function(){${expr}})()`, returnByValue: true, awaitPromise: true });
|
|
|
|
|
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? JSON.stringify(r.exceptionDetails));
|
|
|
|
|
return r.result.value;
|
|
|
|
|
};
|
|
|
|
|
let failures = 0;
|
|
|
|
|
const ok = (cond, what, extra = '') => {
|
|
|
|
|
console.log(` ${cond ? 'ok ' : 'FAIL'} ${what}${extra ? ' — ' + extra : ''}`);
|
|
|
|
|
if (!cond) failures++;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
let target;
|
|
|
|
|
for (let i = 0; i < 100 && !target; i++) {
|
|
|
|
|
await sleep(100);
|
|
|
|
|
try {
|
|
|
|
|
target = (await fetch(`http://127.0.0.1:${port}/json/list`).then(r => r.json()))
|
|
|
|
|
.find(t => t.type === 'page' && t.url.startsWith(url));
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
assert(target, 'editor page');
|
|
|
|
|
ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
|
|
|
await new Promise(r => ws.addEventListener('open', r));
|
|
|
|
|
ws.addEventListener('message', e => {
|
|
|
|
|
const m = JSON.parse(e.data);
|
|
|
|
|
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); }
|
|
|
|
|
});
|
|
|
|
|
await send('Runtime.enable');
|
|
|
|
|
await send('Page.enable');
|
|
|
|
|
for (let i = 0; i < 160; i++) {
|
|
|
|
|
if (await evalJS('return !!(window.cljs && window.re_frame && window.arthur)')) break;
|
|
|
|
|
await sleep(250);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Local route, a turned+scaled shape in :main, selected.
|
|
|
|
|
const setup = await evalJS(`
|
|
|
|
|
const c = cljs.core, k = c.keyword, v = c.vector, m = c.hash_map;
|
|
|
|
|
c.swap_BANG_(re_frame.db.app_db, db => c.assoc(db, k('route'), k('local-test')));
|
|
|
|
|
const blank = arthur.domain.clip.blank();
|
|
|
|
|
let doc = arthur.domain.paint.new_shape(blank, k('main'), k('shape'), 0,
|
|
|
|
|
v(100, 80, 140, 80, 140, 110, 100, 110), k('brow'));
|
|
|
|
|
doc = c.update_in(doc, v(k('symbols'), k('main'), k('nodes'), k('shape'), k('channels')),
|
|
|
|
|
ch => c.merge(ch, m(v(k('xform'), k('rot')), arthur.domain.channel.framed(0.4),
|
|
|
|
|
v(k('xform'), k('scale')), arthur.domain.channel.framed(v(1.5, 0.75)))));
|
|
|
|
|
const entry = m(k('clip'), doc, k('store'), m());
|
|
|
|
|
const cid = arthur.footage.store.install_BANG_(entry, 'peg-probe');
|
|
|
|
|
c.swap_BANG_(re_frame.db.app_db, db =>
|
|
|
|
|
c.assoc(c.assoc_in(db, v(k('ui'), k('open')), k('main')),
|
|
|
|
|
k('clip/current'), cid, k('paint/revision'), 0));
|
|
|
|
|
re_frame.core.dispatch_sync(v(k('arthur.events.ui/select'),
|
|
|
|
|
v(k('node'), k('main'), k('shape'), v(k('shape')))));
|
|
|
|
|
return {cid: String(cid)};
|
|
|
|
|
`);
|
|
|
|
|
console.log('setup:', JSON.stringify(setup));
|
|
|
|
|
await sleep(600);
|
|
|
|
|
|
|
|
|
|
const before = 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 res = arthur.domain.clip.resolver(c.get(e,k('clip')), k('main'), c.get(e,k('store')),
|
|
|
|
|
arthur.domain.palette.index_of, null);
|
|
|
|
|
const ops = res.call(null, 0);
|
|
|
|
|
return Array.from(c.get(c.first(ops), k('pts')));
|
|
|
|
|
`);
|
|
|
|
|
ok(Array.isArray(before) && before.length === 8, 'the shape draws before the peg', JSON.stringify(before));
|
|
|
|
|
|
|
|
|
|
// Add the peg, exactly as the menu item does.
|
|
|
|
|
const made = await evalJS(`
|
|
|
|
|
const c = cljs.core, k = c.keyword, v = c.vector;
|
|
|
|
|
re_frame.core.dispatch_sync(v(k('arthur.events.ui/peg'), v(k('shape'))));
|
|
|
|
|
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 shape = c.get(nodes, k('shape'));
|
|
|
|
|
const pegId = c.get(shape, k('parent'));
|
|
|
|
|
const peg = c.get(nodes, pegId);
|
|
|
|
|
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
|
|
|
|
|
arthur.domain.palette.index_of, null);
|
|
|
|
|
const sel = c.clj__GT_js(c.get_in(db, v(k('ui'), k('selection'))));
|
|
|
|
|
return {
|
|
|
|
|
kind: String(c.get(peg, k('kind'))),
|
|
|
|
|
pegPos: c.clj__GT_js(c.get_in(peg, v(k('channels'), v(k('xform'),k('pos')), k('value')))),
|
|
|
|
|
hasPinv: !!c.get(shape, k('pinv')),
|
|
|
|
|
pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
|
|
|
|
|
selection: sel ? sel.map(String) : null,
|
|
|
|
|
problems: c.clj__GT_js(arthur.domain.clip.problems(doc)),
|
|
|
|
|
};
|
|
|
|
|
`);
|
|
|
|
|
ok(made.kind === ':group', 'the peg is a group', made.kind);
|
|
|
|
|
ok(made.hasPinv, 'the child got a :pinv');
|
|
|
|
|
ok((made.problems ?? []).length === 0, 'the document still validates', JSON.stringify(made.problems));
|
|
|
|
|
const moved = Math.max(...before.map((b, i) => Math.abs(b - made.pts[i])));
|
|
|
|
|
ok(moved < 1e-9, 'nothing moved when the peg appeared', `max drift ${moved}`);
|
|
|
|
|
ok(String(made.selection?.[2]) !== ':shape', 'the peg is selected afterwards', JSON.stringify(made.selection));
|
|
|
|
|
console.log('peg pos:', JSON.stringify(made.pegPos));
|
|
|
|
|
|
|
|
|
|
await sleep(700);
|
|
|
|
|
// THE BIT THAT UNIT TESTS CANNOT SEE: does the stage render grabbable handles?
|
|
|
|
|
const dom = await evalJS(`
|
|
|
|
|
const q = s => document.querySelector(s);
|
|
|
|
|
const g = q('.paint-overlay .handles');
|
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>
2026-10-06 14:33:35 -04:00
|
|
|
const grab = q('.paint-overlay .handles .pivot-grab');
|
Give a peg handles on the stage
A peg was reachable and not usable. It is a `:group`, so it draws nothing, and
both of the stage's ways in key off a DRAWN op: `pick/choose` hit-tests the ops,
so a click never found it, and `handles` hangs its box, corners and turn knob off
`pick/bounds-of`, so it got only the pivot cross — which is `pointer-events:
none`. So the pivot `gesture/refusal` tells you to make could be made, and then
only typed at in the inspector, and reselected only from a timeline row.
Every assertion about the maths passed throughout, which is the point: nothing
under `domain/` can see this.
So the handles fall back to a fixed-size rosette about the peg's own origin —
cross to move, knob to turn, four corners to scale. Fixed size, in stage pixels,
because there is no drawing for it to be proportional to. The cross gets a
transparent `.peg-grab` disc rather than taking the handler itself, since
`.pivot` must stay `pointer-events: none` everywhere else: it is a mark on the
picture and must not eat a click meant for the shape under it.
`test/browser/peg.mjs` asserts the handles as DOM and drags through them with
real pointer events, because that is the half a unit test cannot reach: a peg
appears with no drift at all, is selected, renders one knob, four corners and no
box, and dragging its cross moves its child by exactly the drag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
2026-10-05 19:17:59 -04:00
|
|
|
const r = grab && grab.getBoundingClientRect();
|
|
|
|
|
return {
|
|
|
|
|
handles: !!g,
|
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>
2026-10-06 14:33:35 -04:00
|
|
|
pivotGrab: !!grab,
|
|
|
|
|
pivotGrabEvents: grab ? getComputedStyle(grab).pointerEvents : null,
|
Give a peg handles on the stage
A peg was reachable and not usable. It is a `:group`, so it draws nothing, and
both of the stage's ways in key off a DRAWN op: `pick/choose` hit-tests the ops,
so a click never found it, and `handles` hangs its box, corners and turn knob off
`pick/bounds-of`, so it got only the pivot cross — which is `pointer-events:
none`. So the pivot `gesture/refusal` tells you to make could be made, and then
only typed at in the inspector, and reselected only from a timeline row.
Every assertion about the maths passed throughout, which is the point: nothing
under `domain/` can see this.
So the handles fall back to a fixed-size rosette about the peg's own origin —
cross to move, knob to turn, four corners to scale. Fixed size, in stage pixels,
because there is no drawing for it to be proportional to. The cross gets a
transparent `.peg-grab` disc rather than taking the handler itself, since
`.pivot` must stay `pointer-events: none` everywhere else: it is a mark on the
picture and must not eat a click meant for the shape under it.
`test/browser/peg.mjs` asserts the handles as DOM and drags through them with
real pointer events, because that is the half a unit test cannot reach: a peg
appears with no drift at all, is selected, renders one knob, four corners and no
box, and dragging its cross moves its child by exactly the drag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
2026-10-05 19:17:59 -04:00
|
|
|
knobs: document.querySelectorAll('.paint-overlay .handles .knob').length,
|
|
|
|
|
corners: document.querySelectorAll('.paint-overlay .handles .corner').length,
|
|
|
|
|
boxes: document.querySelectorAll('.paint-overlay .handles .box').length,
|
|
|
|
|
rect: r ? {x: r.x + r.width/2, y: r.y + r.height/2, w: r.width} : null,
|
|
|
|
|
};
|
|
|
|
|
`);
|
|
|
|
|
console.log('dom:', JSON.stringify(dom));
|
|
|
|
|
ok(dom.handles, 'the stage draws handles for the selected peg');
|
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>
2026-10-06 14:33:35 -04:00
|
|
|
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));
|
Give a peg handles on the stage
A peg was reachable and not usable. It is a `:group`, so it draws nothing, and
both of the stage's ways in key off a DRAWN op: `pick/choose` hit-tests the ops,
so a click never found it, and `handles` hangs its box, corners and turn knob off
`pick/bounds-of`, so it got only the pivot cross — which is `pointer-events:
none`. So the pivot `gesture/refusal` tells you to make could be made, and then
only typed at in the inspector, and reselected only from a timeline row.
Every assertion about the maths passed throughout, which is the point: nothing
under `domain/` can see this.
So the handles fall back to a fixed-size rosette about the peg's own origin —
cross to move, knob to turn, four corners to scale. Fixed size, in stage pixels,
because there is no drawing for it to be proportional to. The cross gets a
transparent `.peg-grab` disc rather than taking the handler itself, since
`.pivot` must stay `pointer-events: none` everywhere else: it is a mark on the
picture and must not eat a click meant for the shape under it.
`test/browser/peg.mjs` asserts the handles as DOM and drags through them with
real pointer events, because that is the half a unit test cannot reach: a peg
appears with no drift at all, is selected, renders one knob, four corners and no
box, and dragging its cross moves its child by exactly the drag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
2026-10-05 19:17:59 -04:00
|
|
|
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));
|
|
|
|
|
|
|
|
|
|
// Drag it, through real pointer events on that element.
|
|
|
|
|
if (dom.rect) {
|
|
|
|
|
const { x, y } = dom.rect;
|
|
|
|
|
for (const [type, dx] of [['mousePressed', 0], ['mouseMoved', 18], ['mouseMoved', 36], ['mouseReleased', 36]]) {
|
|
|
|
|
await send('Input.dispatchMouseEvent', {
|
|
|
|
|
type, x: x + dx, y, button: 'left', buttons: 1, clickCount: 1, pointerType: 'mouse',
|
|
|
|
|
});
|
|
|
|
|
await sleep(80);
|
|
|
|
|
}
|
|
|
|
|
await sleep(400);
|
|
|
|
|
const after = 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')))};
|
|
|
|
|
`);
|
|
|
|
|
const dx = after.pts[0] - made.pts[0];
|
|
|
|
|
const dy = after.pts[1] - made.pts[1];
|
|
|
|
|
console.log('after drag: peg pos', JSON.stringify(after.pos), 'child moved by', dx.toFixed(3), dy.toFixed(3));
|
|
|
|
|
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)}`);
|
|
|
|
|
}
|
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
2026-10-05 19:25:04 -04:00
|
|
|
// ⌥-drag the cross: the PIVOT moves and the child must not.
|
|
|
|
|
const dom2 = await evalJS(`
|
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>
2026-10-06 14:33:35 -04:00
|
|
|
const q = document.querySelector('.paint-overlay .handles .pivot-grab');
|
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
2026-10-05 19:25:04 -04:00
|
|
|
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) {
|
2026-10-05 21:48:59 -04:00
|
|
|
// Every modifier that has to place the pivot. CDP modifiers: 1=alt, 2=ctrl,
|
|
|
|
|
// 4=meta. ⌥ alone was the whole gesture once, and on a desktop whose window
|
|
|
|
|
// manager grabs Alt-drag the page never sees it — so each is asserted
|
|
|
|
|
// through real pointer events rather than by reading the handler.
|
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
2026-10-05 19:25:04 -04:00
|
|
|
const { x, y } = dom2.grab;
|
2026-10-05 21:48:59 -04:00
|
|
|
let after2;
|
|
|
|
|
for (const [name, modifiers, sign] of [['alt', 1, -1], ['ctrl', 2, 1], ['meta', 4, -1]]) {
|
|
|
|
|
const pre = 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 {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
|
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>
2026-10-06 14:33:35 -04:00
|
|
|
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))};
|
2026-10-05 21:48:59 -04:00
|
|
|
`);
|
|
|
|
|
// Where the cross is NOW, since the previous modifier already moved it.
|
|
|
|
|
const g = await evalJS(`
|
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>
2026-10-06 14:33:35 -04:00
|
|
|
const q = document.querySelector('.paint-overlay .handles .pivot-grab');
|
2026-10-05 21:48:59 -04:00
|
|
|
const r = q && q.getBoundingClientRect();
|
|
|
|
|
return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null;
|
|
|
|
|
`);
|
|
|
|
|
const dx = 30 * sign, dy = 20 * sign;
|
|
|
|
|
for (const [type, fx, fy] of [['mousePressed', 0, 0], ['mouseMoved', dx/2, dy/2],
|
|
|
|
|
['mouseMoved', dx, dy], ['mouseReleased', dx, dy]]) {
|
|
|
|
|
await send('Input.dispatchMouseEvent', {
|
|
|
|
|
type, x: g.x + fx, y: g.y + fy, button: 'left', buttons: 1, clickCount: 1,
|
|
|
|
|
pointerType: 'mouse', modifiers,
|
|
|
|
|
});
|
|
|
|
|
await sleep(90);
|
|
|
|
|
}
|
|
|
|
|
await sleep(450);
|
|
|
|
|
const post = 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 {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
|
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>
2026-10-06 14:33:35 -04:00
|
|
|
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))};
|
2026-10-05 21:48:59 -04:00
|
|
|
`);
|
|
|
|
|
const drift = Math.max(...pre.pts.map((b, i) => Math.abs(b - post.pts[i])));
|
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>
2026-10-06 14:33:35 -04:00
|
|
|
// 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);
|
2026-10-05 21:48:59 -04:00
|
|
|
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)}`);
|
|
|
|
|
after2 = post;
|
|
|
|
|
}
|
|
|
|
|
// And a plain drag still translates, which is the other half of the split.
|
|
|
|
|
{
|
|
|
|
|
const g = await evalJS(`
|
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>
2026-10-06 14:33:35 -04:00
|
|
|
const q = document.querySelector('.paint-overlay .handles .pivot-grab');
|
2026-10-05 21:48:59 -04:00
|
|
|
const r = q && q.getBoundingClientRect();
|
|
|
|
|
return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null;
|
|
|
|
|
`);
|
|
|
|
|
const pre = after2;
|
|
|
|
|
for (const [type, fx] of [['mousePressed', 0], ['mouseMoved', 12], ['mouseReleased', 12]]) {
|
|
|
|
|
await send('Input.dispatchMouseEvent', {
|
|
|
|
|
type, x: g.x + fx, y: g.y, button: 'left', buttons: 1, clickCount: 1, pointerType: 'mouse',
|
|
|
|
|
});
|
|
|
|
|
await sleep(90);
|
|
|
|
|
}
|
|
|
|
|
await sleep(450);
|
|
|
|
|
const post = 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 res = arthur.domain.clip.resolver(c.get(e, k('clip')), 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')));
|
|
|
|
|
`);
|
|
|
|
|
ok(Math.abs(post[0] - pre.pts[0]) > 2, 'and an UNMODIFIED drag still translates the child',
|
|
|
|
|
`dx=${(post[0] - pre.pts[0]).toFixed(2)}`);
|
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
2026-10-05 19:25:04 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-05 21:48:59 -04:00
|
|
|
|
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
2026-10-05 19:25:04 -04:00
|
|
|
console.log(failures ? `\n${failures} FAILED` : '\nPASS: a peg can be made, moved, and have its pivot placed');
|
Give a peg handles on the stage
A peg was reachable and not usable. It is a `:group`, so it draws nothing, and
both of the stage's ways in key off a DRAWN op: `pick/choose` hit-tests the ops,
so a click never found it, and `handles` hangs its box, corners and turn knob off
`pick/bounds-of`, so it got only the pivot cross — which is `pointer-events:
none`. So the pivot `gesture/refusal` tells you to make could be made, and then
only typed at in the inspector, and reselected only from a timeline row.
Every assertion about the maths passed throughout, which is the point: nothing
under `domain/` can see this.
So the handles fall back to a fixed-size rosette about the peg's own origin —
cross to move, knob to turn, four corners to scale. Fixed size, in stage pixels,
because there is no drawing for it to be proportional to. The cross gets a
transparent `.peg-grab` disc rather than taking the handler itself, since
`.pivot` must stay `pointer-events: none` everywhere else: it is a mark on the
picture and must not eat a click meant for the shape under it.
`test/browser/peg.mjs` asserts the handles as DOM and drags through them with
real pointer events, because that is the half a unit test cannot reach: a peg
appears with no drift at all, is selected, renders one knob, four corners and no
box, and dragging its cross moves its child by exactly the drag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
2026-10-05 19:17:59 -04:00
|
|
|
} finally {
|
|
|
|
|
if (ws) ws.close();
|
|
|
|
|
chrome.kill();
|
|
|
|
|
try {
|
|
|
|
|
rmSync(profile, { recursive: true, force: true });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error.code !== 'ENOTEMPTY') throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (failures) process.exitCode = 1;
|