import { ApplyOverrideToAssetCommand, ApplyOverrideToContainerCommand, ApplyPositionToContainerCommand, RevertOverrideToTemplateCommand, RevertPositionToTemplateCommand, } from '../../core/commands/applyCommands'; import { UnpackContainerCommand } from '../../core/commands/containerCommands'; import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands'; import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap'; import { findUsages } from '../../core/model/usages'; 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, textOf } from '../../core/model/Properties'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; import type { SceneObject } from '../../core/model/SceneObject'; import { COMMON_PROPS, isCommonProp, isRowShown, isSortProp, isTransformProp, type PropertyDef, schemaFor, schemaForObject, } from '../../core/properties/PropertySchema'; import { deviatesFromAsset, deviatesFromTemplate, resolveSceneObjectProps, } from '../../core/properties/resolve'; import { Button, TextField, Theme } from '../../ui'; import { createContainer, removeObjects } from '../actions'; import { aspectActions, sizeCommand } from './aspectAction'; import { defSignature } from './assetSection'; import { actionIf, createFieldRow, type FieldRow, labelledRow, linkLabel, 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 { overrideValue, tilemapSizeValue } 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, the rows shown 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 values = resolveSceneObjectProps(project, asset, object); const rows = schemaForObject(project, asset, object).filter((def) => isRowShown(def, values)); // A tilemap warns when it is placed more than once, so the number of placements shapes it too. const placed = asset.type === 'tilemap' ? `|placed:${findUsages(project, asset.id).length}` : ''; return `${object.parentId}|${kind}${placed}|` + rows.map(defSignature).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), }), ); } 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_ROW, '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_ROW, '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)), ), }); } // Right under Z, which it changes the meaning of. for (const def of schemaForObject(project, asset, object).filter((d) => isSortProp(d.key))) propertyRow(host, holderId, objectId, asset, def, resolved); // 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).filter((def) => isRowShown(def, values)); 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 === 'tilemap') gridEdgesSection(host, holderId, object); if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length); } /** * Grows and shrinks the grid at any edge, one column or row per click, while the painted cells stay * where they are in the scene (#136). The cells belong to the asset, so they move in every other * placement too – which only this one's position is corrected for, hence the note. */ function gridEdgesSection(host: InspectorHost, holderId: string, object: SceneObject): void { addTitle(host, 'GRID EDGES'); for (const edge of TILEMAP_EDGES) { const { row } = labelledRow(edge[0]!.toUpperCase() + edge.slice(1)); for (const count of [-1, 1]) row.add( new Button(count < 0 ? '−' : '+', { variant: 'ghost', minWidth: Theme.controlHeight, onClick: () => host.state.execute(new ResizeTilemapEdgeCommand(holderId, object.id, edge, count)), }), ); host.body.add(row); } const others = findUsages(host.state.project, object.assetId).length - 1; if (others > 0) { addNote(host, `Placed ${others} more time${others === 1 ? '' : 's'}: at the left and top`, 'warning'); addNote(host, 'edge the cells move there as well.', 'warning'); } else addNote(host, 'The painted cells stay where they are in the scene.', 'small'); } /** Registry keys of the two asset rows; `#` keeps them clear of property keys. */ const ASSET_ROW = '#asset'; const PART_OF_ROW = '#part-of'; /** * What the object was placed from: the label is a link like on any reference row (click pings, * double-click opens), the name next to it only reads – changing it would mean another object. */ function assetLink(host: InspectorHost, key: string, text: string, asset: Asset): void { const { row, label } = labelledRow(text); linkLabel(host, label)(asset.id); const name = new TextField(asset.name, { readOnly: true }); row.add(name, 1); host.body.add(row); host.fields.set(key, { row, setValue: (v) => name.setValue(textOf(v)), setModified: () => {} }); } /** * 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); // A rename of the asset changes no structure, so it arrives here rather than as a rebuild. host.fields.get(ASSET_ROW)?.setValue(asset.name); const target = targetOf(project, holderId, objectId); if (target) host.fields.get(PART_OF_ROW)?.setValue(target.asset.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, target); 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 = owner.type === 'tilemap' && (def.key === TILEMAP_COLUMNS_KEY || def.key === TILEMAP_ROWS_KEY) ? tilemapSizeValue(host.state, holderId, objectId, def.key) : 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); } }