Files
feuerwerk/src/core/commands/containerCommands.ts
T
adminandClaude Opus 5.5 5e21d473a4 Fix references into nested placements and on asset delete (#285, #286)
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>
2026-10-01 07:28:49 +02:00

185 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}