import { ApplyOverrideToAssetCommand, ApplyOverrideToContainerCommand, ApplyPositionToContainerCommand, RevertOverrideToTemplateCommand, RevertPositionToTemplateCommand, } from '../../core/commands/applyCommands'; import type { Command } from '../../core/commands/Command'; import { UnpackContainerCommand } from '../../core/commands/containerCommands'; import { ClearOverrideCommand, MoveSceneObjectCommand, RenameSceneObjectCommand, SetEditorOffsetCommand, SetOverrideCommand, SetParentCommand, } from '../../core/commands/sceneObjectCommands'; import type { Asset } from '../../core/model/Asset'; 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'; import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset, } from '../../core/properties/PropertySchema'; import { deviatesFromAsset, deviatesFromTemplate, resolveSceneObjectProps, } from '../../core/properties/resolve'; 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'; import { addGap, addNote, addTitle, type InspectorHost } from './host'; import { previewingHandlers } from './previewHandlers'; const POSITION_AXES: readonly Axis[] = ['x', 'y', 'z']; 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, holderId: string, objectIds: readonly string[], objectId: string, ): string { if (objectIds.length > 1) return `multi|${objectIds.join(',')}`; const holder = findObjectHolder(project, holderId); const view = holder && resolvedHolder(project, holder); const object = view && findObject(view, objectId); const asset = object && findAsset(project, object.assetId); if (!holder || !object || !asset) return 'missing'; const kind = isTemplateContent(holder, objectId) ? 'template' : isLiveInstance(project, holder, object) ? 'instance' : ''; return ( `${object.parentId}|${kind}|` + schemaForAsset(asset) .map((d) => `${d.key}:${d.kind}`) .join(',') ); } /** 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 view = viewOf(host.state.project, holderId); if (!view) return; addTitle(host, `${objectIds.length} OBJECTS SELECTED`); for (const id of objectIds) { 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}`); } addGap(host); 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) }), ); addNote(host, 'Groups the selection into a reusable container', 'small'); addNote(host, 'asset placed at its centre (Ctrl+G).', 'small'); addGap(host); host.body.add( new Button('Remove', { variant: 'danger', onClick: () => removeObjects(host.state, holderId, objectIds), }), ); } /** 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 { 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, { placeholder: asset.name, onChange: (name) => host.state.execute(new RenameSceneObjectCommand(holderId, objectId, name.trim())), }); const nameRow = labelledRow('Name'); nameRow.row.add(host.nameField, 1); host.body.add(nameRow.row); const assetRow = labelledRow('Asset'); assetRow.row.add( new Button(asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }), }), 1, ); host.body.add(assetRow.row); host.body.add(parentRow(host, view, object, target !== undefined)); // 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(target.asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: target.asset.id }), }), 1, ); host.body.add(linkRow.row); } // The master switch, right where it is looked for: above everything type-specific. for (const def of COMMON_PROPS) propertyRow(host, holderId, objectId, asset, def, resolveSceneObjectProps(asset, object)); addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION'); for (const axis of POSITION_AXES) { const moved = () => positionMoved(host.state, holderId, objectId, axis); componentRow(host, holderId, objectId, object.position[axis], { key: axis, label: axis.toUpperCase(), axis, read: (ref) => ref.object.position, write: (ref, value) => ref.setPosition(value), command: (v) => new MoveSceneObjectCommand(holderId, objectId, v), reset: () => moved() ? [ { label: 'Reset to container', onSelect: () => host.state.execute(new RevertPositionToTemplateCommand(holderId, objectId, axis)), }, ] : [], apply: () => moved() ? [ { label: 'Apply to container', onSelect: () => host.state.execute(new ApplyPositionToContainerCommand(holderId, objectId, axis)), }, ] : [], }); } // Where the object is *drawn* in the viewport, nothing else – for pulling stacked objects apart. addTitle(host, 'EDITOR OFFSET (SCENE VIEW ONLY)'); for (const axis of OFFSET_AXES) { componentRow(host, holderId, objectId, object.editorOffset[axis], { key: offsetKey(axis), label: `Offset ${axis.toUpperCase()}`, axis, 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 = currentRef(host.state, holderId, objectId)?.object; if (!current || current.editorOffset[axis] === 0) return []; return [ { label: 'Reset to 0', onSelect: () => host.state.execute( new SetEditorOffsetCommand(holderId, objectId, { ...current.editorOffset, [axis]: 0 }), ), }, ]; }, }); } // Asset properties (or behavior variables) with overrides; the common switch is shown above. const schema = schemaForAsset(asset); const values = resolveSceneObjectProps(asset, object); const own = schema.filter((def) => !isCommonProp(def.key)); if (own.length > 0 || asset.type === 'behavior') 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, target); if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length); if (asset.type !== 'collider' && !target) { addGap(host); host.body.add( new Button('+ Add collider', { variant: 'ghost', onClick: () => addCollider(host.state, holderId, objectId), }), ); } } /** * 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 { 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, targetOf(project, holderId, objectId)); return true; } interface ComponentSpec { key: string; label: string; axis: keyof V & string; read: (ref: ObjectRef) => V; write: (ref: ObjectRef, value: V) => void; command: (value: V) => Command; reset?: FieldActions; apply?: FieldActions; } /** * A number row editing one component of a vector on the object (position, editor offset). * The row edits one axis, the command carries the whole vector: the other components come * from the target after the preview was taken back, so they are the pre-preview ones. */ function componentRow( host: InspectorHost, holderId: string, objectId: string, value: number, spec: ComponentSpec, ): void { const withAxis = (vector: V, next: PropertyValue): V => ({ ...vector, [spec.axis]: Number(next) }); const row = createFieldRow( host, { key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 }, value, previewingHandlers(host, { target: () => currentRef(host.state, holderId, objectId), // Copied: the snapshot outlives the scrub, the vector in the model does not. read: (ref) => ({ ...spec.read(ref) }), write: spec.write, toModel: (next, ref) => withAxis(spec.read(ref), next), commit: spec.command, }), { reset: spec.reset, apply: spec.apply }, ); host.fields.set(spec.key, row); host.body.add(row.row); } /** * 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 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, holderId: string, objectId: string, owner: Asset, def: PropertyDef, values: Record, ): FieldRow { const bases = () => deviations(host.state, holderId, objectId, def.key); const row = createFieldRow( host, def, values[def.key] ?? def.default, previewingHandlers(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), }), { extra: aspectActions( host, owner, def, () => currentProps(host.state, holderId, objectId), (value) => host.state.execute(new SetOverrideCommand(holderId, objectId, def.key, value)), ), reset: () => { const { template, asset } = bases(); return [ ...(template ? [ { label: 'Reset to container', onSelect: () => host.state.execute(new RevertOverrideToTemplateCommand(holderId, objectId, def.key)), }, ] : []), ...(asset ? [ { label: 'Reset to asset', onSelect: () => host.state.execute(new ClearOverrideCommand(holderId, objectId, def.key)), }, ] : []), ]; }, apply: () => { const { template, applyToAsset } = bases(); return [ ...(template ? [ { label: 'Apply to container', onSelect: () => host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)), }, ] : []), ...(applyToAsset ? [ { label: 'Apply to asset', onSelect: () => host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)), }, ] : []), ]; }, }, ); host.fields.set(def.key, row); host.body.add(row.row); return row; } /** * "Parent: ▾" – 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. 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, 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 name = parent ? nameOf(parent) : 'None'; if (fixed) { row.add(new Label(name, { style: 'muted' }), 1); return row; } const button = new Dropdown(name); button.onClick = () => { // Resolved anew on every open: `holder` is a derived copy of the scene, so one captured when // the row was built lists the objects of that moment, not the ones there now. const current = viewOf(host.state.project, holder.id) ?? holder; const items: MenuItem[] = [ { label: 'None', onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, '')) }, ]; for (const { object: candidate, depth } of treeOrder(current)) { if (isSelfOrAncestor(current, object.id, candidate.id)) continue; const label = `${' '.repeat(depth)}${nameOf(candidate)}`; items.push({ label: candidate.id === object.parentId ? `● ${label}` : label, onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, candidate.id)), }); } // Searchable: this lists every object of the scene or container, which grows with the project. new Menu(items, { searchable: true }).openBelow(button); }; row.add(button, 1); return row; } /** * 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, target: InstanceTarget | undefined, ): void { for (const axis of POSITION_AXES) host.fields .get(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 = target ? deviatesFromTemplate(object, target.template, def.key) : deviatesFromAsset(object, asset, def.key); host.fields.get(def.key)?.setModified(deviates); } } /** * 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; applyToAsset: boolean } { const view = viewOf(state.project, holderId); const object = view && findObject(view, objectId); const asset = object && findAsset(state.project, object.assetId); 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(target) && deviatesFromTemplate(object, target!.template, key), asset: !target && fromAsset, applyToAsset: fromAsset, }; } /** Whether the object sits somewhere else on `axis` than its container template says. */ function positionMoved( state: InspectorHost['state'], holderId: string, objectId: string, axis: Axis, ): boolean { 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 currentRef( state: InspectorHost['state'], holderId: string, objectId: string, ): ObjectRef | undefined { const holder = findObjectHolder(state.project, holderId); 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 = currentRef(state, holderId, objectId)?.object; const asset = object && findAsset(state.project, object.assetId); return object && asset ? resolveSceneObjectProps(asset, object) : null; }