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

+6
View File
@@ -91,6 +91,8 @@ export class CreateContainerCommand implements Command {
parentId: isRoot ? '' : (templateIds.get(object.parentId) ?? ''),
instanceEdits: { ...object.instanceEdits },
};
// The template has no groups of its own.
delete template.groupId;
const objectAsset = findAsset(project, object.assetId);
if (isRoot && objectAsset)
carryTransform(
@@ -103,6 +105,10 @@ export class CreateContainerCommand implements Command {
}
const container = createSceneObject(this.objectId, this.assetId, containerLocal, containerParentId);
// Made of the members of one group, the container takes their place in it.
const rootGroups = new Set([...roots].map((id) => findObject(holder, id)!.groupId));
const groupId = rootGroups.size === 1 ? [...rootGroups][0] : undefined;
if (!containerParentId && groupId) container.groupId = groupId;
this.removed = removeSubtrees(holder, [...roots]);
project.assets.push(asset);
holder.objects.push(container);
+137
View File
@@ -0,0 +1,137 @@
import { newId } from '../ids';
import { findGroup } from '../model/hierarchy';
import type { ObjectHolder, Project } from '../model/Project';
import type { ObjectGroup } from '../model/SceneObject';
import { type Command, CommandError } from './Command';
import { requireHolder, requireStructuralObject } from './require';
/**
* An edit of the hierarchy folders (#241). It only ever touches `groups` and the objects'
* `groupId`, so undo puts both back as they were. A group left without members is not listed and
* is dropped on the next load.
*/
abstract class GroupEditCommand implements Command {
abstract readonly label: string;
private groups: ObjectGroup[] | undefined;
private groupIds = new Map<string, string | undefined>();
protected constructor(protected readonly holderId: string) {}
protected abstract apply(holder: ObjectHolder, project: Project): void;
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
this.groups = holder.groups?.map((g) => ({ ...g }));
this.groupIds = new Map(holder.objects.map((o) => [o.id, o.groupId]));
this.apply(holder, project);
}
undo(project: Project): void {
const holder = requireHolder(project, this.holderId);
if (this.groups) holder.groups = this.groups.map((g) => ({ ...g }));
else delete holder.groups;
for (const object of holder.objects) {
const was = this.groupIds.get(object.id);
if (was === undefined) delete object.groupId;
else object.groupId = was;
}
}
protected requireGroup(holder: ObjectHolder, groupId: string): ObjectGroup {
const group = findGroup(holder, groupId);
if (!group) throw new CommandError('That group no longer exists');
return group;
}
/** Puts the objects into the group; only top-level objects can go into one. */
protected join(
project: Project,
holder: ObjectHolder,
objectIds: readonly string[],
groupId: string,
): void {
const objects = objectIds.map((id) => requireStructuralObject(project, holder, id));
if (objects.some((o) => o.parentId))
throw new CommandError('Only top-level objects go into a group – a child stays under its parent');
for (const object of objects) object.groupId = groupId;
}
}
/** Makes a new group of top-level objects, taking them out of any group they were in. */
export class GroupObjectsCommand extends GroupEditCommand {
readonly label = 'Group objects';
readonly groupId = newId();
constructor(
holderId: string,
private readonly objectIds: readonly string[],
private readonly name: string,
) {
super(holderId);
}
protected override apply(holder: ObjectHolder, project: Project): void {
if (this.objectIds.length === 0) throw new CommandError('Select the objects to group first');
this.join(project, holder, this.objectIds, this.groupId);
holder.groups = [...(holder.groups ?? []), { id: this.groupId, name: this.name }];
}
}
/** Moves objects into a group, or out of every group with an empty `groupId`. */
export class SetObjectGroupCommand extends GroupEditCommand {
readonly label: string;
constructor(
holderId: string,
private readonly objectIds: readonly string[],
private readonly groupId: string,
) {
super(holderId);
this.label = groupId ? 'Move into group' : 'Remove from group';
}
protected override apply(holder: ObjectHolder, project: Project): void {
if (this.groupId) {
this.requireGroup(holder, this.groupId);
this.join(project, holder, this.objectIds, this.groupId);
return;
}
for (const id of this.objectIds) delete requireStructuralObject(project, holder, id).groupId;
}
}
export class RenameGroupCommand extends GroupEditCommand {
readonly label = 'Rename group';
constructor(
holderId: string,
private readonly groupId: string,
private readonly name: string,
) {
super(holderId);
}
protected override apply(holder: ObjectHolder): void {
this.requireGroup(holder, this.groupId).name = this.name;
}
}
/** Dissolves a group; its members stay where they are, at the top level. */
export class UngroupCommand extends GroupEditCommand {
readonly label = 'Ungroup';
constructor(
holderId: string,
private readonly groupId: string,
) {
super(holderId);
}
protected override apply(holder: ObjectHolder): void {
this.requireGroup(holder, this.groupId);
for (const object of holder.objects) if (object.groupId === this.groupId) delete object.groupId;
const rest = (holder.groups ?? []).filter((g) => g.id !== this.groupId);
if (rest.length > 0) holder.groups = rest;
else delete holder.groups;
}
}
+10 -2
View File
@@ -10,8 +10,16 @@ function firstFree(taken: ReadonlySet<string>, candidate: (i: number) => string,
/** "Sprite", "Sprite 2", "Sprite 3" … – the first name not yet used by an asset. */
export function nextAssetName(project: Project, base: string): string {
const taken = new Set(project.assets.map((a) => a.name));
return taken.has(base) ? firstFree(taken, (i) => `${base} ${i}`, 2) : base;
return nextFreeName(
project.assets.map((a) => a.name),
base,
);
}
/** `base`, else "`base` 2", "`base` 3" … – the first name none of `taken` holds. */
export function nextFreeName(taken: readonly string[], base: string): string {
const names = new Set(taken);
return names.has(base) ? firstFree(names, (i) => `${base} ${i}`, 2) : base;
}
/** "Layer 1", "Layer 2", … – the first numbered name none of `taken` holds. */
+4
View File
@@ -3,6 +3,7 @@ import { isTerminalType, terminalNode } from '../graph/nested';
import { nodeDef } from '../graph/NodeRegistry';
import { newId } from '../ids';
import type { GraphClip, ObjectClip } from '../model/clipboard';
import { findGroup } from '../model/hierarchy';
import { isLiveInstance } from '../model/instance';
import { placementRejection } from '../model/placement';
import type { Vec2 } from '../model/Properties';
@@ -59,10 +60,13 @@ export class PasteObjectsCommand implements Command {
const root = roots.get(object.id);
if (!root) {
copy.parentId = this.ids.get(object.parentId) ?? '';
delete copy.groupId;
return copy;
}
const parentId = this.parents[object.id] ?? '';
copy.parentId = parentId;
// The copy stays in its group when it lands at the top level of a holder that has the group.
if (parentId || !findGroup(holder, copy.groupId ?? '')) delete copy.groupId;
copy.position = localPositionFor(project, holder, parentId, {
x: root.world.x + this.shift.x,
y: root.world.y + this.shift.y,
+6 -2
View File
@@ -265,11 +265,12 @@ export class ReorderObjectCommand implements Command {
/**
* Re-parents an object ('' = top level). It keeps its world position: the local one is recomputed
* for the new parent.
* for the new parent. Going under a parent takes it out of its group, which holds top-level
* objects only (#241).
*/
export class SetParentCommand implements Command {
readonly label = 'Set parent';
private previous: { parentId: string; position: Position; overrides: PropertyBag } = {
private previous: { parentId: string; position: Position; overrides: PropertyBag; groupId?: string } = {
parentId: '',
position: { x: 0, y: 0, z: 0 },
overrides: {},
@@ -295,7 +296,9 @@ export class SetParentCommand implements Command {
parentId: object.parentId,
position: { ...object.position },
overrides: { ...object.overrides },
groupId: object.groupId,
};
if (this.parentId) delete object.groupId;
// The object stays where it is and how it looks: the new local position and, when the parent
// chain's turn or size changed, transform rows that absorb the difference.
const world = worldTransform(project, holder, object);
@@ -315,5 +318,6 @@ export class SetParentCommand implements Command {
object.parentId = this.previous.parentId;
object.position = { ...this.previous.position };
object.overrides = { ...this.previous.overrides };
if (this.previous.groupId !== undefined) object.groupId = this.previous.groupId;
}
}