Multiselection and container assets: create container from selection, template instances

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 17:40:51 +02:00
1 parent 91ee2feadd
commit 0b78382bcc
26 files changed
+897 -114

No files matched your search

+105
View File
@@ -664,6 +664,111 @@ try {
await sleep(200);
await shot('22-trigger-collider');
// 16. Multi-selection. Reset both crates to known spots, then Ctrl-click the second row in
// the hierarchy: both are selected, the inspector shows the multi view.
await evaluate(`(() => {
const objs = __gm.state.activeScene.objects;
const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2');
o1.position = { x: -150, y: 0, z: 0 }; o1.parentId = '';
o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = '';
objs.find(o => o.id === 'oc2').overrides = { width: 140 };
__gm.state.select(null);
__gm.state.preview();
})()`);
await sleep(200);
const clickMod = async (x, y, modifiers) => {
await mouse('mouseMoved', x, y);
await mouse('mousePressed', x, y, 'left', { modifiers });
await mouse('mouseReleased', x, y, 'left', { modifiers });
await sleep(150);
};
await clickText('Red crate');
const blueRow3 = await locate('Blue crate');
await clickMod(blueRow3.x, blueRow3.y, 2); // Ctrl
s = JSON.parse(await stateJson());
check('Ctrl-click in the hierarchy adds to the selection', s.selection?.kind === 'object' && JSON.stringify(s.selection.objectIds) === '["o1","o2"]' && s.selection.objectId === 'o2', JSON.stringify(s.selection));
check('inspector shows the multi-selection', Boolean(await evaluate(`__gm.locate('2 OBJECTS SELECTED')`)));
await shot('23-multi-selected');
await clickMod(blueRow3.x, blueRow3.y, 2);
s = JSON.parse(await stateJson());
check('Ctrl-click again removes the object from the selection', s.selection?.kind === 'object' && JSON.stringify(s.selection.objectIds) === '["o1"]', JSON.stringify(s.selection));
await clickMod(blueRow3.x, blueRow3.y, 2);
// 16a. Create container: both crates (with their colliders and the Mover) become the content
// of a new container asset; the scene gets one container object at the selection's
// centre (-15,-40), the crates hang below it with recomputed local positions, world
// positions are unchanged, and the container is selected.
const assetsBeforeContainer = await evaluate('__gm.state.project.assets.length');
const objectsBeforeContainer = await evaluate('__gm.state.activeScene.objects.length');
await clickText('Create container');
await sleep(250);
s = JSON.parse(await stateJson());
const containerObj = s.selection?.kind === 'object' ? JSON.parse(await objectJson(s.selection.objectId)) : null;
const containerAsset = containerObj ? JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === '${containerObj.assetId}'))`)) : null;
check('Create container adds one container asset and one placed container, selected', containerAsset?.type === 'container' && s.selection.objectIds.length === 1 && (await evaluate('__gm.state.project.assets.length')) === assetsBeforeContainer + 1 && (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeContainer + 1, JSON.stringify({ containerObj, type: containerAsset?.type }));
check('the container sits at the centre of the selection', containerObj && containerObj.parentId === '' && containerObj.position.x === -15 && containerObj.position.y === -40 && containerObj.position.z === 0, JSON.stringify(containerObj?.position));
let o1c = JSON.parse(await objectJson('o1'));
const o2c = JSON.parse(await objectJson('o2'));
check('the crates are re-parented below the container with local positions relative to its centre', o1c.parentId === containerObj?.id && o1c.position.x === -135 && o1c.position.y === 40 && o2c.parentId === containerObj?.id && o2c.position.x === 135 && o2c.position.y === -40, JSON.stringify({ o1: o1c.position, o2: o2c.position }));
check('the template holds the whole subtree (2 crates, 2 colliders, the Mover)', containerAsset?.objects.length === 5 && containerAsset.objects.filter((t) => t.parentId === '').length === 2 && o1c.templateId && containerAsset.objects.some((t) => t.id === o1c.templateId), JSON.stringify(containerAsset?.objects.map((t) => [t.assetId, t.parentId === '' ? 'root' : 'child'])));
const orderC = JSON.parse(await evaluate('JSON.stringify(__gm.hierarchyOrder())'));
const cAt = orderC.indexOf(containerObj?.id);
check('hierarchy lists the container with its content indented below', cAt >= 0 && JSON.stringify(orderC.slice(cAt)) === JSON.stringify([containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']), JSON.stringify(orderC));
check('container symbol is drawn in the overlay layer', containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay');
await shot('24-container-created');
// 16b. Dragging the container symbol in the viewport moves the whole group; undo restores it.
const cScreen = JSON.parse(await evaluate(`JSON.stringify(__gm.objectScreen('${containerObj.id}'))`));
await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y);
const containerMoved = JSON.parse(await objectJson(containerObj.id));
o1c = JSON.parse(await objectJson('o1'));
check('dragging the container moves the group (children keep their local position)', containerMoved.position.x === 85 && o1c.position.x === -135, JSON.stringify({ container: containerMoved.position, o1: o1c.position }));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
check('undo moves the container back', JSON.parse(await objectJson(containerObj.id)).position.x === -15);
// 16c. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks
// the objects whose position lies inside – the crate and its collider, not the Mover
// (at y = 110), not the camera (at the origin).
await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50);
s = JSON.parse(await stateJson());
check('rubber-band selection picks the objects inside it', s.selection?.kind === 'object' && JSON.stringify([...s.selection.objectIds].sort()) === '["o1","oc1"]', JSON.stringify(s.selection));
await shot('25-marquee');
// 16d. Dragging one of several selected objects moves all of them as one undo step.
const redScreen = JSON.parse(await evaluate(`JSON.stringify(__gm.objectScreen('o1'))`));
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] })`);
await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20);
o1c = JSON.parse(await objectJson('o1'));
const o2d = JSON.parse(await objectJson('o2'));
check('dragging a multi-selection moves every selected object', o1c.position.x === -85 && o2d.position.x === 185, JSON.stringify({ o1: o1c.position, o2: o2d.position }));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
o1c = JSON.parse(await objectJson('o1'));
check('one undo restores both', o1c.position.x === -135 && JSON.parse(await objectJson('o2')).position.x === 135);
// 16e. Delete with a multi-selection removes all of them (with subtrees) as one step.
const objectsBeforeDelete = await evaluate('__gm.state.activeScene.objects.length');
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] })`);
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
await sleep(150);
check('Delete removes the whole multi-selection with its subtrees', (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeDelete - 5);
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
check('undo restores all of them', (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeDelete);
// 16f. Placing the container asset again from the asset panel creates a second instance:
// the root plus 5 content objects with fresh ids, the crate copy at the same offset.
await clickText(containerAsset.name, 'right');
await sleep(150);
await clickText('Place in scene');
await sleep(250);
const afterPlace = JSON.parse(await evaluate('JSON.stringify(__gm.state.activeScene.objects)'));
const secondRoot = afterPlace.find((o) => o.assetId === containerAsset.id && o.id !== containerObj.id);
const secondCrate = secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId);
check('placing the container asset instantiates its content below a new container object', afterPlace.length === objectsBeforeDelete + 6 && secondCrate && secondCrate.position.x === -135 && secondCrate.position.y === 40, JSON.stringify({ count: afterPlace.length, secondCrate }));
await shot('26-container-placed');
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
ws.close();
} catch (error) {