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[] = []; /** Kept across a redo: the ids of the resolved content are built from them. */ private readonly templateIds = new Map(); 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 = {}; 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; } }