Files
adminandClaude Opus 5.5 6d4247ebf8 Paste where the view shows it, editor offsets included (#263)
Paste mixed two frames. The pointer comes from the Scene View, which
draws every object moved by the editor offsets of it and its parents.
Paste turned that point into a local position through the parents'
plain world transform, so the copy landed beside the pointer by the
offset of the parent it went into. Without a pointer, an original with
an offset of its own had its copy placed beside its game position,
which the view does not show.

- A copied root now remembers where the view showed it
  (`ClipRoot.shown`, from `editorTransform`) instead of its world
  position.
- New `editorLocalPositionFor` in `properties/transform.ts` turns a
  shown point into a local position through the parent chain with the
  editor offsets, as the view draws it. Paste uses it.
- The copy itself still gets no editor offset of its own, as since
  #240. In the game it stands where it was drawn, minus the offsets of
  its parents.
- Re-parenting under a parent with an offset still keeps the game
  position, so the object jumps in the view by that offset. That is a
  different rule from paste and is left as it is.

Tests:
- Unit (clipboard, groups, references, transform): 50 passed, 2 new.
- e2e 51 on melektaus-atx: ALL PASSED, 12 checks, 1 new (51f).
- Gate on melektaus-atx: 195 files, 2829 tests, CHECK EXIT 0.
- Counter-checks: without the paste fix both new unit tests fail
  (copy at 540/470 instead of 500/500) and 51f fails by exactly the
  60/-40 offset. With the root remembering its world position again,
  the own-offset unit test fails.
- No full run. The e2e total of 1591 is 1590 plus 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:36:30 +02:00

172 lines
8.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Copy and paste of scene objects and graph nodes (#240), with the real keys and the real menus.
* What only the running editor knows is where the pointer is: a paste under it has to land exactly
* there, and one without it has to land beside the original – the rules themselves are unit-tested.
* Everything pasted here is taken back by undo, so the fixture leaves as it came.
*/
export async function clipboard(t) {
const { check, json, evaluate, mouse, settle, press, click, clickText, select, showTab, undo, shot } = t;
const stored = () => json('__gm.state.activeScene.objects');
const selected = async () => (await json('__gm.state.selection')) ?? {};
const near = (a, b) => a && b && Math.abs(a.x - b.x) <= 1.5 && Math.abs(a.y - b.y) <= 1.5;
const moveTo = async (p) => {
await mouse('mouseMoved', p.x, p.y);
await settle();
};
await press('Escape');
await showTab('scene');
await evaluate('__gm.state.clipboard = null');
// Two crates of its own, each with a collider, where the seed has its red and blue crate. In the
// full run those two are long packed into a container by module 09, so they are no fixture here.
const BLUE = 'clip-blue';
const RED = 'clip-red';
await t.mutate(`
const scene = __gm.state.activeScene;
const put = (id, assetId, parentId, x, y, name) => {
const o = structuredClone(scene.objects[0]);
Object.assign(o, { id, assetId, parentId, name, position: { x, y, z: 0 }, overrides: {}, instanceEdits: {} });
delete o.groupId;
if ('enabled' in o) o.enabled = true;
scene.objects.push(o);
};
put(${JSON.stringify(BLUE)}, 'crate', '', 120, -80, 'Blue crate');
put(${JSON.stringify(BLUE)} + '-col', 'collider', ${JSON.stringify(BLUE)}, 0, 0, 'Collider');
put(${JSON.stringify(RED)}, 'crate', '', -150, 0, 'Red crate');
put(${JSON.stringify(RED)} + '-col', 'collider', ${JSON.stringify(RED)}, 0, 0, 'Collider');
__gm.state.select(null)`);
// 51a. Ctrl+C on the blue crate, Ctrl+V with the pointer over empty space: the crate and its
// collider come back as new objects, the crate standing right under the pointer.
await select({ kind: 'object', holderId: 'scene1', objectId: BLUE, objectIds: [BLUE] });
await press('c', { ctrl: true });
const spot = await json('__gm.emptySpot()');
const before = await stored();
await moveTo(spot);
await press('v', { ctrl: true });
const afterPointer = await stored();
const pasted = (await selected()).objectId;
const copy = afterPointer.find((o) => o.id === pasted);
const copiedCollider = afterPointer.find((o) => o.parentId === pasted);
check(
'Ctrl+V puts a copy of the crate and its collider under the pointer',
afterPointer.length === before.length + 2 &&
copy?.assetId === 'crate' &&
copy.name === 'Blue crate' &&
copiedCollider?.assetId === 'collider' &&
near(await json(`__gm.objectScreen(${JSON.stringify(pasted)})`), spot),
JSON.stringify({ spot, copy, at: await json(`__gm.objectScreen(${JSON.stringify(pasted)})`) }),
);
check(
'the hierarchy lists the copy',
(await json('__gm.hierarchyOrder()')).includes(pasted),
JSON.stringify(await json('__gm.hierarchyOrder()')),
);
await shot('51-pasted-at-pointer');
await undo();
check('one undo takes the whole paste back', (await stored()).length === before.length);
// 51b. With the pointer over the hierarchy the copy has nowhere to be aimed: it lands one grid
// step down and right of the original, beside it rather than inside it.
await select({ kind: 'object', holderId: 'scene1', objectId: BLUE, objectIds: [BLUE] });
await moveTo(await json(`__gm.hierarchyRow(${JSON.stringify(BLUE)})`));
await press('v', { ctrl: true });
const besideId = (await selected()).objectId;
const beside = (await stored()).find((o) => o.id === besideId);
const original = (await stored()).find((o) => o.id === BLUE);
check(
'without the pointer on the view the copy lands a grid step beside the original',
beside !== undefined &&
beside.id !== BLUE &&
beside.parentId === original.parentId &&
beside.position.x === original.position.x + 100 &&
beside.position.y === original.position.y + 100,
JSON.stringify({ original: original.position, beside }),
);
await undo();
// 51c. The object menu: right-click the red crate, "Paste" puts the copy inside what was hit –
// the crate or the collider drawn over it, whichever the pointer picks.
const red = await json(`__gm.objectScreen(${JSON.stringify(RED)})`);
await click(red.x, red.y, 'right');
const target = (await selected()).objectId;
const rows = (await json('__gm.menuRows()')) ?? [];
await clickText('Paste');
const insideId = (await selected()).objectId;
const inside = (await stored()).find((o) => o.id === insideId);
check(
'the object menu offers Copy and Paste, and Paste puts the copy into the object clicked',
rows.includes('Copy') &&
rows.includes('Paste') &&
[RED, `${RED}-col`].includes(target) &&
inside?.assetId === 'crate' &&
inside.parentId === target,
JSON.stringify({ target, rows, inside }),
);
await undo();
// 51f. The red crate drawn aside by its editor offset (#263): Ctrl+V with it selected puts the
// copy inside it and still right under the pointer, where the view shows the copy.
await t.mutate(`
__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(RED)}).editorOffset = { x: 60, y: -40 }`);
await select({ kind: 'object', holderId: 'scene1', objectId: RED, objectIds: [RED] });
const offsetSpot = await json('__gm.emptySpot()');
await moveTo(offsetSpot);
await press('v', { ctrl: true });
const underRedId = (await selected()).objectId;
const underRed = (await stored()).find((o) => o.id === underRedId);
const shownAt = await json(`__gm.objectScreen(${JSON.stringify(underRedId)})`);
check(
'Ctrl+V into a parent with an editor offset still lands under the pointer',
underRed?.assetId === 'crate' && underRed.parentId === RED && near(shownAt, offsetSpot),
JSON.stringify({ offsetSpot, shownAt, underRed: underRed && underRed.position }),
);
await undo();
check('the scene is back as it was', (await stored()).length === before.length);
// 51d. A node of the Mover behavior, copied and pasted under the pointer in its own graph.
await select({ kind: 'node', assetId: 'mover', nodeId: 'n-axis', nodeIds: ['n-axis'] });
await showTab('graph');
await press('c', { ctrl: true });
const graphSpot = await json('__gm.graph.emptySpot()');
const nodes = () => json("__gm.state.project.assets.find((a) => a.id === 'mover').graph.nodes");
const count = (await nodes()).length;
await moveTo(graphSpot);
await press('v', { ctrl: true });
const newNode = (await selected()).nodeId;
const zoom = await json('__gm.graph.zoom()');
const title = await json(`__gm.graph.nodeAt(${JSON.stringify(newNode)})`);
// `nodeAt` is the middle of the title bar; the node's corner is what lands on the pointer.
const corner = title && { x: title.x - (176 / 2) * zoom, y: title.y - (24 / 2) * zoom };
check(
'Ctrl+V in the graph puts a copy of the node under the pointer',
(await nodes()).length === count + 1 &&
(await nodes()).find((n) => n.id === newNode)?.type === 'AxisInput' &&
near(corner, graphSpot),
JSON.stringify({ graphSpot, corner, zoom }),
);
await undo();
// 51e. The palette on empty space offers Paste first, and it lands where the palette opened.
await click(graphSpot.x, graphSpot.y, 'right');
const offered = await json('__gm.graph.paletteRows()');
await clickText('Paste');
const fromPalette = (await selected()).nodeId;
check(
'the palette on empty space offers Paste, which adds the copy there',
offered[0]?.type === 'paste' &&
(await nodes()).length === count + 1 &&
(await nodes()).find((n) => n.id === fromPalette)?.type === 'AxisInput',
JSON.stringify(offered.slice(0, 3)),
);
await undo();
check('the graph is back as it was', (await nodes()).length === count);
await evaluate('__gm.state.clipboard = null');
await showTab('scene');
await t.mutate(`
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('clip-'));`);
await t.saved();
}