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

+12
View File
@@ -1,4 +1,6 @@
import { type Application, type Container, Rectangle, Text } from 'pixi.js';
import { resolveObjects } from '../core/model/instance';
import { findObjectHolder } from '../core/model/Project';
import { Button, ui, Widget } from '../ui';
import type { CentreTab, Editor } from './Editor';
import type { EditorState } from './state';
@@ -103,8 +105,18 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const p = popup.getGlobalPosition();
return { x: p.x, y: p.y, w: popup.w, h: popup.h };
};
/**
* A holder's objects the way the editor sees them: the content of placed containers is resolved
* from the template, so it is nowhere in `holder.objects` (see `model/instance.ts`).
*/
const objectsOf = (holderId: string) => {
const holder = findObjectHolder(state.project, holderId);
return holder ? resolveObjects(state.project, holder) : [];
};
(window as unknown as { __gm: unknown }).__gm = {
state,
objectsOf,
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
locate,
overlayTexts,
overlayRect,
+151 -82
View File
@@ -6,6 +6,7 @@ import {
RevertPositionToTemplateCommand,
} from '../../core/commands/applyCommands';
import type { Command } from '../../core/commands/Command';
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
@@ -15,8 +16,16 @@ import {
SetParentCommand,
} from '../../core/commands/sceneObjectCommands';
import type { Asset } from '../../core/model/Asset';
import { type TemplateLink, templateLinkOf } from '../../core/model/container';
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import {
type InstanceTarget,
instanceTargetOf,
isLiveInstance,
isTemplateContent,
objectRef,
type ObjectRef,
resolvedHolder,
} from '../../core/model/instance';
import type { Axis, PropertyBag, PropertyValue } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
@@ -31,7 +40,7 @@ import {
deviatesFromTemplate,
resolveSceneObjectProps,
} from '../../core/properties/resolve';
import { Button, Dropdown, Menu, type MenuItem, TextField } from '../../ui';
import { Button, Dropdown, Label, Menu, type MenuItem, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { aspectActions } from './aspectAction';
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
@@ -43,6 +52,17 @@ const OFFSET_AXES = ['x', 'y'] as const;
const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
/** The holder with the content of placed containers resolved in – what the inspector addresses. */
function viewOf(project: Project, holderId: string): ObjectHolder | undefined {
const holder = findObjectHolder(project, holderId);
return holder && resolvedHolder(project, holder);
}
function targetOf(project: Project, holderId: string, objectId: string): InstanceTarget | undefined {
const holder = findObjectHolder(project, holderId);
return holder && instanceTargetOf(project, holder, objectId);
}
/** Structure-defining facts of an object selection: parent, template link and schema. */
export function objectSignature(
project: Project,
@@ -52,11 +72,17 @@ export function objectSignature(
): string {
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
const holder = findObjectHolder(project, holderId);
const object = holder && findObject(holder, objectId);
const view = holder && resolvedHolder(project, holder);
const object = view && findObject(view, objectId);
const asset = object && findAsset(project, object.assetId);
if (!object || !asset) return 'missing';
if (!holder || !object || !asset) return 'missing';
const kind = isTemplateContent(holder, objectId)
? 'template'
: isLiveInstance(project, holder, object)
? 'instance'
: '';
return (
`${object.parentId}|${object.templateId}|` +
`${object.parentId}|${kind}|` +
schemaForAsset(asset)
.map((d) => `${d.key}:${d.kind}`)
.join(',')
@@ -65,11 +91,11 @@ export function objectSignature(
/** Several objects: what they are, and the actions that apply to all of them at once. */
export function buildObjectsSection(host: InspectorHost, holderId: string, objectIds: string[]): void {
const holder = findObjectHolder(host.state.project, holderId);
if (!holder) return;
const view = viewOf(host.state.project, holderId);
if (!view) return;
addTitle(host, `${objectIds.length} OBJECTS SELECTED`);
for (const id of objectIds) {
const object = findObject(holder, id);
const object = findObject(view, id);
const asset = object && findAsset(host.state.project, object.assetId);
if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`);
}
@@ -77,6 +103,12 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec
addNote(host, 'Ctrl-click adds or removes objects.', 'small');
addNote(host, 'Dragging one in the viewport moves all of them.', 'small');
addGap(host);
const holder = findObjectHolder(host.state.project, holderId)!;
if (objectIds.some((id) => isTemplateContent(holder, id))) {
addNote(host, 'Part of the selection comes from a container', 'small');
addNote(host, 'template, so grouping and removing are off.', 'small');
return;
}
host.body.add(
new Button('Create container', { onClick: () => createContainer(host.state, holderId, objectIds) }),
);
@@ -93,10 +125,12 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec
/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */
export function buildObjectSection(host: InspectorHost, holderId: string, objectId: string): void {
const holder = findObjectHolder(host.state.project, holderId);
const object = holder && findObject(holder, objectId);
const asset = object && findAsset(host.state.project, object.assetId);
if (!holder || !object || !asset) return;
const { project } = host.state;
const view = viewOf(project, holderId);
const object = view && findObject(view, objectId);
const asset = object && findAsset(project, object.assetId);
if (!view || !object || !asset) return;
const target = targetOf(project, holderId, objectId);
addTitle(host, `${asset.type.toUpperCase()} OBJECT`);
host.nameField = new TextField(object.name, {
@@ -117,16 +151,15 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
);
host.body.add(assetRow.row);
host.body.add(parentRow(host, holder, object));
host.body.add(parentRow(host, view, object, target !== undefined));
// Part of a placed container: values compare against the template, not just the asset.
const link = templateLinkOf(host.state.project, holder, object);
if (link) {
// Content of a placed container: values compare against the template, not just the asset.
if (target) {
const linkRow = labelledRow('Part of');
linkRow.row.add(
new Button(link.asset.name, {
new Button(target.asset.name, {
variant: 'ghost',
onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }),
onClick: () => host.state.select({ kind: 'asset', assetId: target.asset.id }),
}),
1,
);
@@ -144,8 +177,8 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
key: axis,
label: axis.toUpperCase(),
axis,
read: (o) => o.position,
write: (o, v) => (o.position = v),
read: (ref) => ref.object.position,
write: (ref, value) => ref.setPosition(value),
command: (v) => new MoveSceneObjectCommand(holderId, objectId, v),
reset: () =>
moved()
@@ -177,12 +210,12 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
key: offsetKey(axis),
label: `Offset ${axis.toUpperCase()}`,
axis,
read: (o) => o.editorOffset,
write: (o, v) => (o.editorOffset = v),
read: (ref) => ref.object.editorOffset,
write: (ref, value) => ref.setEditorOffset(value),
command: (v) => new SetEditorOffsetCommand(holderId, objectId, v),
// No apply: the offset never leaves the placed object.
reset: () => {
const current = currentObject(host.state, holderId, objectId);
const current = currentRef(host.state, holderId, objectId)?.object;
if (!current || current.editorOffset[axis] === 0) return [];
return [
{
@@ -205,16 +238,11 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values);
markObjectRows(host, object, asset, link);
markObjectRows(host, object, asset, target);
if (asset.type === 'container') {
const count = asset.objects.length;
addTitle(host, 'CONTENT');
addNote(host, count === 0 ? 'The container is empty.' : `Instance with ${count} template object(s).`);
if (count > 0) addNote(host, 'Its content is listed below it in the hierarchy.', 'small');
}
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
if (asset.type !== 'collider') {
if (asset.type !== 'collider' && !target) {
addGap(host);
host.body.add(
new Button('+ Add collider', {
@@ -225,18 +253,53 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
}
}
/**
* What a placed container holds. While it follows its template the content cannot be changed here –
* unpacking writes it into the scene as ordinary objects, which cuts the link for good.
*/
function contentSection(
host: InspectorHost,
holderId: string,
object: SceneObject,
templateCount: number,
): void {
const holder = findObjectHolder(host.state.project, holderId);
const live = Boolean(holder && isLiveInstance(host.state.project, holder, object));
addTitle(host, 'CONTENT');
if (!live) {
addNote(host, 'Unpacked: its content is stored in the scene.');
return;
}
addNote(
host,
templateCount === 0 ? 'The container is empty.' : `Follows a template of ${templateCount} object(s).`,
);
if (templateCount === 0) return;
addNote(host, 'Its content is listed below it in the hierarchy.', 'small');
addGap(host);
host.body.add(
new Button('Unpack', {
variant: 'ghost',
onClick: () => host.state.execute(new UnpackContainerCommand(holderId, object.id)),
}),
);
addNote(host, 'Copies the content into the scene and stops', 'small');
addNote(host, 'following the template.', 'small');
}
/** Pushes the current model values into the rows. False when the object is gone (rebuild). */
export function refreshObjectSection(host: InspectorHost, holderId: string, objectId: string): boolean {
const holder = findObjectHolder(host.state.project, holderId);
const object = holder && findObject(holder, objectId);
const asset = object && findAsset(host.state.project, object.assetId);
if (!holder || !object || !asset) return false;
const { project } = host.state;
const view = viewOf(project, holderId);
const object = view && findObject(view, objectId);
const asset = object && findAsset(project, object.assetId);
if (!view || !object || !asset) return false;
host.nameField?.setValue(object.name);
for (const axis of POSITION_AXES) host.fields.get(axis)?.setValue(object.position[axis]);
for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setValue(object.editorOffset[axis]);
const values = resolveSceneObjectProps(asset, object);
for (const def of schemaForAsset(asset)) host.fields.get(def.key)?.setValue(values[def.key] ?? def.default);
markObjectRows(host, object, asset, templateLinkOf(host.state.project, holder, object));
markObjectRows(host, object, asset, targetOf(project, holderId, objectId));
return true;
}
@@ -244,8 +307,8 @@ interface ComponentSpec<V extends object> {
key: string;
label: string;
axis: keyof V & string;
read: (object: SceneObject) => V;
write: (object: SceneObject, value: V) => void;
read: (ref: ObjectRef) => V;
write: (ref: ObjectRef, value: V) => void;
command: (value: V) => Command;
reset?: FieldActions;
apply?: FieldActions;
@@ -268,12 +331,12 @@ function componentRow<V extends object>(
host,
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
value,
previewingHandlers<SceneObject, V>(host, {
target: () => currentObject(host.state, holderId, objectId),
previewingHandlers<ObjectRef, V>(host, {
target: () => currentRef(host.state, holderId, objectId),
// Copied: the snapshot outlives the scrub, the vector in the model does not.
read: (object) => ({ ...spec.read(object) }),
read: (ref) => ({ ...spec.read(ref) }),
write: spec.write,
toModel: (next, object) => withAxis(spec.read(object), next),
toModel: (next, ref) => withAxis(spec.read(ref), next),
commit: spec.command,
}),
{ reset: spec.reset, apply: spec.apply },
@@ -286,10 +349,9 @@ function componentRow<V extends object>(
* One editable property of a placed object: writes an override, offers ↺ (back to the asset
* or, inside a container, to the template) and ↑ (apply upwards).
*
* Inside a container a value has two bases – the template it was instantiated from and, one
* step further up, the asset – so ↺/↑ can become a menu. Dropping the override is what makes
* the object follow the asset again: the template is only read when the container is placed,
* never at draw time.
* Inside a container a value has two bases – the template it follows and, one step further up,
* the asset. Reset always goes to the nearest one, so content of a placed container offers only
* "Reset to container"; "Apply to asset" still reaches past it, dropping the template's override.
*/
function propertyRow(
host: InspectorHost,
@@ -304,11 +366,11 @@ function propertyRow(
host,
def,
values[def.key] ?? def.default,
previewingHandlers<SceneObject>(host, {
target: () => currentObject(host.state, holderId, objectId),
read: (object) => object.overrides[def.key],
write: (object, value) => (object.overrides[def.key] = value),
remove: (object) => delete object.overrides[def.key],
previewingHandlers<ObjectRef>(host, {
target: () => currentRef(host.state, holderId, objectId),
read: (ref) => ref.object.overrides[def.key],
write: (ref, value) => ref.setOverride(def.key, value),
remove: (ref) => ref.setOverride(def.key, undefined),
commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
}),
{
@@ -342,7 +404,7 @@ function propertyRow(
];
},
apply: () => {
const { template, asset } = bases();
const { template, applyToAsset } = bases();
return [
...(template
? [
@@ -353,7 +415,7 @@ function propertyRow(
},
]
: []),
...(asset
...(applyToAsset
? [
{
label: 'Apply to asset',
@@ -373,22 +435,28 @@ function propertyRow(
/**
* "Parent: <name> ▾" – picks another object of the same scene or container, or none. The object's own
* subtree is excluded (that would be a cycle). For behaviors the parent is the owner.
* subtree is excluded (that would be a cycle). For behaviors the parent is the owner. Content of a
* placed container only shows its parent: where it hangs is the template's business.
*/
function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject) {
function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject, fixed: boolean) {
const { row } = labelledRow('Parent');
const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?';
const parent = findObject(holder, object.parentId);
const button = new Dropdown(parent ? nameOf(parent) : 'None');
const name = parent ? nameOf(parent) : 'None';
if (fixed) {
row.add(new Label(name, { style: 'muted' }), 1);
return row;
}
const button = new Dropdown(name);
button.onClick = () => {
const items: MenuItem[] = [
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, '')) },
];
for (const { object: candidate, depth } of treeOrder(holder)) {
if (isSelfOrAncestor(holder, object.id, candidate.id)) continue;
const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
const label = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({
label: candidate.id === object.parentId ? `● ${name}` : name,
label: candidate.id === object.parentId ? `● ${label}` : label,
onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, candidate.id)),
});
}
@@ -400,49 +468,51 @@ function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObjec
}
/**
* Bold marks exactly what the ↺/↑ buttons offer: for a container instance a deviation from
* the template (position per axis, overrides), for any other object one from the asset. The
* Bold marks exactly what the ↺/↑ buttons offer: for content of a placed container a deviation
* from the template (position per axis, overrides), for any other object one from the asset. The
* editor offset has one base only – no offset at all.
*/
function markObjectRows(
host: InspectorHost,
object: SceneObject,
asset: Asset,
link: TemplateLink | undefined,
target: InstanceTarget | undefined,
): void {
for (const axis of POSITION_AXES)
host.fields
.get(axis)
?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
?.setModified(Boolean(target) && object.position[axis] !== target!.template.position[axis]);
for (const axis of OFFSET_AXES)
host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0);
for (const def of schemaForAsset(asset)) {
const deviates = link
? deviatesFromTemplate(object, link.template, def.key)
const deviates = target
? deviatesFromTemplate(object, target.template, def.key)
: deviatesFromAsset(object, asset, def.key);
host.fields.get(def.key)?.setModified(deviates);
}
}
/**
* The two bases a property can be reset to or applied upwards, each only when it actually
* differs: the template of the container the object belongs to, and the asset behind it.
* Evaluated on demand so a menu never acts on a stale snapshot.
* What the ↺/↑ menus may offer, each only when it actually differs. Reset goes to the nearest base
* only: dropping a deviation inside a container lands on the template, never past it. Evaluated on
* demand so a menu never acts on a stale snapshot.
*/
function deviations(
state: InspectorHost['state'],
holderId: string,
objectId: string,
key: string,
): { template: boolean; asset: boolean } {
const holder = findObjectHolder(state.project, holderId);
const object = holder && findObject(holder, objectId);
): { template: boolean; asset: boolean; applyToAsset: boolean } {
const view = viewOf(state.project, holderId);
const object = view && findObject(view, objectId);
const asset = object && findAsset(state.project, object.assetId);
if (!holder || !object || !asset) return { template: false, asset: false };
const link = templateLinkOf(state.project, holder, object);
if (!view || !object || !asset) return { template: false, asset: false, applyToAsset: false };
const target = targetOf(state.project, holderId, objectId);
const fromAsset = deviatesFromAsset(object, asset, key);
return {
template: Boolean(link) && deviatesFromTemplate(object, link!.template, key),
asset: deviatesFromAsset(object, asset, key),
template: Boolean(target) && deviatesFromTemplate(object, target!.template, key),
asset: !target && fromAsset,
applyToAsset: fromAsset,
};
}
@@ -453,24 +523,23 @@ function positionMoved(
objectId: string,
axis: Axis,
): boolean {
const holder = findObjectHolder(state.project, holderId);
const object = holder && findObject(holder, objectId);
const link = holder && object && templateLinkOf(state.project, holder, object);
return Boolean(link) && object!.position[axis] !== link!.template.position[axis];
const target = targetOf(state.project, holderId, objectId);
const object = currentRef(state, holderId, objectId)?.object;
return Boolean(target && object) && object!.position[axis] !== target!.template.position[axis];
}
function currentObject(
function currentRef(
state: InspectorHost['state'],
holderId: string,
objectId: string,
): SceneObject | undefined {
): ObjectRef | undefined {
const holder = findObjectHolder(state.project, holderId);
return holder && findObject(holder, objectId);
return holder && objectRef(state.project, holder, objectId);
}
/** The values the object shows right now, asset defaults and overrides merged. */
function currentProps(state: InspectorHost['state'], holderId: string, objectId: string): PropertyBag | null {
const object = currentObject(state, holderId, objectId);
const object = currentRef(state, holderId, objectId)?.object;
const asset = object && findAsset(state.project, object.assetId);
return object && asset ? resolveSceneObjectProps(asset, object) : null;
}
+19 -3
View File
@@ -1,6 +1,8 @@
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { hasColorSwatch } from '../../core/model/AssetTypes';
import { ancestorIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
@@ -79,8 +81,10 @@ export class HierarchyPanel extends Panel {
this.refresh();
}
/** With the content of placed containers resolved in – the rows show it like any other object. */
private holder(): ObjectHolder | undefined {
return this.source();
const holder = this.source();
return holder && resolvedHolder(this.state.project, holder);
}
/** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */
@@ -113,6 +117,11 @@ export class HierarchyPanel extends Panel {
const inScene = holder.type === 'scene';
const object = findObject(holder, id);
const type = object && findAsset(state.project, object.assetId)?.type;
// Content of a placed container belongs to its template: values can be changed here, the
// structure cannot (see `model/instance.ts`).
const source = this.source();
const fromTemplate = Boolean(source && isTemplateContent(source, id));
const instance = Boolean(object && source && isLiveInstance(state.project, source, object));
// Inside a multi-selection the menu acts on all selected objects.
const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id];
const many = selected.length > 1;
@@ -123,16 +132,22 @@ export class HierarchyPanel extends Panel {
},
{
label: 'Add collider',
disabled: !type || type === 'collider' || many,
disabled: !type || type === 'collider' || many || fromTemplate || instance,
onSelect: () => addCollider(state, holder.id, id),
},
{
label: many ? `Create container from ${selected.length} objects` : 'Create container',
disabled: !source || selected.some((oid) => isTemplateContent(source, oid)),
onSelect: () => createContainer(state, holder.id, selected),
},
{
label: 'Unpack container',
disabled: !instance,
onSelect: () => state.execute(new UnpackContainerCommand(holder.id, id)),
},
{
label: 'Unparent (move to top level)',
disabled: !object?.parentId || many,
disabled: !object?.parentId || many || fromTemplate,
onSelect: () => state.execute(new SetParentCommand(holder.id, id, '')),
},
{ label: 'Expand all', onSelect: () => this.expandAll() },
@@ -142,6 +157,7 @@ export class HierarchyPanel extends Panel {
? `Remove ${selected.length} objects from ${inScene ? 'scene' : 'container'}`
: `Remove from ${inScene ? 'scene' : 'container'}`,
danger: true,
disabled: !source || selected.some((oid) => isTemplateContent(source, oid)),
onSelect: () => removeObjects(state, holder.id, selected),
},
]);
+5 -2
View File
@@ -2,6 +2,7 @@ import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack';
import type { Asset, SceneAsset } from '../core/model/Asset';
import { findObject } from '../core/model/hierarchy';
import { resolvedHolder } from '../core/model/instance';
import {
createEmptyProject,
findAsset,
@@ -232,7 +233,8 @@ export class EditorState {
if (s.kind === 'asset' && !findAsset(this.project, s.assetId)) this.select(null);
if (s.kind === 'object') {
const holder = findObjectHolder(this.project, s.holderId);
const alive = holder ? s.objectIds.filter((id) => findObject(holder, id)) : [];
const view = holder && resolvedHolder(this.project, holder);
const alive = view ? s.objectIds.filter((id) => findObject(view, id)) : [];
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
}
if (s.kind === 'node') {
@@ -287,7 +289,8 @@ export class EditorState {
if (s.kind === 'asset') return findAsset(this.project, s.assetId);
if (s.kind !== 'object') return undefined;
const holder = findObjectHolder(this.project, s.holderId);
const assetId = holder && findObject(holder, s.objectId)?.assetId;
const view = holder && resolvedHolder(this.project, holder);
const assetId = view && findObject(view, s.objectId)?.assetId;
return assetId ? findAsset(this.project, assetId) : undefined;
}