Files
feuerwerk/src/core/model/objectRefs.ts
T
adminandClaude Opus 5.5 214218d1b2 Clear references to a template object in every scene on delete (#269)
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>
2026-09-30 22:35:51 +02:00

171 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}