Group scene objects in the hierarchy, for the editor only (#241)
game-maker / gate (push) Successful in 3m58s
game-maker / suite (push) Failing after 9m58s

A scene with a hundred crates fills the hierarchy with a hundred rows.
Groups bundle top-level objects under one folder row that folds away.
They exist for the editor only. Parenting, positions and the game do
not see them.

- Model: a scene or container template holds a list of groups, an
  object may carry a groupId. Old projects load unchanged. Loading
  drops empty groups and members that are no longer top level.
- Only top-level objects can join a group. Parenting a member takes it
  out of the group, and Undo puts it back. There are no groups in
  groups.
- Order: treeOrder lists the members together, where the first one
  stands. The same order decides drawing at equal z, in the editor and
  in the game, so the hierarchy and the picture cannot disagree. The
  stored object list stays untouched when grouping.
- Hierarchy: a group row shows a folder icon, the name and the member
  count. Clicking it selects all members and keeps it folded. Selecting
  a single member elsewhere unfolds it. Folding lasts for the session.
- Create with "Group..." in the object menu. A name dialog asks first,
  so creating is one Undo step and Cancel creates nothing.
- The group menu has Select members, Rename... and Ungroup. The object
  menu adds "Remove from group".
- Drag and drop: onto the group row appends, into a gap between members
  inserts there, next to a row outside leaves the group. Dragging the
  group row moves all members as a block. The drop rules live in
  hierarchyDrop.ts as a pure function.
- Copy and paste keeps a top-level copy in the group of its original.
  A container built from group members takes their place in the group.
- Clicking an object that is already selected together with others now
  keeps only that object selected. ListView narrows the selection on
  release, so a drag starting on such a row keeps the selection.

Not checked in the browser: groups in the container tab, "Remove from
group", Ctrl-click on a group row and a drag out of a multi-selection.
The code path covers them.

Tests:
- check.sh: 177 files, 2552 tests, CHECK EXIT 0
- e2e 1, 51, 52: ALL PASSED, 31 checks (new module 52)
- 19 new unit tests in object-groups.test.ts and hierarchy-drop.test.ts
- 9 counter-probes, all fail as expected (7 unit, 2 in the browser)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 01:17:46 +02:00
1 parent 2b89292eb4
commit 53fa63441b
26 files changed
+1174 -83

No files matched your search

+4 -2
View File
@@ -13,7 +13,7 @@ import {
findObject,
type ObjectTree,
treeOrder,
visibleTreeOrder,
hierarchyRows,
} from '../../src/core/model/hierarchy';
import { findScene } from '../../src/core/model/Project';
import { localPositionFor } from '../../src/core/properties/transform';
@@ -94,7 +94,9 @@ describe('scene hierarchy', () => {
stack.execute(new SetParentCommand(sceneId, c, a));
stack.execute(new SetParentCommand(sceneId, b, c));
const rows = (expanded: string[]) =>
visibleTreeOrder(sceneOf(), new Set(expanded)).map((e) => [e.object.id, e.depth, e.hasChildren]);
hierarchyRows(sceneOf(), new Set(expanded)).flatMap((e) =>
e.kind === 'object' ? [[e.object.id, e.depth, e.hasChildren]] : [],
);
// Nothing expanded (the default when a scene is opened) – only the top level is listed.
expect(rows([])).toEqual([[a, 0, true]]);
+213
View File
@@ -0,0 +1,213 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import {
GroupObjectsCommand,
RenameGroupCommand,
SetObjectGroupCommand,
UngroupCommand,
} from '../../src/core/commands/groupCommands';
import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands';
import { PlaceAssetCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import type { ContainerAsset } from '../../src/core/model/Asset';
import { copyObjects } from '../../src/core/model/clipboard';
import {
canGroup,
findObject,
groupMemberIds,
groupRowId,
hierarchyRows,
treeOrder,
} from '../../src/core/model/hierarchy';
import { instanceChildId, resolvedHolder } from '../../src/core/model/instance';
import { findAsset, findScene } from '../../src/core/model/Project';
import { serializeProject } from '../../src/core/store/serialize';
import { makeSpriteScene, place, roundTrip } from '../helpers';
/** Five top-level boxes a–e in that stored order; `c` has a child `k`. */
function fiveBoxes() {
const f = makeSpriteScene();
const [a, b, c, d, e] = [0, 1, 2, 3, 4].map((i) => place(f, f.spriteId, { x: i * 10, y: 0 }));
const k = place(f, f.spriteId, { x: 20, y: 10 }, c);
return { ...f, a: a!, b: b!, c: c!, d: d!, e: e!, k };
}
const ids = (entries: { object: { id: string } }[]) => entries.map((entry) => entry.object.id);
describe('hierarchy groups (#241)', () => {
it('lists a group as one block where its first member stands, and draws equal z in that order', () => {
const { stack, sceneId, sceneOf, a, b, c, d, e, k } = fiveBoxes();
stack.execute(new GroupObjectsCommand(sceneId, [b, d], 'Crates'));
// d moves up behind b; the stored list is not touched.
expect(ids(treeOrder(sceneOf()))).toEqual([a, b, d, c, k, e]);
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, d, e, k]);
});
it('shows a row over the members, indents them and folds them away', () => {
const { stack, sceneId, sceneOf, a, b, c, d, e, k } = fiveBoxes();
const group = new GroupObjectsCommand(sceneId, [c, e], 'Crates');
stack.execute(group);
const row = groupRowId(group.groupId);
const rows = (expanded: string[]) =>
hierarchyRows(sceneOf(), new Set(expanded)).map((r) =>
r.kind === 'group' ? [r.group.name, r.memberIds.length, r.depth] : [r.object.id, r.depth],
);
// Folded, the way a freshly opened scene starts.
expect(rows([])).toEqual([
[a, 0],
[b, 0],
['Crates', 2, 0],
[d, 0],
]);
// Open: members one step in, the child of a member one more.
expect(rows([row, c])).toEqual([
[a, 0],
[b, 0],
['Crates', 2, 0],
[c, 1],
[k, 2],
[e, 1],
[d, 0],
]);
expect(rows([row])).toEqual([
[a, 0],
[b, 0],
['Crates', 2, 0],
[c, 1],
[e, 1],
[d, 0],
]);
});
it('only groups top-level objects', () => {
const { stack, sceneId, sceneOf, a, k } = fiveBoxes();
expect(canGroup(sceneOf(), [a])).toBe(true);
expect(canGroup(sceneOf(), [a, k])).toBe(false);
expect(() => stack.execute(new GroupObjectsCommand(sceneId, [a, k], 'G'))).toThrow(/top-level/);
expect(sceneOf().groups).toBeUndefined();
expect(findObject(sceneOf(), a)!.groupId).toBeUndefined();
});
it('takes an object out of its group when it goes under a parent, and back on undo', () => {
const { stack, sceneId, sceneOf, a, b, c } = fiveBoxes();
const group = new GroupObjectsCommand(sceneId, [b, c], 'G');
stack.execute(group);
stack.execute(new SetParentCommand(sceneId, b, a));
expect(findObject(sceneOf(), b)!.groupId).toBeUndefined();
expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([c]);
stack.undo();
expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([b, c]);
});
it('renames, ungroups and moves members in and out, each one undo step', () => {
const { stack, sceneId, sceneOf, a, b, c } = fiveBoxes();
const group = new GroupObjectsCommand(sceneId, [a, b], 'Group');
stack.execute(group);
stack.execute(new RenameGroupCommand(sceneId, group.groupId, 'Crates'));
stack.execute(new SetObjectGroupCommand(sceneId, [c], group.groupId));
stack.execute(new SetObjectGroupCommand(sceneId, [a], ''));
expect(sceneOf().groups).toEqual([{ id: group.groupId, name: 'Crates' }]);
expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([b, c]);
stack.execute(new UngroupCommand(sceneId, group.groupId));
expect(sceneOf().groups).toBeUndefined();
expect(sceneOf().objects.some((o) => o.groupId)).toBe(false);
stack.undo();
expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([b, c]);
stack.undo();
stack.undo();
expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([a, b]);
stack.undo();
expect(sceneOf().groups).toEqual([{ id: group.groupId, name: 'Group' }]);
stack.undo();
expect(sceneOf().groups).toBeUndefined();
expect(sceneOf().objects.some((o) => o.groupId !== undefined)).toBe(false);
});
it('keeps groups across saving and drops what no longer holds together', () => {
const f = fiveBoxes();
const { project, stack, sceneId, a, b, c, k } = f;
const kept = new GroupObjectsCommand(sceneId, [a, c], 'Crates');
const emptied = new GroupObjectsCommand(sceneId, [b], 'Gone');
stack.execute(kept);
stack.execute(emptied);
stack.execute(new SetObjectGroupCommand(sceneId, [b], ''));
// A group id on a child and one naming no group, as a hand-edited file could have them.
const raw = serializeProject(project) as {
assets: { id: string; objects?: { id: string; groupId?: string }[] }[];
};
const scene = raw.assets.find((asset) => asset.id === sceneId)!;
scene.objects!.find((o) => o.id === k)!.groupId = kept.groupId;
scene.objects!.find((o) => o.id === b)!.groupId = 'nowhere';
const loaded = findScene(roundTrip(raw as never), sceneId)!;
expect(loaded.groups).toEqual([{ id: kept.groupId, name: 'Crates' }]);
expect(groupMemberIds(loaded, kept.groupId)).toEqual([a, c]);
expect(findObject(loaded, k)!.groupId).toBeUndefined();
expect(findObject(loaded, b)!.groupId).toBeUndefined();
});
it('loads a project from before groups unchanged', () => {
const { project, sceneId } = fiveBoxes();
const loaded = roundTrip(project);
expect(JSON.stringify(serializeProject(loaded))).not.toMatch(/group/i);
expect(findScene(loaded, sceneId)!.groups).toBeUndefined();
expect(treeOrder(findScene(loaded, sceneId)!).map((e) => e.object.id)).toEqual(
treeOrder(findScene(project, sceneId)!).map((e) => e.object.id),
);
});
it('pastes a copy into the group only at the top level of a holder that has the group', () => {
const { project, stack, sceneId, sceneOf, a, b } = fiveBoxes();
const group = new GroupObjectsCommand(sceneId, [a], 'Crates');
stack.execute(group);
const clip = copyObjects(project, sceneOf(), [a])!;
const beside = new PasteObjectsCommand(sceneId, clip, {}, { x: 5, y: 0 });
stack.execute(beside);
expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([a, beside.rootIds[0]]);
const under = new PasteObjectsCommand(sceneId, clip, { [a]: b }, { x: 5, y: 0 });
stack.execute(under);
expect(findObject(sceneOf(), under.rootIds[0]!)!.groupId).toBeUndefined();
const other = new CreateAssetCommand('scene', 'Other');
stack.execute(other);
const elsewhere = new PasteObjectsCommand(other.asset.id, clip, {}, { x: 0, y: 0 });
stack.execute(elsewhere);
expect(findObject(findScene(project, other.asset.id)!, elsewhere.rootIds[0]!)!.groupId).toBeUndefined();
});
it('puts a container made of one group in its place and leaves the template without groups', () => {
const { project, stack, sceneId, sceneOf, a, b, c } = fiveBoxes();
const group = new GroupObjectsCommand(sceneId, [a, b, c], 'Crates');
stack.execute(group);
const create = new CreateContainerCommand(sceneId, [a, b]);
stack.execute(create);
expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([c, create.objectId]);
const template = findAsset(project, create.assetId) as ContainerAsset;
expect(template.objects.some((o) => o.groupId !== undefined)).toBe(false);
});
it('orders the content of a placed container the way its template lists it', () => {
const f = makeSpriteScene();
const container = new CreateAssetCommand('container', 'Crates');
f.stack.execute(container);
const holderId = container.asset.id;
const [x, y, z] = [0, 1, 2].map((i) => {
const put = new PlaceAssetCommand(holderId, f.spriteId, { x: i * 10, y: 0 });
f.stack.execute(put);
return put.objectId;
});
f.stack.execute(new GroupObjectsCommand(holderId, [x!, z!], 'Pair'));
const template = findAsset(f.project, holderId) as ContainerAsset;
expect(ids(treeOrder(template))).toEqual([x, z, y]);
const instance = place(f, holderId, { x: 0, y: 0 });
const content = treeOrder(resolvedHolder(f.project, f.sceneOf()))
.filter((entry) => entry.object.parentId === instance)
.map((entry) => entry.object.id);
expect(content).toEqual([x, z, y].map((id) => instanceChildId(instance, id!)));
});
});