Resolve container content from the template instead of copying it

A scene now stores only the container object; its content is resolved from
the template on every read, with deviations kept as sparse instanceEdits on
the container object. This drops the propagation bookkeeping entirely --
templateSync.ts is gone and the three structural gaps (add, remove, reparent)
cannot diverge any more.

Values stay editable on an instance, structure does not; Unpack writes the
content into the scene, keeping ids. Play mode bakes containers into the
throwaway copy before entities are built. No migration: formatVersion is 2
and the seeders write the new format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 21:09:53 +02:00
1 parent 9d93020bd8
commit ead649eec3
37 files changed
+1878 -1006

No files matched your search

+63 -99
View File
@@ -1,41 +1,31 @@
import { newId } from '../ids';
import { isPlaceableType } from '../model/AssetTypes';
import { containerReaches, instantiateContainer } from '../model/container';
import { containerReaches } from '../model/container';
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
import {
type Position,
type PropertyBag,
type PropertyValue,
toPosition,
type Vec2,
} from '../model/Properties';
import { captureObject, isLiveInstance, type ObjectMemo, restoreObject } from '../model/instance';
import { type Position, type PropertyValue, toPosition, type Vec2 } from '../model/Properties';
import type { Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue } from '../properties/PropertySchema';
import { type Removed, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireAsset, requireObject, requirePropDef, requireHolder } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
import {
ALL_AXES,
nameSlot,
overrideSlot,
positionSlot,
restoreSlots,
type SlotMemo,
syncTemplateEdit,
} from './templateSync';
requireAsset,
requireObject,
requireObjectRef,
requirePropDef,
requireHolder,
requireStructural,
} from './require';
/**
* The position is a world position; with a parent it is converted to the parent's local space.
* Placing a container into a scene also creates its content (see `instantiateContainer`); into
* a container template it does not, because the template spells out one object per placed copy
* and `instantiateContainer` expands the nested one when the copy is made.
* Placing a container adds the container object alone – its content is resolved from the template
* (see `model/instance.ts`), so it is never copied into the scene.
*/
export class PlaceAssetCommand implements Command {
readonly label = 'Place asset';
readonly objectId = newId();
private createdIds: string[] = [];
constructor(
private readonly holderId: string,
@@ -48,25 +38,19 @@ export class PlaceAssetCommand implements Command {
const holder = requireHolder(project, this.holderId);
const asset = requireAsset(project, this.assetId);
if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene');
if (
holder.type === 'container' &&
asset.type === 'container' &&
containerReaches(project, asset, holder.id)
)
if (asset.type === 'container' && containerReaches(project, asset, holder.id))
throw new CommandError(`${asset.name} cannot contain ${holder.name}: that nests forever`);
if (this.parentId) requireObject(project, this.holderId, this.parentId);
if (this.parentId) {
requireObject(project, this.holderId, this.parentId);
requireStructural(project, holder, this.parentId);
}
const local = localPositionFor(holder, this.parentId, toPosition(this.position));
const created = [createSceneObject(this.objectId, this.assetId, local, this.parentId)];
if (asset.type === 'container' && holder.type === 'scene')
created.push(...instantiateContainer(project, asset, this.objectId));
this.createdIds = created.map((o) => o.id);
holder.objects.push(...created);
holder.objects.push(createSceneObject(this.objectId, this.assetId, local, this.parentId));
}
undo(project: Project): void {
const holder = requireHolder(project, this.holderId);
const ids = new Set(this.createdIds);
holder.objects = holder.objects.filter((o) => !ids.has(o.id));
holder.objects = holder.objects.filter((o) => o.id !== this.objectId);
}
}
@@ -83,8 +67,12 @@ export class RemoveSceneObjectsCommand implements Command {
}
execute(project: Project): void {
for (const id of this.objectIds) requireObject(project, this.holderId, id);
this.removed = removeSubtrees(requireHolder(project, this.holderId), this.objectIds);
const holder = requireHolder(project, this.holderId);
for (const id of this.objectIds) {
requireStructural(project, holder, id);
requireObject(project, this.holderId, id);
}
this.removed = removeSubtrees(holder, this.objectIds);
}
undo(project: Project): void {
@@ -104,8 +92,7 @@ export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand {
*/
export class MoveSceneObjectCommand implements Command {
readonly label = 'Move object';
private previous: Position = { x: 0, y: 0, z: 0 };
private memos: SlotMemo<number>[] = [];
private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
@@ -114,28 +101,20 @@ export class MoveSceneObjectCommand implements Command {
) {}
execute(project: Project): void {
const object = requireObject(project, this.holderId, this.objectId);
this.previous = { ...object.position };
object.position = { ...this.position };
this.memos = syncTemplateEdit(
project,
this.holderId,
object,
ALL_AXES.map(positionSlot),
ALL_AXES.map((axis) => this.previous[axis]),
);
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
ref.setPosition(this.position);
}
undo(project: Project): void {
restoreSlots(this.memos);
requireObject(project, this.holderId, this.objectId).position = { ...this.previous };
undo(): void {
if (this.memo) restoreObject(this.memo);
}
}
/** Editor-only: undoable like any other edit, but it changes nothing the game can see. */
export class SetEditorOffsetCommand implements Command {
readonly label = 'Editor offset';
private previous: Vec2 = { x: 0, y: 0 };
private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
@@ -144,20 +123,19 @@ export class SetEditorOffsetCommand implements Command {
) {}
execute(project: Project): void {
const object = requireObject(project, this.holderId, this.objectId);
this.previous = { ...object.editorOffset };
object.editorOffset = { x: this.offset.x, y: this.offset.y };
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
ref.setEditorOffset(this.offset);
}
undo(project: Project): void {
requireObject(project, this.holderId, this.objectId).editorOffset = { ...this.previous };
undo(): void {
if (this.memo) restoreObject(this.memo);
}
}
export class RenameSceneObjectCommand implements Command {
readonly label = 'Rename object';
private previous = '';
private memos: SlotMemo<string>[] = [];
private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
@@ -166,53 +144,36 @@ export class RenameSceneObjectCommand implements Command {
) {}
execute(project: Project): void {
const object = requireObject(project, this.holderId, this.objectId);
this.previous = object.name;
object.name = this.name;
this.memos = syncTemplateEdit(project, this.holderId, object, [nameSlot], [this.previous]);
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
ref.setName(this.name);
}
undo(project: Project): void {
restoreSlots(this.memos);
requireObject(project, this.holderId, this.objectId).name = this.previous;
undo(): void {
if (this.memo) restoreObject(this.memo);
}
}
abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
private memos: SlotMemo<PropertyValue | undefined>[] = [];
abstract class OverrideCommand implements Command {
private memo: ObjectMemo | undefined;
protected constructor(
private readonly holderId: string,
private readonly objectId: string,
key: string,
label: string,
) {
super(key, label);
protected readonly key: string,
readonly label: string,
) {}
protected abstract nextValue(object: SceneObject, project: Project): PropertyValue | undefined;
execute(project: Project): void {
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
ref.setOverride(this.key, this.nextValue(ref.object, project));
}
protected override resolveTarget(project: Project): SceneObject {
return requireObject(project, this.holderId, this.objectId);
}
protected override bagOf(object: SceneObject): PropertyBag {
return object.overrides;
}
override execute(project: Project): void {
const before = this.resolveTarget(project).overrides[this.key];
super.execute(project);
this.memos = syncTemplateEdit(
project,
this.holderId,
this.resolveTarget(project),
[overrideSlot(this.key)],
[before],
);
}
override undo(project: Project): void {
restoreSlots(this.memos);
super.undo(project);
undo(): void {
if (this.memo) restoreObject(this.memo);
}
}
@@ -226,7 +187,7 @@ export class SetOverrideCommand extends OverrideCommand {
super(holderId, objectId, key, `Override ${key}`);
}
protected override computeNext(object: SceneObject, project: Project): PropertyValue {
protected override nextValue(object: SceneObject, project: Project): PropertyValue {
return coerceValue(requirePropDef(requireAsset(project, object.assetId), this.key), this.value);
}
}
@@ -238,7 +199,7 @@ export class ClearOverrideCommand extends OverrideCommand {
// No schema check, unlike setting one: a key the asset no longer declares is exactly what one
// wants to be able to clear.
protected override computeNext(): undefined {
protected override nextValue(): undefined {
return undefined;
}
}
@@ -262,10 +223,13 @@ export class SetParentCommand implements Command {
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
requireStructural(project, holder, this.objectId);
const object = requireObject(project, this.holderId, this.objectId);
if (this.parentId) {
if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent');
requireObject(project, this.holderId, this.parentId);
requireStructural(project, holder, this.parentId);
if (isLiveInstance(project, holder, requireObject(project, this.holderId, this.parentId)))
throw new CommandError('A placed container shows its template; unpack it to put objects inside');
if (isSelfOrAncestor(holder, this.objectId, this.parentId))
throw new CommandError('An object cannot be parented to one of its own children');
}