An unconnected list input is a button into the List tab instead of a number field; a struct field can hold a list, so ListSlot carries an optional field name and the three owner cases only say what sits at a key; and a list variable picks its item kind in the inspector (marker doubles as the chooser), so a list of structs no longer has to be born from a project file. The declaration keeps the storable shapes finite: a struct field may be a list, but that list holds primitives only, so list->struct->primitive and struct->list->primitive are the two deepest forms. check 674, e2e 483, jump-run 24, Tetris 19. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
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,
|
||
type PropertyDef,
|
||
schemaFor,
|
||
schemaForAsset,
|
||
} from '../../core/properties/PropertySchema';
|
||
import {
|
||
deviatesFromAsset,
|
||
deviatesFromTemplate,
|
||
resolveSceneObjectProps,
|
||
} from '../../core/properties/resolve';
|
||
import { Button, TextField } from '../../ui';
|
||
import { addCollider, createContainer, removeObjects } from '../actions';
|
||
import { aspectActions, sizeCommand } from './aspectAction';
|
||
import { defSignature } from './assetSection';
|
||
import { createFieldRow, type FieldRow, labelledRow } from './fieldFactory';
|
||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||
import { parentRow } from './parentRow';
|
||
import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers';
|
||
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
|
||
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
|
||
|
||
/** 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(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`);
|
||
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.
|
||
const resolved = resolveSceneObjectProps(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: () =>
|
||
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 switches are shown above.
|
||
const values = resolveSceneObjectProps(asset, object);
|
||
const own =
|
||
asset.type === 'behavior'
|
||
? asset.variables
|
||
: schemaForAsset(asset).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;
|
||
}
|
||
|
||
/**
|
||
* 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<string, PropertyValue>,
|
||
): FieldRow {
|
||
const bases = () => deviations(host.state, holderId, objectId, def.key);
|
||
const overrideSpec: PreviewedValue<ObjectRef> = {
|
||
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),
|
||
};
|
||
const row = createFieldRow(
|
||
host,
|
||
def,
|
||
values[def.key] ?? def.default,
|
||
{
|
||
...previewingHandlers<ObjectRef>(host, overrideSpec),
|
||
...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })),
|
||
...structFields<ObjectRef>(host, def, overrideSpec, (field) => ({
|
||
kind: 'object',
|
||
holderId,
|
||
objectId,
|
||
key: def.key,
|
||
field,
|
||
})),
|
||
},
|
||
{
|
||
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 [
|
||
...(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;
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
for (const def of schemaForAsset(asset)) {
|
||
const deviates = target
|
||
? deviatesFromTemplate(object, target.base, def.key)
|
||
: deviatesFromAsset(object, asset, def.key);
|
||
host.fields.get(def.key)?.setModified(deviates);
|
||
}
|
||
}
|