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

+3 -79
View File
@@ -1,46 +1,10 @@
import { newId } from '../ids';
import type { ContainerAsset } from './Asset';
import { chainOf, type ObjectTree } from './hierarchy';
import { findAsset, objectsWhere, type Project, type SceneObjectRef } from './Project';
import type { SceneObject } from './SceneObject';
export interface TemplateLink {
containerObject: SceneObject;
asset: ContainerAsset;
template: SceneObject;
}
/**
* The nearest container ancestor whose asset holds the template `object` was instantiated from;
* undefined when it has no template id or the link is stale.
*/
export function templateLinkOf(
project: Project,
tree: ObjectTree,
object: SceneObject,
): TemplateLink | undefined {
if (!object.templateId) return undefined;
for (const containerObject of chainOf(tree, object).slice(1)) {
const asset = findAsset(project, containerObject.assetId);
if (asset?.type !== 'container') continue;
const template = asset.objects.find((t) => t.id === object.templateId);
if (template) return { containerObject, asset, template };
}
return undefined;
}
/**
* Every placed copy of a template object. Template objects themselves never carry a template id, so
* one is never mistaken for a copy of itself even though the search covers container assets too.
*/
export function instancesOf(project: Project, templateId: string): SceneObjectRef[] {
return objectsWhere(project, (o) => o.templateId === templateId);
}
import { findAsset, type Project } from './Project';
/**
* Whether `wantedId` is that container or sits somewhere in its content. Editing a container's
* template can place containers into it, and a cycle would expand into thousands of objects the
* moment one of them is placed.
* template can place containers into it, and a cycle would expand forever the moment one of them
* is resolved.
*/
export function containerReaches(project: Project, asset: ContainerAsset, wantedId: string): boolean {
if (asset.id === wantedId) return true;
@@ -57,43 +21,3 @@ export function containerReaches(project: Project, asset: ContainerAsset, wanted
}
return false;
}
const MAX_NESTING = 8;
/**
* One fresh object per template object, parented below `containerObjectId` or below the copy of its
* template parent. A template object that is itself a container is expanded in turn, unless the
* template already spells its content out.
*/
export function instantiateContainer(
project: Project,
asset: ContainerAsset,
containerObjectId: string,
depth = 0,
): SceneObject[] {
const ids = new Map(asset.objects.map((t) => [t.id, newId()]));
const result: SceneObject[] = [];
for (const template of asset.objects) {
const id = ids.get(template.id)!;
const parentId = template.parentId
? (ids.get(template.parentId) ?? containerObjectId)
: containerObjectId;
result.push({
id,
assetId: template.assetId,
name: template.name,
position: { ...template.position },
// The editor offset is a tweak on one placed object, not something copies should inherit.
editorOffset: { x: 0, y: 0 },
overrides: { ...template.overrides },
parentId,
templateId: template.id,
});
const nested = findAsset(project, template.assetId);
const hasExplicitContent = asset.objects.some((o) => o.parentId === template.id);
if (nested?.type === 'container' && !hasExplicitContent && depth < MAX_NESTING) {
result.push(...instantiateContainer(project, nested, id, depth + 1));
}
}
return result;
}