/** * 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 ObjectHolder, objectHolders, type Project } from './Project'; import { treeOrder, type ObjectTree } from './hierarchy'; import { instanceChildId, instanceTargetOf, isEmptyEdit, resolveObjects } from './instance'; import { mapNested, type PropertyBag, type PropertyValue, sameValue, tagged, tagOf, untag, } from './Properties'; import { objectName, 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; /** The object as the view of the holder knows it – where the eyedropper finds it (#276). */ objectId: 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 = objectName(object, findAsset(project, object.assetId)); choices.push({ ref: object.id.slice(scope.length), objectId: object.id, 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. `fromId` is the object the reference sits on. */ export function swapReferences( objects: readonly SceneObject[], swap: (ref: string, fromId: string) => string | undefined, recorder: Recorder, ): void { const inBag = (bag: PropertyBag, fromId: string) => { for (const [key, value] of Object.entries(bag)) { const next = withReferences(value, (ref) => swap(ref, fromId)); if (next !== value) recorder.set(bag, key, next); } }; for (const object of objects) { inBag(object.overrides, object.id); for (const [path, edit] of Object.entries(object.instanceEdits)) inBag(edit.overrides, instanceChildId(object.id, path)); } } /** The ids every holder shows, container content included – what a reference there can name. */ export function shownObjectIds(project: Project): Map> { return new Map( objectHolders(project).map((h) => [h, new Set(resolveObjects(project, h).map((o) => o.id))]), ); } /** * Empties every reference that named an object shown `before` and gone now – in the holder it was * removed from and in every placement of a container it was removed from (#269). A container edit * emptied down to what its template says goes, so the placement follows the template again. */ export function dropReferencesToGone( project: Project, before: Map>, recorder: Recorder, ): void { const edits: { holder: ObjectHolder; object: SceneObject; path: string; was: PropertyBag }[] = []; for (const [holder, now] of shownObjectIds(project)) { const was = before.get(holder); if (!was || [...was].every((id) => now.has(id))) continue; for (const object of holder.objects) for (const [path, edit] of Object.entries(object.instanceEdits)) edits.push({ holder, object, path, was: { ...edit.overrides } }); const swap = (ref: string, fromId: string) => { const named = referenceCandidates(fromId, ref).find((id) => was.has(id)); return named && !now.has(named) ? '' : undefined; }; swapReferences(holder.objects, swap, recorder); } // Only once every holder is done: the base of a nested placement is the edits of the one around it. for (const { holder, object, path, was } of edits) { const edit = object.instanceEdits[path]!; const emptied = Object.keys(was).filter((key) => edit.overrides[key] !== was[key]); const base = emptied.length && instanceTargetOf(project, holder, instanceChildId(object.id, path))?.base; if (!base) continue; for (const key of emptied) if (sameValue(edit.overrides[key], base.overrides[key])) recorder.delete(edit.overrides, key); if (isEmptyEdit(edit)) recorder.delete(object.instanceEdits, path); } } /** * 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 { return mapNested(value, (part) => { if (tagOf(part) !== 'object') return undefined; const ref = referencedId(part as PropertyValue); const next = ref ? swap(ref) : undefined; return next === undefined || next === ref ? part : objectReference(next); }) as PropertyValue; }