import { ApplyOverrideToAssetCommand, ApplyOverrideToContainerCommand, ApplyPositionToContainerCommand, RevertOverrideToTemplateCommand, RevertPositionToTemplateCommand, } from '../../core/commands/applyCommands'; import { UnpackContainerCommand } from '../../core/commands/containerCommands'; import { ClearOverrideCommand, MoveSceneObjectCommand, RenameSceneObjectCommand, SetEditorOffsetCommand, SetOverrideCommand, } from '../../core/commands/sceneObjectCommands'; import type { Asset } from '../../core/model/Asset'; import { findObject } from '../../core/model/hierarchy'; import { type InstanceTarget, isLiveInstance, isTemplateContent, type ObjectRef, resolvedHolder, } from '../../core/model/instance'; import type { PropertyValue } from '../../core/model/Properties'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; import type { SceneObject } from '../../core/model/SceneObject'; import { COMMON_PROPS, isCommonProp, isTransformProp, type PropertyDef, schemaFor, schemaForObject, } from '../../core/properties/PropertySchema'; import { deviatesFromAsset, deviatesFromTemplate, resolveSceneObjectProps, } from '../../core/properties/resolve'; import { Button } from '../../ui'; import { createContainer, removeObjects } from '../actions'; import { aspectActions, sizeCommand } from './aspectAction'; import { defSignature } from './assetSection'; import { actionIf, createFieldRow, type FieldRow, labelledRow, type RowActions } from './fieldFactory'; import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host'; import type { EditorState, ListSlot } from '../state'; import { parentRow } from './parentRow'; import { listOpener, previewingHandlers, structFields } from './previewHandlers'; import { buildTilemapSection, tilemapSignature } from './tilemapSection'; import { overrideValue } from './valueSpecs'; import { buildVariable } from './variableRows'; import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries'; import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows'; /** * The rows a placed object shows for what it was placed from: a behavior's declared variables, any * other asset's schema minus the common switches. */ export function objectRows(project: Project, asset: Asset, object: SceneObject): PropertyDef[] { return asset.type === 'behavior' ? asset.variables : schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key)); } /** Structure-defining facts of an object selection: parent, template link, schema and painting. */ export function objectSignature( state: EditorState, holderId: string, objectIds: readonly string[], objectId: string, ): string { const project = state.project; 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' : ''; const rows = schemaForObject(project, asset, object); const painting = asset.type === 'tilemap' ? `|${tilemapSignature(state, asset)}` : ''; return `${object.parentId}|${kind}|` + rows.map(defSignature).join(',') + painting; } /** 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), }), ); } 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`); addNameField(host, object.name, { placeholder: asset.name, onChange: (name) => host.state.execute(new RenameSceneObjectCommand(holderId, objectId, name.trim())), }); assetLink(host, 'Asset', asset); 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) assetLink(host, 'Part of', target.asset); // The master switch, right where it is looked for: above everything type-specific. const resolved = resolveSceneObjectProps(host.state.project, asset, object); for (const def of COMMON_PROPS) propertyRow(host, holderId, objectId, asset, def, resolved); // A behavior's own switches sit with it rather than in the section below: that one lists what // the behavior *declares*, and `Run in editor` is not something it declared. if (asset.type === 'behavior') for (const def of schemaFor('behavior').filter((d) => !isCommonProp(d.key))) propertyRow(host, holderId, objectId, asset, def, resolved); 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: () => actionIf(moved(), 'Reset to container', () => host.state.execute(new RevertPositionToTemplateCommand(holderId, objectId, axis)), ), apply: () => actionIf(moved(), 'Apply to container', () => host.state.execute(new ApplyPositionToContainerCommand(holderId, objectId, axis)), ), }); } // Turn, size and mirror sit with the position: together they are where the object is. const transform = schemaForObject(project, asset, object).filter((def) => isTransformProp(def.key)); if (transform.length > 0) addTitle(host, 'TRANSFORM'); for (const def of transform) propertyRow(host, holderId, objectId, asset, def, resolved); // 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) return []; return actionIf(current.editorOffset[axis] !== 0, 'Reset to 0', () => host.state.execute( new SetEditorOffsetCommand(holderId, objectId, { ...current.editorOffset, [axis]: 0 }), ), ); }, }); } // Asset properties (or behavior variables) with overrides; the switches are shown above. const values = resolveSceneObjectProps(host.state.project, asset, object); const own = objectRows(host.state.project, asset, object); 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, asset.type === 'behavior'); markObjectRows(host, object, asset, target); if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length); if (asset.type === 'tilemap') buildTilemapSection(host, asset); } function assetLink(host: InspectorHost, label: string, asset: Asset): void { const { row } = labelledRow(label); row.add( new Button(asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }), }), 1, ); host.body.add(row); } /** * 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(project, asset, object); for (const def of schemaForObject(project, asset, object)) host.fields.get(def.key)?.setValue(values[def.key] ?? def.default); markObjectRows(host, object, asset, targetOf(project, holderId, objectId)); return true; } /** * One editable property of a placed object. Reset always goes to the nearest base (the template inside * a container), while "Apply to asset" reaches past it, dropping the template's override. */ function propertyRow( host: InspectorHost, holderId: string, objectId: string, owner: Asset, def: PropertyDef, values: Record, variable = false, ): FieldRow { const bases = () => deviations(host.state, holderId, objectId, def.key); const overrideSpec = overrideValue(host.state, holderId, objectId, def.key); const fieldSlot = (field: string): ListSlot => ({ kind: 'object', holderId, objectId, key: def.key, field, }); const rowActions: RowActions = { extra: aspectActions( host, owner, def, () => currentProps(host.state, holderId, objectId), (values) => host.state.execute( sizeCommand(values, (axis, value) => new SetOverrideCommand(holderId, objectId, axis, value)), ), ), reset: () => { const { template, asset } = bases(); return [ ...actionIf(template, 'Reset to container', () => host.state.execute(new RevertOverrideToTemplateCommand(holderId, objectId, def.key)), ), ...actionIf(asset, 'Reset to asset', () => host.state.execute(new ClearOverrideCommand(holderId, objectId, def.key)), ), ]; }, apply: () => { const { template, applyToAsset } = bases(); return [ ...actionIf(template, 'Apply to container', () => host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)), ), ...actionIf(applyToAsset, 'Apply to asset', () => host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)), ), ]; }, }; const value = values[def.key] ?? def.default; const handlers = { ...previewingHandlers(host, overrideSpec), ...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })), ...structFields(host, def, overrideSpec, fieldSlot), }; // A variable reads exactly like it does on its asset, which adds its own rows to the body. const row = variable ? buildVariable(host, def, value, handlers, { actions: rowActions }) : createFieldRow(host, def, value, handlers, rowActions); host.fields.set(def.key, row); if (!variable) host.body.add(row.row); return row; } /** * Gold 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!.base.position[axis]); for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0); const project = host.state.project; for (const def of schemaForObject(project, asset, object)) { const deviates = target ? deviatesFromTemplate(object, target.base, def.key) : deviatesFromAsset(project, object, asset, def.key); host.fields.get(def.key)?.setModified(deviates); } }