Split the object inspector, and sweep out the section banners
objectSection.ts had grown to 559 lines around three separable parts: the queries, the vector rows and the parent row. Four object commands repeated the same capture/restore preamble – applyCommands stays out, its five carry extra undo state whose restore order matters. 58 dashed banner comments are gone. The nine labels in nodes/index.ts stay: they are the only structure a flat 90-entry array has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
11981b40d0
commit
3b3f374e10
11 files changed
+258
-244
No files matched your search
@@ -1,6 +1,12 @@
|
||||
import { newId } from '../ids';
|
||||
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
|
||||
import { captureObject, isLiveInstance, type ObjectMemo, restoreObject } from '../model/instance';
|
||||
import {
|
||||
captureObject,
|
||||
isLiveInstance,
|
||||
type ObjectMemo,
|
||||
type ObjectRef,
|
||||
restoreObject,
|
||||
} from '../model/instance';
|
||||
import { placementRejection } from '../model/placement';
|
||||
import { type Position, type PropertyValue, toPosition, type Vec2 } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
@@ -17,6 +23,33 @@ import {
|
||||
requireStructural,
|
||||
} from './require';
|
||||
|
||||
/**
|
||||
* An edit to one placed object. The base captures the object before the edit and puts it back on
|
||||
* undo, so a subclass only says what changes. `applyCommands` deliberately does not use this: its
|
||||
* commands carry extra undo state whose restore order matters.
|
||||
*/
|
||||
abstract class ObjectEditCommand implements Command {
|
||||
abstract readonly label: string;
|
||||
private memo: ObjectMemo | undefined;
|
||||
|
||||
protected constructor(
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
) {}
|
||||
|
||||
protected abstract apply(ref: ObjectRef, project: Project): void;
|
||||
|
||||
execute(project: Project): void {
|
||||
const ref = requireObjectRef(project, this.holderId, this.objectId);
|
||||
this.memo = captureObject(ref);
|
||||
this.apply(ref, project);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.memo) restoreObject(this.memo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The position is a world position; with a parent it is converted to the parent's local space.
|
||||
* Placing a container adds the container object alone – its content is resolved from the template
|
||||
@@ -88,91 +121,70 @@ export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand {
|
||||
* The previous position is read at execution time, so a caller that previewed the move must
|
||||
* restore the model before executing.
|
||||
*/
|
||||
export class MoveSceneObjectCommand implements Command {
|
||||
export class MoveSceneObjectCommand extends ObjectEditCommand {
|
||||
readonly label = 'Move object';
|
||||
private memo: ObjectMemo | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
private readonly position: Position,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const ref = requireObjectRef(project, this.holderId, this.objectId);
|
||||
this.memo = captureObject(ref);
|
||||
ref.setPosition(this.position);
|
||||
) {
|
||||
super(holderId, objectId);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.memo) restoreObject(this.memo);
|
||||
protected override apply(ref: ObjectRef): void {
|
||||
ref.setPosition(this.position);
|
||||
}
|
||||
}
|
||||
|
||||
/** Editor-only: undoable like any other edit, but it changes nothing the game can see. */
|
||||
export class SetEditorOffsetCommand implements Command {
|
||||
export class SetEditorOffsetCommand extends ObjectEditCommand {
|
||||
readonly label = 'Editor offset';
|
||||
private memo: ObjectMemo | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
private readonly offset: Vec2,
|
||||
) {}
|
||||
) {
|
||||
super(holderId, objectId);
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const ref = requireObjectRef(project, this.holderId, this.objectId);
|
||||
this.memo = captureObject(ref);
|
||||
protected override apply(ref: ObjectRef): void {
|
||||
ref.setEditorOffset(this.offset);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.memo) restoreObject(this.memo);
|
||||
}
|
||||
}
|
||||
|
||||
export class RenameSceneObjectCommand implements Command {
|
||||
export class RenameSceneObjectCommand extends ObjectEditCommand {
|
||||
readonly label = 'Rename object';
|
||||
private memo: ObjectMemo | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
private readonly name: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const ref = requireObjectRef(project, this.holderId, this.objectId);
|
||||
this.memo = captureObject(ref);
|
||||
ref.setName(this.name);
|
||||
) {
|
||||
super(holderId, objectId);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.memo) restoreObject(this.memo);
|
||||
protected override apply(ref: ObjectRef): void {
|
||||
ref.setName(this.name);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class OverrideCommand implements Command {
|
||||
private memo: ObjectMemo | undefined;
|
||||
|
||||
abstract class OverrideCommand extends ObjectEditCommand {
|
||||
protected constructor(
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
protected readonly key: string,
|
||||
readonly label: string,
|
||||
) {}
|
||||
) {
|
||||
super(holderId, objectId);
|
||||
}
|
||||
|
||||
protected abstract nextValue(object: SceneObject, project: Project): PropertyValue | undefined;
|
||||
|
||||
execute(project: Project): void {
|
||||
const ref = requireObjectRef(project, this.holderId, this.objectId);
|
||||
this.memo = captureObject(ref);
|
||||
protected override apply(ref: ObjectRef, project: Project): void {
|
||||
ref.setOverride(this.key, this.nextValue(ref.object, project));
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.memo) restoreObject(this.memo);
|
||||
}
|
||||
}
|
||||
|
||||
export class SetOverrideCommand extends OverrideCommand {
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { PropertyDef } from '../../properties/PropertySchema';
|
||||
import type { NodeDef, PortDef } from '../NodeRegistry';
|
||||
import { asBoolean, asNumber } from '../values';
|
||||
|
||||
// --- ports -----------------------------------------------------------------------------------
|
||||
|
||||
export const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' });
|
||||
export const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' });
|
||||
export const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' });
|
||||
@@ -23,8 +21,6 @@ export const collisionOutputs = (): PortDef[] => [
|
||||
obj('collider'),
|
||||
];
|
||||
|
||||
// --- params ----------------------------------------------------------------------------------
|
||||
|
||||
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
|
||||
|
||||
/** Options are empty on purpose: the graph inspector fills in the behavior's variables. */
|
||||
@@ -39,8 +35,6 @@ export const variableParam: PropertyDef = {
|
||||
/** No `assetTypes`: any placeable asset is a valid answer here. */
|
||||
export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' };
|
||||
|
||||
// --- node factories --------------------------------------------------------------------------
|
||||
|
||||
export function binaryMath(type: string, description: string, fn: (a: number, b: number) => number): NodeDef {
|
||||
return {
|
||||
type,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* What the object inspector asks about the object it is showing. Every one of these resolves from
|
||||
* the project on demand: a resolved holder is a throwaway copy, so a row that captured one would
|
||||
* answer for the project as it stood when the row was built.
|
||||
*/
|
||||
import { findObject } from '../../core/model/hierarchy';
|
||||
import {
|
||||
type InstanceTarget,
|
||||
instanceTargetOf,
|
||||
objectRef,
|
||||
type ObjectRef,
|
||||
resolvedHolder,
|
||||
} from '../../core/model/instance';
|
||||
import type { Axis, PropertyBag } from '../../core/model/Properties';
|
||||
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
|
||||
import {
|
||||
deviatesFromAsset,
|
||||
deviatesFromTemplate,
|
||||
resolveSceneObjectProps,
|
||||
} from '../../core/properties/resolve';
|
||||
import type { InspectorHost } from './host';
|
||||
|
||||
type State = InspectorHost['state'];
|
||||
|
||||
/** The holder with the content of placed containers resolved in – what the inspector addresses. */
|
||||
export function viewOf(project: Project, holderId: string): ObjectHolder | undefined {
|
||||
const holder = findObjectHolder(project, holderId);
|
||||
return holder && resolvedHolder(project, holder);
|
||||
}
|
||||
|
||||
export function targetOf(project: Project, holderId: string, objectId: string): InstanceTarget | undefined {
|
||||
const holder = findObjectHolder(project, holderId);
|
||||
return holder && instanceTargetOf(project, holder, objectId);
|
||||
}
|
||||
|
||||
export function currentRef(state: 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. */
|
||||
export function currentProps(state: 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function deviations(
|
||||
state: 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!.base, key),
|
||||
asset: !target && fromAsset,
|
||||
applyToAsset: fromAsset,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the object sits somewhere else on `axis` than its container template says. */
|
||||
export function positionMoved(state: 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!.base.position[axis];
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
RevertOverrideToTemplateCommand,
|
||||
RevertPositionToTemplateCommand,
|
||||
} from '../../core/commands/applyCommands';
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
|
||||
import {
|
||||
ClearOverrideCommand,
|
||||
@@ -13,21 +12,18 @@ import {
|
||||
RenameSceneObjectCommand,
|
||||
SetEditorOffsetCommand,
|
||||
SetOverrideCommand,
|
||||
SetParentCommand,
|
||||
} from '../../core/commands/sceneObjectCommands';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
|
||||
import { findObject } 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 { PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
|
||||
import type { SceneObject } from '../../core/model/SceneObject';
|
||||
import {
|
||||
COMMON_PROPS,
|
||||
@@ -41,28 +37,15 @@ import {
|
||||
deviatesFromTemplate,
|
||||
resolveSceneObjectProps,
|
||||
} from '../../core/properties/resolve';
|
||||
import { Button, Dropdown, Label, Menu, type MenuItem, TextField } from '../../ui';
|
||||
import { Button, TextField } from '../../ui';
|
||||
import { addCollider, createContainer, removeObjects } from '../actions';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
|
||||
import { createFieldRow, type FieldRow, labelledRow } from './fieldFactory';
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { parentRow } from './parentRow';
|
||||
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);
|
||||
}
|
||||
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(
|
||||
@@ -124,7 +107,6 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec
|
||||
);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
@@ -311,48 +293,6 @@ export function refreshObjectSection(host: InspectorHost, holderId: string, obje
|
||||
return true;
|
||||
}
|
||||
|
||||
interface ComponentSpec<V extends object> {
|
||||
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<V extends object>(
|
||||
host: InspectorHost,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
value: number,
|
||||
spec: ComponentSpec<V>,
|
||||
): 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<ObjectRef, V>(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).
|
||||
@@ -444,43 +384,6 @@ function propertyRow(
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Parent: <name> ▾" – 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -505,55 +408,3 @@ function markObjectRows(
|
||||
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!.base, 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!.base.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;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
|
||||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||||
import type { SceneObject } from '../../core/model/SceneObject';
|
||||
import { Dropdown, Label, Menu, type MenuItem } from '../../ui';
|
||||
import { labelledRow } from './fieldFactory';
|
||||
import type { InspectorHost } from './host';
|
||||
import { viewOf } from './objectQueries';
|
||||
|
||||
/**
|
||||
* "Parent: <name> ▾" – 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.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import type { ObjectRef } from '../../core/model/instance';
|
||||
import type { Axis, PropertyValue } from '../../core/model/Properties';
|
||||
import { createFieldRow, type FieldActions } from './fieldFactory';
|
||||
import type { InspectorHost } from './host';
|
||||
import { currentRef } from './objectQueries';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
|
||||
export const POSITION_AXES: readonly Axis[] = ['x', 'y', 'z'];
|
||||
export const OFFSET_AXES = ['x', 'y'] as const;
|
||||
|
||||
export const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
|
||||
|
||||
export interface ComponentSpec<V extends object> {
|
||||
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.
|
||||
*/
|
||||
export function componentRow<V extends object>(
|
||||
host: InspectorHost,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
value: number,
|
||||
spec: ComponentSpec<V>,
|
||||
): 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<ObjectRef, V>(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);
|
||||
}
|
||||
@@ -102,8 +102,6 @@ export class EditorState {
|
||||
for (const listener of this.listeners.get(event) ?? []) listener();
|
||||
}
|
||||
|
||||
// --- project lifecycle ---------------------------------------------------------------------
|
||||
|
||||
loadProject(project: Project, revision: number): void {
|
||||
this.edit = null;
|
||||
this.mode = 'edit';
|
||||
@@ -165,8 +163,6 @@ export class EditorState {
|
||||
return findScene(this.project, this.project.activeSceneId);
|
||||
}
|
||||
|
||||
// --- editing -------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Runs a command through the undo stack. Returns false (and sets a notice) when invalid.
|
||||
* In play mode the command changes the runtime copy only (see `enterPlay`).
|
||||
@@ -234,8 +230,6 @@ export class EditorState {
|
||||
this.emit('save');
|
||||
}
|
||||
|
||||
// --- selection -----------------------------------------------------------------------------
|
||||
|
||||
select(selection: Selection): void {
|
||||
// Test hooks pass the short form `{ kind: 'object', holderId, objectId }` without `objectIds`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
@@ -350,8 +344,6 @@ export class EditorState {
|
||||
return assetId ? findAsset(this.project, assetId) : undefined;
|
||||
}
|
||||
|
||||
// --- status --------------------------------------------------------------------------------
|
||||
|
||||
setSaveStatus(status: SaveStatus): void {
|
||||
if (this.saveStatus === status) return;
|
||||
this.saveStatus = status;
|
||||
|
||||
@@ -143,8 +143,6 @@ export class TextureCanvas extends Widget {
|
||||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||||
}
|
||||
|
||||
// --- model ------------------------------------------------------------------------------
|
||||
|
||||
private asset(): TextureAsset | null {
|
||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||
return asset && asset.type === 'texture' ? asset : null;
|
||||
@@ -159,8 +157,6 @@ export class TextureCanvas extends Widget {
|
||||
return this.asset()?.frames.find((f) => f.id === frameId);
|
||||
}
|
||||
|
||||
// --- pointer ----------------------------------------------------------------------------
|
||||
|
||||
private onPointerDown(e: FederatedPointerEvent): void {
|
||||
const asset = this.asset();
|
||||
// Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame
|
||||
@@ -312,8 +308,6 @@ export class TextureCanvas extends Widget {
|
||||
this.cursor = over ? 'move' : 'crosshair';
|
||||
}
|
||||
|
||||
// --- drawing ----------------------------------------------------------------------------
|
||||
|
||||
override redraw(): void {
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
|
||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||
|
||||
@@ -82,8 +82,6 @@ export class World {
|
||||
return this.elapsed;
|
||||
}
|
||||
|
||||
// --- model sync ------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Makes the entities match the scene: one entity per placed object (created on demand,
|
||||
* dropped when the object is gone), positions, names and resolved properties taken from
|
||||
@@ -215,8 +213,6 @@ export class World {
|
||||
return { structure: spawned || scene.objects.length !== before, values };
|
||||
}
|
||||
|
||||
// --- simulation ------------------------------------------------------------------------------
|
||||
|
||||
/** Registers a system; it starts immediately and is ticked in registration order. */
|
||||
addSystem(system: System): this {
|
||||
this.systems.push(system);
|
||||
@@ -260,8 +256,6 @@ export class World {
|
||||
this.entities.length = 0;
|
||||
}
|
||||
|
||||
// --- queries ---------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether a world point lies on an entity: on the colliders of its subtree when it has
|
||||
* any, otherwise on the box of a sprite or text in it. Disabled parts never answer, so a
|
||||
|
||||
@@ -76,7 +76,6 @@ export class Input {
|
||||
return this.released;
|
||||
}
|
||||
|
||||
/** Keys currently held. */
|
||||
get heldKeys(): ReadonlySet<string> {
|
||||
return this.down;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user