The aspect button now carries two offers, so it becomes a menu: fit one side to the texture ratio, or set both sides to the exact pixel size as a single undo step. Dropping a row on the outer quarter of another row inserts it into that gap instead of parenting into it, reordering among the siblings there. Order is the stored order, so ReorderObjectCommand is a splice; equal z falls back to tree order, which makes this the way to draw one object in front of another. Rejected on container content like the other structural edits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
5.9 KiB
JavaScript
165 lines
5.9 KiB
JavaScript
import { same } from '../lib/harness.mjs';
|
||
|
||
/**
|
||
* Hierarchy / parenting. Gives both crates unique names (later steps locate them by these),
|
||
* then parents by drag, by the inspector menu and unparents from the context menu – the world
|
||
* position is kept every time.
|
||
*/
|
||
export async function parenting(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
json,
|
||
shot,
|
||
click,
|
||
clickText,
|
||
drag,
|
||
locate,
|
||
typeText,
|
||
press,
|
||
undo,
|
||
mutate,
|
||
select,
|
||
object,
|
||
entity,
|
||
stop,
|
||
} = t;
|
||
|
||
// 14. Drag the blue crate's row onto the red crate's row: it becomes a child, keeps its world
|
||
// position (local becomes the difference) and is listed right below its parent.
|
||
await mutate(`
|
||
const objs = __gm.state.activeScene.objects;
|
||
const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2');
|
||
o1.name = 'Red crate'; o1.position = { x: -150, y: 0, z: 0 };
|
||
o2.name = 'Blue crate'; o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = '';
|
||
__gm.state.select(null)`);
|
||
const redRow = await locate('Red crate');
|
||
const blueRow = await locate('Blue crate');
|
||
await drag(blueRow.x, blueRow.y, redRow.x, redRow.y);
|
||
await sleep(100);
|
||
let o2 = await object('o2');
|
||
check(
|
||
'dropping a row onto another parents it, keeping the world position',
|
||
o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80,
|
||
JSON.stringify(o2),
|
||
);
|
||
const order = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())');
|
||
// o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider.
|
||
const o1At = order.indexOf('o1');
|
||
check(
|
||
'children are listed directly below their parent',
|
||
same(order.slice(o1At + 1, o1At + 5), ['o2', 'oc2', 'o4', 'oc1']),
|
||
JSON.stringify(order),
|
||
);
|
||
await shot('20-parented');
|
||
|
||
// 14a. Moving the parent (X field of the red crate) drags the child along in world space.
|
||
await clickText('Red crate');
|
||
const xField = await locate('X');
|
||
await click(xField.x + 160, xField.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('-50');
|
||
await press('Enter');
|
||
await sleep(200);
|
||
o2 = await object('o2');
|
||
const parent = await object('o2').then((o) => object(o.parentId));
|
||
const worldOfO2 = { x: o2.position.x + parent.position.x, y: o2.position.y + parent.position.y };
|
||
check(
|
||
'moving the parent moves the child in world space (local unchanged)',
|
||
o2.position.x === 270 && same(worldOfO2, { x: 220, y: -80 }),
|
||
`${JSON.stringify(o2.position)} world=${JSON.stringify(worldOfO2)}`,
|
||
);
|
||
|
||
// 14b. Play: entities mirror the hierarchy; undo restores parent and position.
|
||
await select(null);
|
||
await press('Enter', { ctrl: true });
|
||
await sleep(300);
|
||
const e2 = await entity('o2');
|
||
check(
|
||
'play instantiates the parent link and world positions',
|
||
e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270,
|
||
JSON.stringify(e2),
|
||
);
|
||
await stop();
|
||
await undo(); // move
|
||
await undo(); // parent
|
||
o2 = await object('o2');
|
||
check(
|
||
'undo restores top level and the original local position',
|
||
o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80,
|
||
JSON.stringify(o2),
|
||
);
|
||
|
||
// 14c. Parent via the inspector menu: the picker is searchable, Enter takes the single match.
|
||
await clickText('Blue crate');
|
||
await clickText('None'); // the Parent dropdown, labelled with the current parent
|
||
const entries = await json('__gm.overlayTexts()');
|
||
check(
|
||
'the parent picker offers a search box and lists the candidates',
|
||
entries.includes('Search…') && entries.includes('None') && entries.includes('Red crate'),
|
||
JSON.stringify(entries),
|
||
);
|
||
await typeText('red');
|
||
await sleep(150);
|
||
const filtered = await json('__gm.overlayTexts()');
|
||
check(
|
||
'typing filters the picker down to the matching entries',
|
||
same(filtered, ['red', 'Red crate']),
|
||
JSON.stringify(filtered),
|
||
);
|
||
await shot('30-parent-search');
|
||
await press('Enter'); // takes the first match
|
||
await sleep(150);
|
||
o2 = await object('o2');
|
||
check(
|
||
'inspector Parent menu parents the object',
|
||
o2.parentId === 'o1' && o2.position.x === 270,
|
||
JSON.stringify(o2),
|
||
);
|
||
check('the picker closed after Enter', same(await json('__gm.overlayTexts()'), []));
|
||
const blueRow2 = await locate('Blue crate');
|
||
await click(blueRow2.x, blueRow2.y, 'right');
|
||
await clickText('Unparent (move to top level)');
|
||
await sleep(100);
|
||
o2 = await object('o2');
|
||
check(
|
||
'Unparent moves the object to the top level, keeping its world position',
|
||
o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80,
|
||
JSON.stringify(o2),
|
||
);
|
||
|
||
// 14d. The gap above a row is a different drop than the row itself: it orders the dragged
|
||
// object among that row's siblings instead of putting it inside, and moves nothing.
|
||
const rowsBefore = await json('__gm.hierarchyOrder()');
|
||
check(
|
||
'the blue crate is listed after the red one to begin with',
|
||
rowsBefore.indexOf('o2') > rowsBefore.indexOf('o1'),
|
||
JSON.stringify(rowsBefore),
|
||
);
|
||
const redRow3 = await locate('Red crate');
|
||
const blueRow3 = await locate('Blue crate');
|
||
// 10 px above the centre of a 26 px row is its top quarter – the gap, not the row.
|
||
await drag(blueRow3.x, blueRow3.y, redRow3.x, redRow3.y - 10);
|
||
await sleep(150);
|
||
const rowsAfter = await json('__gm.hierarchyOrder()');
|
||
o2 = await object('o2');
|
||
check(
|
||
'dropping into the gap reorders instead of parenting',
|
||
rowsAfter.indexOf('o2') < rowsAfter.indexOf('o1') && o2.parentId === '',
|
||
JSON.stringify({ rowsAfter, parentId: o2.parentId }),
|
||
);
|
||
check(
|
||
'and a reorder moves nothing in the world',
|
||
o2.position.x === 120 && o2.position.y === -80,
|
||
JSON.stringify(o2.position),
|
||
);
|
||
await shot('31-reordered');
|
||
await undo();
|
||
await sleep(150);
|
||
check(
|
||
'undo puts the order back',
|
||
same(await json('__gm.hierarchyOrder()'), rowsBefore),
|
||
JSON.stringify(await json('__gm.hierarchyOrder()')),
|
||
);
|
||
}
|