Files
feuerwerk/src/editor/inspector/objectSection.ts
T
2026-09-18 06:41:47 +02:00

373 lines
15 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.
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<string, PropertyValue>,
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<ObjectRef>(host, overrideSpec),
...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })),
...structFields<ObjectRef>(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);
}
}