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>
164 lines
6.8 KiB
TypeScript
164 lines
6.8 KiB
TypeScript
import { newId } from '../ids';
|
||
import type { ContainerAsset } from '../model/Asset';
|
||
import { findObject, removeSubtrees, subtreeIds, subtreeRoots, treeOrder } from '../model/hierarchy';
|
||
import { type InstanceEdit, isLiveInstance, materializeInstance } from '../model/instance';
|
||
import { apply, invert, multiply, translation } from '../model/matrix';
|
||
import type { Position } from '../model/Properties';
|
||
import { findAsset, findObjectHolder, type Project } from '../model/Project';
|
||
import { createSceneObject, type SceneObject } from '../model/SceneObject';
|
||
import {
|
||
carryTransform,
|
||
localPositionFor,
|
||
parentTransform,
|
||
worldPosition,
|
||
worldTransform,
|
||
} from '../properties/transform';
|
||
import { removeById, type Removed, restoreAt } from '../util/list';
|
||
import { buildAsset } from './assetCommands';
|
||
import { type Command, CommandError } from './Command';
|
||
import { nextAssetName } from './naming';
|
||
import { requireHolder, requireStructuralObject } from './require';
|
||
|
||
/**
|
||
* Turns the selected objects and their descendants into a container asset placed in their stead, the
|
||
* template centred on the selection's bounding box (z = 0).
|
||
*/
|
||
export class CreateContainerCommand implements Command {
|
||
readonly label = 'Create container';
|
||
readonly assetId = newId();
|
||
readonly objectId = newId();
|
||
private removed: Removed<SceneObject>[] = [];
|
||
/** Kept across a redo: the ids of the resolved content are built from them. */
|
||
private readonly templateIds = new Map<string, string>();
|
||
|
||
constructor(
|
||
private readonly holderId: string,
|
||
private readonly objectIds: readonly string[],
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const holder = requireHolder(project, this.holderId);
|
||
const ids = [...new Set(this.objectIds)];
|
||
const selected = ids.map((id) => requireStructuralObject(project, holder, id));
|
||
if (selected.length === 0) throw new CommandError('Select at least one object to create a container');
|
||
|
||
const worlds = selected.map((o) => worldPosition(project, holder, o));
|
||
const centre: Position = {
|
||
x: Math.round((Math.min(...worlds.map((p) => p.x)) + Math.max(...worlds.map((p) => p.x))) / 2),
|
||
y: Math.round((Math.min(...worlds.map((p) => p.y)) + Math.max(...worlds.map((p) => p.y))) / 2),
|
||
z: 0,
|
||
};
|
||
|
||
// Members are collected in tree order, so a parent is always built before its children.
|
||
const roots = new Set(
|
||
subtreeRoots(
|
||
holder,
|
||
selected.map((o) => o.id),
|
||
),
|
||
);
|
||
const memberIds = subtreeIds(holder, roots);
|
||
const members = treeOrder(holder)
|
||
.map((e) => e.object)
|
||
.filter((o) => memberIds.has(o.id));
|
||
const rootParents = new Set([...roots].map((id) => findObject(holder, id)!.parentId));
|
||
const containerParentId = rootParents.size === 1 ? [...rootParents][0]! : '';
|
||
|
||
const asset = buildAsset(
|
||
'container',
|
||
nextAssetName(project, 'Container'),
|
||
this.assetId,
|
||
) as ContainerAsset;
|
||
const containerLocal = localPositionFor(project, holder, containerParentId, centre);
|
||
// The template is the container's frame: a root keeps its place and look inside it, which
|
||
// under a turned or sized parent means transform rows of its own (see `carryTransform`).
|
||
const containerWorld = multiply(
|
||
parentTransform(project, holder, containerParentId).m,
|
||
translation(containerLocal.x, containerLocal.y),
|
||
);
|
||
const templateIds = this.templateIds;
|
||
for (const object of members) if (!templateIds.has(object.id)) templateIds.set(object.id, newId());
|
||
for (const object of members) {
|
||
const isRoot = roots.has(object.id);
|
||
const world = worldTransform(project, holder, object);
|
||
const inside = apply(invert(containerWorld), { x: world.m.tx, y: world.m.ty });
|
||
const template: SceneObject = {
|
||
...object,
|
||
id: templateIds.get(object.id)!,
|
||
position: isRoot ? { x: inside.x, y: inside.y, z: world.z } : { ...object.position },
|
||
// A view-only offset belongs to the placed object, not to the template.
|
||
editorOffset: { x: 0, y: 0 },
|
||
overrides: { ...object.overrides },
|
||
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(
|
||
objectAsset,
|
||
template,
|
||
parentTransform(project, holder, object.parentId).m,
|
||
containerWorld,
|
||
);
|
||
asset.objects.push(template);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const holder = findObjectHolder(project, this.holderId);
|
||
if (holder) {
|
||
holder.objects = removeById(holder.objects, this.objectId);
|
||
restoreAt(holder.objects, this.removed);
|
||
}
|
||
project.assets = removeById(project.assets, this.assetId);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Writes the content a placed container shows into the holder as ordinary objects. It stops
|
||
* following the template from then on – which is what makes its structure editable, the one thing
|
||
* an instance does not allow.
|
||
*/
|
||
export class UnpackContainerCommand implements Command {
|
||
readonly label = 'Unpack container';
|
||
private createdIds: string[] = [];
|
||
private edits: Record<string, InstanceEdit> = {};
|
||
|
||
constructor(
|
||
private readonly holderId: string,
|
||
private readonly objectId: string,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const holder = requireHolder(project, this.holderId);
|
||
const object = requireStructuralObject(project, holder, this.objectId);
|
||
if (!isLiveInstance(project, holder, object))
|
||
throw new CommandError(`${object.name || 'That object'} is not a placed container`);
|
||
const created = materializeInstance(project, holder, object);
|
||
if (created.length === 0) throw new CommandError('The container is empty, there is nothing to unpack');
|
||
this.createdIds = created.map((o) => o.id);
|
||
this.edits = object.instanceEdits;
|
||
object.instanceEdits = {};
|
||
holder.objects.push(...created);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const holder = findObjectHolder(project, this.holderId);
|
||
if (!holder) return;
|
||
const ids = new Set(this.createdIds);
|
||
holder.objects = holder.objects.filter((o) => !ids.has(o.id));
|
||
const object = findObject(holder, this.objectId);
|
||
if (object) object.instanceEdits = this.edits;
|
||
}
|
||
}
|