Deleting an object in a container tab only cleared references inside the template. Placements in scenes and other containers kept their instance edit and showed "(missing)" in the inspector. - shownObjectIds records which objects every holder shows before the delete. dropReferencesToGone compares that with the state after it and clears each reference that no longer resolves. - A reference is resolved from its own place with referenceCandidates, the same way the runtime does. One rule covers instance edits, nested placements and full ids like p~crate. - A cleared instance edit is dropped when it now says the same as the template's own value. Without such a value a marked None stays, as if picked by hand, and Reset hands the row back to the template. - All cleared references come back with a single undo. - References that were already dead in saved projects stay untouched. Findings, not fixed here: - A reference from container D into a nested placement q is stored as q~<id>, but the copy is named r~q/<id> in every placement of D. The runtime finds nothing, so that reference is dead from the start. - Delete asset removes placed objects by cascade but does not clear references to them. Tests: - Unit object-references: 17 passed (13 + 4) - Gate: 198 files, 2873 tests, exit 0 - e2e 1-17,57: 564 checks passed, 57 has 13 (+1 for 57h) - Five unit counter-probes and one browser counter-probe fail as expected - e2e total 1607 is computed, not measured in a full run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
7.0 KiB
TypeScript
171 lines
7.0 KiB
TypeScript
/**
|
||
* An object's place names another object of the same scene by its id (#258): `{"$object": "<id>"}`,
|
||
* 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 (`<instance>~<path>`) 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<ObjectHolder, Set<string>> {
|
||
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<ObjectHolder, Set<string>>,
|
||
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;
|
||
}
|