Two findings from #269. A reference from a container into a placement nested in it was dead from the start, and deleting an asset left references to the objects it took along showing "(missing)". - A reference is stored the way its holder shows the object. In a container tab that is q~x for content of a nested placement q. - referenceCandidates translates it when the container is placed: in placement r, q~x becomes r~q/x. pathOfId and idOfPath in instance.ts do the mapping, objectChoices offers the same spelling the other way. The rule holds at any depth. - Storing q/x as proposed in #285 was dropped. The container tab would then have had to translate its own ids. - No migration. All projects and snapshots hold three references, none of them with ~ or /. - Create container lets a reference to content of a member placement follow it. It was left dead before. - Paste lets such a reference point into the copy. It kept pointing at the original before. Both go through the new renamedReference. - Delete asset records what every holder shows before the cascade and clears every reference to an object that is gone afterwards, with dropReferencesToGone from #269. The row shows None, undo restores the references (#286). Tests: - Gate: 198 files, 2882 tests (2873 + 9), exit 0 - Unit object-references: 26 passed (17 + 9) - e2e 1-17,57: 566 checks passed, 57 has +57i and +57j - Six unit counter-probes fail between 1 and 3 tests each - The browser counter-probe fails 57i and 57j as described in the issues - e2e total 1609 is computed, not measured in a full run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
7.7 KiB
TypeScript
185 lines
7.7 KiB
TypeScript
import { newId } from '../ids';
|
||
import type { ContainerAsset } from '../model/Asset';
|
||
import { findObject, removeSubtrees, subtreeIds, subtreeRoots, treeOrder } from '../model/hierarchy';
|
||
import {
|
||
instanceChildId,
|
||
type InstanceEdit,
|
||
isLiveInstance,
|
||
materializeInstance,
|
||
pathOfId,
|
||
} from '../model/instance';
|
||
import { anchorReferences, renamedReference, swapReferences } from '../model/objectRefs';
|
||
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 { Recorder } from '../util/recorder';
|
||
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>();
|
||
private readonly references = new Recorder();
|
||
|
||
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]);
|
||
// A reference to a member follows it (#258): inside the template to its template id, from the
|
||
// rest of the holder to what the placed container shows of it – content of a member placement
|
||
// included (#285).
|
||
const templateIdOf = (ref: string) => renamedReference(ref, templateIds);
|
||
swapReferences(asset.objects, templateIdOf, this.references);
|
||
const shown = (ref: string) => {
|
||
const moved = templateIdOf(ref);
|
||
return moved && instanceChildId(this.objectId, pathOfId(moved));
|
||
};
|
||
swapReferences(holder.objects, shown, this.references);
|
||
project.assets.push(asset);
|
||
holder.objects.push(container);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
this.references.revert();
|
||
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);
|
||
anchorReferences(created);
|
||
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;
|
||
}
|
||
}
|