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 -3
View File
@@ -41,9 +41,9 @@ export interface ColliderAsset extends AssetBase {
}
/**
* `objects` is a template instantiated below the container whenever it is placed. Template
* positions are local to the container's origin, and every placed copy gets fresh ids and
* remembers the template object it came from (`SceneObject.templateId`).
* `objects` is a template, not content: a placed container shows it without copying it, so an
* edit here reaches every instance (see `model/instance.ts`). Positions are local to the
* container's origin.
*/
export interface ContainerAsset extends AssetBase {
type: 'container';
+12 -8
View File
@@ -1,9 +1,13 @@
import { schemaForAsset } from '../properties/PropertySchema';
import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset';
import { overrideSites } from './instance';
import type { SceneObject } from './SceneObject';
/** Reserved for format migrations – the loader does not branch on it yet. */
export const PROJECT_FORMAT_VERSION = 1;
/**
* 2 since a placed container stopped copying its template into the scene. Old files are not
* migrated: the loader simply reads no container content from them any more.
*/
export const PROJECT_FORMAT_VERSION = 2;
export interface Project {
formatVersion: number;
@@ -82,7 +86,8 @@ export function findUsages(project: Project, assetId: string): SceneObjectRef[]
export type PropertyUsage =
| { on: 'asset'; asset: Asset; key: string }
| { on: 'object'; holder: ObjectHolder; object: SceneObject; key: string };
/** `path` is empty for a placed object's own override, set for one a container instance keeps. */
| { on: 'object'; holder: ObjectHolder; object: SceneObject; path: string; key: string };
/**
* The other half of "where is this asset used": a texture is never placed, it is worn through a
@@ -102,11 +107,10 @@ export function findPropertyUsages(project: Project, ids: string | readonly stri
refKeys.set(asset.id, keys);
for (const key of keys) if (matches(asset.props[key])) result.push({ on: 'asset', asset, key });
}
for (const holder of objectHolders(project)) {
for (const object of holder.objects) {
for (const key of refKeys.get(object.assetId) ?? [])
if (matches(object.overrides[key])) result.push({ on: 'object', holder, object, key });
}
for (const site of overrideSites(project, () => true)) {
for (const key of refKeys.get(site.assetId) ?? [])
if (matches(site.bag[key]))
result.push({ on: 'object', holder: site.holder, object: site.object, path: site.path, key });
}
return result;
}
+6 -4
View File
@@ -1,3 +1,4 @@
import type { InstanceEdit } from './instance';
import { type Position, type PropertyBag, toPosition, type Vec2 } from './Properties';
/**
@@ -23,10 +24,11 @@ export interface SceneObject {
*/
parentId: string;
/**
* The template object inside the container asset this one was instantiated from, empty for
* everything else – the link that lets template changes reach the instances later on.
* Only on a placed container: what the user changed about its content, keyed by the path of
* template ids leading to the object (see `model/instance.ts`). The content itself is not
* stored – it follows the template.
*/
templateId: string;
instanceEdits: Record<string, InstanceEdit>;
}
export function createSceneObject(
@@ -43,6 +45,6 @@ export function createSceneObject(
editorOffset: { x: 0, y: 0 },
overrides: {},
parentId,
templateId: '',
instanceEdits: {},
};
}
+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;
}
+429
View File
@@ -0,0 +1,429 @@
/**
* A container placed in a scene is not copied into it. The scene stores the container object
* alone; its content is resolved from the template on every read, so a template edit reaches
* every instance at once. What the user changes on one of those resolved objects is kept as a
* sparse `InstanceEdit` on the instance, keyed by the path of template ids leading to it – and
* only where it actually deviates, which is what makes an untouched value keep following.
*
* Ids of resolved content are derived (`<instance id>~<path>`), never stored, and stable as long
* as the template is: the selection, the hierarchy and the viewport address them like any other.
*/
import type { Asset, ContainerAsset } from './Asset';
import type { ObjectTree } from './hierarchy';
import {
type Axis,
type Position,
type PropertyBag,
type PropertyValue,
setBagValue,
type Vec2,
} from './Properties';
import { findAsset, type ObjectHolder, objectHolders, type Project } from './Project';
import type { SceneObject } from './SceneObject';
/** Neither character can occur inside an id (see `newId`), so a derived id parses back uniquely. */
const ID_SEP = '~';
const PATH_SEP = '/';
/** A container inside a container inside … – the same guard `instantiate` needs against cycles. */
const MAX_NESTING = 8;
const AXES: readonly Axis[] = ['x', 'y', 'z'];
/** Sparse deviations of one resolved object; everything absent follows the template. */
export interface InstanceEdit {
name?: string;
position: Partial<Record<Axis, number>>;
editorOffset?: Vec2;
overrides: PropertyBag;
}
export function emptyEdit(): InstanceEdit {
return { position: {}, overrides: {} };
}
export function isEmptyEdit(edit: InstanceEdit): boolean {
return (
edit.name === undefined &&
edit.editorOffset === undefined &&
Object.keys(edit.position).length === 0 &&
Object.keys(edit.overrides).length === 0
);
}
function copyEdit(edit: InstanceEdit): InstanceEdit {
return {
...(edit.name === undefined ? {} : { name: edit.name }),
...(edit.editorOffset === undefined ? {} : { editorOffset: { ...edit.editorOffset } }),
position: { ...edit.position },
overrides: { ...edit.overrides },
};
}
/** The outer edit wins per field and per key; anything it leaves out keeps the inner value. */
function mergeEdits(
inner: InstanceEdit | undefined,
outer: InstanceEdit | undefined,
): InstanceEdit | undefined {
if (!inner) return outer;
if (!outer) return inner;
return {
name: outer.name ?? inner.name,
editorOffset: outer.editorOffset ?? inner.editorOffset,
position: { ...inner.position, ...outer.position },
overrides: { ...inner.overrides, ...outer.overrides },
};
}
export function containerAssetOf(project: Project, object: SceneObject): ContainerAsset | undefined {
const asset: Asset | undefined = findAsset(project, object.assetId);
return asset?.type === 'container' ? asset : undefined;
}
/**
* The placed containers still showing their template, with the asset behind each. Content spelled
* out in the holder means the instance was unpacked (or baked for a play run) and is an ordinary
* parent from then on.
*/
function liveInstances(project: Project, holder: ObjectTree): Map<SceneObject, ContainerAsset> {
const parents = new Set(holder.objects.map((o) => o.parentId));
const live = new Map<SceneObject, ContainerAsset>();
for (const object of holder.objects) {
if (parents.has(object.id)) continue;
const asset = containerAssetOf(project, object);
if (asset) live.set(object, asset);
}
return live;
}
export function isLiveInstance(project: Project, holder: ObjectTree, object: SceneObject): boolean {
return liveInstances(project, holder).has(object);
}
export function instanceChildId(instanceId: string, path: string): string {
return `${instanceId}${ID_SEP}${path}`;
}
/** Splits a derived id back into the instance it belongs to and the template path below it. */
export function splitInstanceId(id: string): { instanceId: string; path: string } | undefined {
const at = id.indexOf(ID_SEP);
if (at < 0) return undefined;
return { instanceId: id.slice(0, at), path: id.slice(at + 1) };
}
/**
* Whether the object is only resolved from a template rather than stored in the holder. The
* question is where it lives, not what its id looks like: unpacking keeps the derived id – so the
* selection and the running game survive it – and the object is an ordinary one from then on.
*/
export function isTemplateContent(holder: ObjectTree, objectId: string): boolean {
return !holder.objects.some((o) => o.id === objectId);
}
function resolveChild(
template: SceneObject,
edit: InstanceEdit | undefined,
id: string,
parentId: string,
): SceneObject {
const position = { ...template.position };
for (const axis of AXES) {
const own = edit?.position[axis];
if (own !== undefined) position[axis] = own;
}
return {
id,
assetId: template.assetId,
name: edit?.name ?? template.name,
position,
editorOffset: edit?.editorOffset ? { ...edit.editorOffset } : { x: 0, y: 0 },
overrides: { ...template.overrides, ...edit?.overrides },
parentId,
instanceEdits: {},
};
}
function expand(
project: Project,
asset: ContainerAsset,
instanceId: string,
prefix: string,
editAt: (path: string) => InstanceEdit | undefined,
rootParentId: string,
depth: number,
out: SceneObject[],
): void {
for (const template of asset.objects) {
const path = prefix ? `${prefix}${PATH_SEP}${template.id}` : template.id;
const id = instanceChildId(instanceId, path);
const parentId = template.parentId
? instanceChildId(instanceId, prefix ? `${prefix}${PATH_SEP}${template.parentId}` : template.parentId)
: rootParentId;
out.push(resolveChild(template, editAt(path), id, parentId));
const nested = containerAssetOf(project, template);
const spelledOut = asset.objects.some((o) => o.parentId === template.id);
if (!nested || spelledOut || depth >= MAX_NESTING) continue;
// The template object's own edits are the base, the outer instance's edits sit on top of them.
// `editAt` always takes the full path; the base is keyed relative to the nested container.
const own = template.instanceEdits;
const below = (full: string) => mergeEdits(own[full.slice(path.length + 1)], editAt(full));
expand(project, nested, instanceId, path, below, id, depth + 1, out);
}
}
/**
* The holder's objects with the content of every live container instance woven in. Stored objects
* are passed through by reference – only instance content is freshly built and must not be written
* to (see `objectRef`). Without a live instance the stored list is returned unchanged.
*/
export function resolveObjects(project: Project, holder: ObjectTree): SceneObject[] {
const live = liveInstances(project, holder);
if (live.size === 0) return holder.objects;
const out: SceneObject[] = [];
for (const object of holder.objects) {
out.push(object);
const asset = live.get(object);
if (asset) expand(project, asset, object.id, '', (p) => object.instanceEdits[p], object.id, 0, out);
}
return out;
}
/** The holder with its resolved objects – a read-only view for anything that walks the tree. */
export function resolvedHolder(project: Project, holder: ObjectHolder): ObjectHolder {
const objects = resolveObjects(project, holder);
return objects === holder.objects ? holder : { ...holder, objects };
}
export interface InstanceTarget {
/** The stored container object the edits live on. */
instance: SceneObject;
path: string;
/** The container asset that declares `template` – the one to open to change it for good. */
asset: ContainerAsset;
template: SceneObject;
}
/** Where a derived id points: the instance holding the edits, and the template object behind it. */
export function instanceTargetOf(
project: Project,
holder: ObjectTree,
objectId: string,
): InstanceTarget | undefined {
const split = splitInstanceId(objectId);
const instance = split && holder.objects.find((o) => o.id === split.instanceId);
if (!split || !instance) return undefined;
let asset = containerAssetOf(project, instance);
let template: SceneObject | undefined;
for (const segment of split.path.split(PATH_SEP)) {
const next = template ? containerAssetOf(project, template) : asset;
if (!next) return undefined;
asset = next;
template = next.objects.find((t) => t.id === segment);
if (!template) return undefined;
}
return asset && template ? { instance, path: split.path, asset, template } : undefined;
}
// --- writing -----------------------------------------------------------------------------------
/**
* Read/write access to a placed object, whether it is stored in the holder or resolved from a
* container template. `object` is the current state either way; the setters keep an instance edit
* to exactly what deviates from the template, so a value put back to the template's follows again.
*/
export interface ObjectRef {
readonly object: SceneObject;
/** The instance edits belong to, undefined for a stored object. */
readonly target: InstanceTarget | undefined;
setName(name: string): void;
setPosition(position: Position): void;
setEditorOffset(offset: Vec2): void;
setOverride(key: string, value: PropertyValue | undefined): void;
}
function editOf(target: InstanceTarget): InstanceEdit {
const existing = target.instance.instanceEdits[target.path];
if (existing) return existing;
const edit = emptyEdit();
target.instance.instanceEdits[target.path] = edit;
return edit;
}
function prune(target: InstanceTarget): void {
const edit = target.instance.instanceEdits[target.path];
if (edit && isEmptyEdit(edit)) delete target.instance.instanceEdits[target.path];
}
function instanceRef(target: InstanceTarget, object: SceneObject): ObjectRef {
const write = (mutate: (edit: InstanceEdit) => void): void => {
mutate(editOf(target));
prune(target);
};
return {
object,
target,
setName: (name) =>
write((edit) => {
if (name === target.template.name) delete edit.name;
else edit.name = name;
}),
setPosition: (position) =>
write((edit) => {
for (const axis of AXES) {
if (position[axis] === target.template.position[axis]) delete edit.position[axis];
else edit.position[axis] = position[axis];
}
}),
setEditorOffset: (offset) =>
write((edit) => {
if (offset.x === 0 && offset.y === 0) delete edit.editorOffset;
else edit.editorOffset = { x: offset.x, y: offset.y };
}),
setOverride: (key, value) =>
write((edit) => {
if (value === target.template.overrides[key]) delete edit.overrides[key];
else setBagValue(edit.overrides, key, value);
}),
};
}
function storedRef(object: SceneObject): ObjectRef {
return {
object,
target: undefined,
setName: (name) => {
object.name = name;
},
setPosition: (position) => {
object.position = { ...position };
},
setEditorOffset: (offset) => {
object.editorOffset = { x: offset.x, y: offset.y };
},
setOverride: (key, value) => setBagValue(object.overrides, key, value),
};
}
export function objectRef(project: Project, holder: ObjectTree, objectId: string): ObjectRef | undefined {
const stored = holder.objects.find((o) => o.id === objectId);
if (stored) return storedRef(stored);
const target = instanceTargetOf(project, holder, objectId);
const object = resolveObjects(project, holder).find((o) => o.id === objectId);
return target && object ? instanceRef(target, object) : undefined;
}
/** Everything a value edit can change, snapshotted so a command can put it back exactly. */
export type ObjectMemo =
| {
kind: 'stored';
object: SceneObject;
name: string;
position: Position;
editorOffset: Vec2;
overrides: PropertyBag;
}
| { kind: 'edit'; instance: SceneObject; path: string; edit: InstanceEdit | undefined };
export function captureObject(ref: ObjectRef): ObjectMemo {
if (ref.target) {
const edit = ref.target.instance.instanceEdits[ref.target.path];
return {
kind: 'edit',
instance: ref.target.instance,
path: ref.target.path,
edit: edit && copyEdit(edit),
};
}
const { object } = ref;
return {
kind: 'stored',
object,
name: object.name,
position: { ...object.position },
editorOffset: { ...object.editorOffset },
overrides: { ...object.overrides },
};
}
export function restoreObject(memo: ObjectMemo): void {
if (memo.kind === 'edit') {
if (memo.edit) memo.instance.instanceEdits[memo.path] = copyEdit(memo.edit);
else delete memo.instance.instanceEdits[memo.path];
return;
}
memo.object.name = memo.name;
memo.object.position = { ...memo.position };
memo.object.editorOffset = { ...memo.editorOffset };
memo.object.overrides = { ...memo.overrides };
}
/**
* One sparse bag of property overrides in the project, with the asset whose schema governs it:
* a placed object's own, or the deviations a container instance keeps for its content. Anything
* rewriting overrides across the project (removing or renaming a behavior variable, counting the
* uses of a texture) has to cover both, or it silently misses everything inside a container.
*/
export interface OverrideSite {
holder: ObjectHolder;
/** The stored object the bag lives on – the placed object itself, or the container instance. */
object: SceneObject;
/** Template path inside `object`; empty when the bag is the object's own overrides. */
path: string;
assetId: string;
bag: PropertyBag;
}
export function overrideSites(project: Project, wanted: (assetId: string) => boolean): OverrideSite[] {
const sites: OverrideSite[] = [];
for (const holder of objectHolders(project)) {
for (const object of holder.objects) {
if (wanted(object.assetId))
sites.push({ holder, object, path: '', assetId: object.assetId, bag: object.overrides });
for (const [path, edit] of Object.entries(object.instanceEdits)) {
const target = instanceTargetOf(project, holder, instanceChildId(object.id, path));
if (target && wanted(target.template.assetId))
sites.push({ holder, object, path, assetId: target.template.assetId, bag: edit.overrides });
}
}
}
return sites;
}
// --- unpacking ---------------------------------------------------------------------------------
/**
* The resolved content of an instance as objects that can be stored. The derived ids are kept:
* they are unique already, and anything pointing at one – the selection, a running game, a check
* script – keeps working across the unpack.
*/
export function materializeInstance(
project: Project,
holder: ObjectTree,
instance: SceneObject,
): SceneObject[] {
return resolveObjects(project, holder)
.filter((o) => splitInstanceId(o.id)?.instanceId === instance.id)
.map((o) => ({
...o,
position: { ...o.position },
editorOffset: { ...o.editorOffset },
overrides: { ...o.overrides },
instanceEdits: {},
}));
}
/**
* Spells out every live instance of the holder. The runtime needs it: entities are built from
* stored objects, and a behavior may destroy or move one of them – which a resolved copy could
* not remember. Returns true when something was written.
*/
export function bakeInstances(project: Project, holder: ObjectTree): boolean {
const parents = new Set(holder.objects.map((o) => o.parentId));
const live = holder.objects.filter((o) => !parents.has(o.id) && containerAssetOf(project, o) !== undefined);
if (live.length === 0) return false;
for (const instance of live) {
holder.objects.push(...materializeInstance(project, holder, instance));
instance.instanceEdits = {};
}
return true;
}