/** * An object's place names another object of the same scene by its id (#258): `{"$object": ""}`, * empty for none. Inside a container the id is the one the template gives it, so every placement of * the container points at its own copy – which is why a reference is looked up from where it sits. */ import { findAsset, type Project } from './Project'; import { treeOrder, type ObjectTree } from './hierarchy'; import { resolveObjects } from './instance'; import { isList, isStruct, type PropertyBag, type PropertyValue, tagged, tagOf, untag } from './Properties'; import type { SceneObject } from './SceneObject'; import { Recorder } from '../util/recorder'; /** The id an object's place names; '' for none and for anything that is no object's place. */ export function referencedId(value: PropertyValue | undefined): string { if (tagOf(value) !== 'object') return ''; const id = untag(value); return typeof id === 'string' ? id : ''; } export function objectReference(id: string): PropertyValue { return tagged('object', id); } /** * What a derived id (`~`) has in front of the template id of its own container: * `inst~a/b` → `inst~a/`. A stored object has none. */ function scopeOf(objectId: string): string { const at = Math.max(objectId.lastIndexOf('/'), objectId.lastIndexOf('~')); return at < 0 ? '' : objectId.slice(0, at + 1); } /** * The ids `ref`, written on the object `fromId`, may stand for, nearest first: the copy in the same * container placement, then the id as written (a reference out of unpacked content keeps its id). */ export function referenceCandidates(fromId: string, ref: string): string[] { if (!ref) return []; const scope = scopeOf(fromId); return scope ? [scope + ref, ref] : [ref]; } export interface ObjectChoice { /** What the reference stores. */ ref: string; label: string; depth: number; } /** * What an object's place on `objectId` may name, in hierarchy order: every object of the holder, * or – for content of a placed container – the objects of that container, named as its template * names them. The object itself is one of them. */ export function objectChoices(project: Project, holder: ObjectTree, objectId: string): ObjectChoice[] { const objects = resolveObjects(project, holder); const stored = holder.objects.some((o) => o.id === objectId); const scope = stored ? '' : scopeOf(objectId); const choices: ObjectChoice[] = []; for (const { object, depth } of treeOrder({ ...holder, objects })) { if (!object.id.startsWith(scope)) continue; const label = object.name || findAsset(project, object.assetId)?.name || object.id; choices.push({ ref: object.id.slice(scope.length), label, depth }); } if (!scope) return choices; // The container's own content sits one level below it in the tree. const top = Math.min(...choices.map((c) => c.depth)); return choices.map((c) => ({ ...c, depth: c.depth - top })); } /** * Renames the references the objects' own values and their container edits hold, as `swap` says, * through `recorder` so an undo takes it back. */ export function swapReferences( objects: readonly SceneObject[], swap: (ref: string) => string | undefined, recorder: Recorder, ): void { const inBag = (bag: PropertyBag) => { for (const [key, value] of Object.entries(bag)) { const next = withReferences(value, swap); if (next !== value) recorder.set(bag, key, next); } }; for (const object of objects) { inBag(object.overrides); for (const edit of Object.values(object.instanceEdits)) inBag(edit.overrides); } } /** * Spells out the references of content just written into its holder (unpacked): a template id * becomes the id of the copy among `objects`, so the inspector finds it among the holder's own. */ export function anchorReferences(objects: readonly SceneObject[]): void { const ids = new Set(objects.map((o) => o.id)); for (const object of objects) swapReferences( [object], (ref) => referenceCandidates(object.id, ref).find((id) => ids.has(id)), new Recorder(), ); } /** * `value` with every reference `swap` has an answer for renamed – '' empties it. Untouched parts * keep their identity, so a caller can tell whether anything changed. */ function withReferences(value: PropertyValue, swap: (ref: string) => string | undefined): PropertyValue { if (tagOf(value) === 'object') { const ref = referencedId(value); const next = ref ? swap(ref) : undefined; return next === undefined || next === ref ? value : objectReference(next); } if (isList(value)) { const items = value.map((item) => withReferences(item, swap)); return items.some((item, i) => item !== value[i]) ? items : value; } if (isStruct(value) && tagOf(value) === undefined) { const entries = Object.entries(value); const mapped = entries.map(([key, field]) => [key, withReferences(field, swap)] as const); return mapped.some(([, field], i) => field !== entries[i]![1]) ? Object.fromEntries(mapped) : value; } return value; }