Add a Container tab and propagate template edits
The Container tab reuses SceneViewport against any object holder, so a container template is edited exactly like a scene. Right-click no longer changes the selection, which previously made nesting impossible: opening the menu on a container loaded it, and "Place in container" then targeted itself. Template edits (move, rename, override) now reach already-placed instances per slot, through a shared templateSync used by both the apply path and the plain object commands. Copies the user has touched keep their own value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6bedc948be
commit
9d93020bd8
35 files changed
+1452
-363
No files matched your search
@@ -14,11 +14,11 @@ import {
|
||||
SetOverrideCommand,
|
||||
SetParentCommand,
|
||||
} from '../../core/commands/sceneObjectCommands';
|
||||
import type { Asset, SceneAsset } from '../../core/model/Asset';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import { type TemplateLink, templateLinkOf } from '../../core/model/container';
|
||||
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
|
||||
import type { Axis, PropertyBag, PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset, findScene, type Project } from '../../core/model/Project';
|
||||
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
|
||||
import type { SceneObject } from '../../core/model/SceneObject';
|
||||
import {
|
||||
COMMON_PROPS,
|
||||
@@ -46,13 +46,13 @@ const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
|
||||
/** Structure-defining facts of an object selection: parent, template link and schema. */
|
||||
export function objectSignature(
|
||||
project: Project,
|
||||
sceneId: string,
|
||||
holderId: string,
|
||||
objectIds: readonly string[],
|
||||
objectId: string,
|
||||
): string {
|
||||
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
|
||||
const scene = findScene(project, sceneId);
|
||||
const object = scene && findObject(scene, objectId);
|
||||
const holder = findObjectHolder(project, holderId);
|
||||
const object = holder && findObject(holder, objectId);
|
||||
const asset = object && findAsset(project, object.assetId);
|
||||
if (!object || !asset) return 'missing';
|
||||
return (
|
||||
@@ -64,44 +64,44 @@ export function objectSignature(
|
||||
}
|
||||
|
||||
/** Several objects: what they are, and the actions that apply to all of them at once. */
|
||||
export function buildObjectsSection(host: InspectorHost, sceneId: string, objectIds: string[]): void {
|
||||
const scene = findScene(host.state.project, sceneId);
|
||||
if (!scene) return;
|
||||
export function buildObjectsSection(host: InspectorHost, holderId: string, objectIds: string[]): void {
|
||||
const holder = findObjectHolder(host.state.project, holderId);
|
||||
if (!holder) return;
|
||||
addTitle(host, `${objectIds.length} OBJECTS SELECTED`);
|
||||
for (const id of objectIds) {
|
||||
const object = findObject(scene, id);
|
||||
const object = findObject(holder, 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 scene moves all of them.', 'small');
|
||||
addNote(host, 'Dragging one in the viewport moves all of them.', 'small');
|
||||
addGap(host);
|
||||
host.body.add(
|
||||
new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }),
|
||||
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 from scene', {
|
||||
new Button('Remove', {
|
||||
variant: 'danger',
|
||||
onClick: () => removeObjects(host.state, sceneId, objectIds),
|
||||
onClick: () => removeObjects(host.state, holderId, objectIds),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */
|
||||
export function buildObjectSection(host: InspectorHost, sceneId: string, objectId: string): void {
|
||||
const scene = findScene(host.state.project, sceneId);
|
||||
const object = scene && findObject(scene, objectId);
|
||||
export function buildObjectSection(host: InspectorHost, holderId: string, objectId: string): void {
|
||||
const holder = findObjectHolder(host.state.project, holderId);
|
||||
const object = holder && findObject(holder, objectId);
|
||||
const asset = object && findAsset(host.state.project, object.assetId);
|
||||
if (!scene || !object || !asset) return;
|
||||
if (!holder || !object || !asset) return;
|
||||
|
||||
addTitle(host, `${asset.type.toUpperCase()} OBJECT`);
|
||||
host.nameField = new TextField(object.name, {
|
||||
placeholder: asset.name,
|
||||
onChange: (name) => host.state.execute(new RenameSceneObjectCommand(sceneId, objectId, name.trim())),
|
||||
onChange: (name) => host.state.execute(new RenameSceneObjectCommand(holderId, objectId, name.trim())),
|
||||
});
|
||||
const nameRow = labelledRow('Name');
|
||||
nameRow.row.add(host.nameField, 1);
|
||||
@@ -117,10 +117,10 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
);
|
||||
host.body.add(assetRow.row);
|
||||
|
||||
host.body.add(parentRow(host, scene, object));
|
||||
host.body.add(parentRow(host, holder, object));
|
||||
|
||||
// Part of a placed container: values compare against the template, not just the asset.
|
||||
const link = templateLinkOf(host.state.project, scene, object);
|
||||
const link = templateLinkOf(host.state.project, holder, object);
|
||||
if (link) {
|
||||
const linkRow = labelledRow('Part of');
|
||||
linkRow.row.add(
|
||||
@@ -135,25 +135,25 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
|
||||
// The master switch, right where it is looked for: above everything type-specific.
|
||||
for (const def of COMMON_PROPS)
|
||||
propertyRow(host, sceneId, objectId, asset, def, resolveSceneObjectProps(asset, object));
|
||||
propertyRow(host, holderId, objectId, asset, def, resolveSceneObjectProps(asset, object));
|
||||
|
||||
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
|
||||
for (const axis of POSITION_AXES) {
|
||||
const moved = () => positionMoved(host.state, sceneId, objectId, axis);
|
||||
componentRow(host, sceneId, objectId, object.position[axis], {
|
||||
const moved = () => positionMoved(host.state, holderId, objectId, axis);
|
||||
componentRow(host, holderId, objectId, object.position[axis], {
|
||||
key: axis,
|
||||
label: axis.toUpperCase(),
|
||||
axis,
|
||||
read: (o) => o.position,
|
||||
write: (o, v) => (o.position = v),
|
||||
command: (v) => new MoveSceneObjectCommand(sceneId, objectId, v),
|
||||
command: (v) => new MoveSceneObjectCommand(holderId, objectId, v),
|
||||
reset: () =>
|
||||
moved()
|
||||
? [
|
||||
{
|
||||
label: 'Reset to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)),
|
||||
host.state.execute(new RevertPositionToTemplateCommand(holderId, objectId, axis)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -163,33 +163,33 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
{
|
||||
label: 'Apply to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)),
|
||||
host.state.execute(new ApplyPositionToContainerCommand(holderId, objectId, axis)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Where the object is *drawn* in the scene view, nothing else – for pulling stacked objects apart.
|
||||
// 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, sceneId, objectId, object.editorOffset[axis], {
|
||||
componentRow(host, holderId, objectId, object.editorOffset[axis], {
|
||||
key: offsetKey(axis),
|
||||
label: `Offset ${axis.toUpperCase()}`,
|
||||
axis,
|
||||
read: (o) => o.editorOffset,
|
||||
write: (o, v) => (o.editorOffset = v),
|
||||
command: (v) => new SetEditorOffsetCommand(sceneId, objectId, v),
|
||||
command: (v) => new SetEditorOffsetCommand(holderId, objectId, v),
|
||||
// No apply: the offset never leaves the placed object.
|
||||
reset: () => {
|
||||
const current = currentObject(host.state, sceneId, objectId);
|
||||
const current = currentObject(host.state, holderId, objectId);
|
||||
if (!current || current.editorOffset[axis] === 0) return [];
|
||||
return [
|
||||
{
|
||||
label: 'Reset to 0',
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 }),
|
||||
new SetEditorOffsetCommand(holderId, objectId, { ...current.editorOffset, [axis]: 0 }),
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -204,7 +204,7 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
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, sceneId, objectId, asset, def, values);
|
||||
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values);
|
||||
markObjectRows(host, object, asset, link);
|
||||
|
||||
if (asset.type === 'container') {
|
||||
@@ -219,24 +219,24 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
host.body.add(
|
||||
new Button('+ Add collider', {
|
||||
variant: 'ghost',
|
||||
onClick: () => addCollider(host.state, sceneId, objectId),
|
||||
onClick: () => addCollider(host.state, holderId, objectId),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pushes the current model values into the rows. False when the object is gone (rebuild). */
|
||||
export function refreshObjectSection(host: InspectorHost, sceneId: string, objectId: string): boolean {
|
||||
const scene = findScene(host.state.project, sceneId);
|
||||
const object = scene && findObject(scene, objectId);
|
||||
export function refreshObjectSection(host: InspectorHost, holderId: string, objectId: string): boolean {
|
||||
const holder = findObjectHolder(host.state.project, holderId);
|
||||
const object = holder && findObject(holder, objectId);
|
||||
const asset = object && findAsset(host.state.project, object.assetId);
|
||||
if (!scene || !object || !asset) return false;
|
||||
if (!holder || !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, templateLinkOf(host.state.project, scene, object));
|
||||
markObjectRows(host, object, asset, templateLinkOf(host.state.project, holder, object));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ interface ComponentSpec<V extends object> {
|
||||
*/
|
||||
function componentRow<V extends object>(
|
||||
host: InspectorHost,
|
||||
sceneId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
value: number,
|
||||
spec: ComponentSpec<V>,
|
||||
@@ -269,7 +269,7 @@ function componentRow<V extends object>(
|
||||
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
|
||||
value,
|
||||
previewingHandlers<SceneObject, V>(host, {
|
||||
target: () => currentObject(host.state, sceneId, objectId),
|
||||
target: () => currentObject(host.state, holderId, objectId),
|
||||
// Copied: the snapshot outlives the scrub, the vector in the model does not.
|
||||
read: (object) => ({ ...spec.read(object) }),
|
||||
write: spec.write,
|
||||
@@ -283,7 +283,7 @@ function componentRow<V extends object>(
|
||||
}
|
||||
|
||||
/**
|
||||
* One editable property of a scene object: writes an override, offers ↺ (back to the asset
|
||||
* 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 was instantiated from and, one
|
||||
@@ -293,31 +293,31 @@ function componentRow<V extends object>(
|
||||
*/
|
||||
function propertyRow(
|
||||
host: InspectorHost,
|
||||
sceneId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
owner: Asset,
|
||||
def: PropertyDef,
|
||||
values: Record<string, PropertyValue>,
|
||||
): FieldRow {
|
||||
const bases = () => deviations(host.state, sceneId, objectId, def.key);
|
||||
const bases = () => deviations(host.state, holderId, objectId, def.key);
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
def,
|
||||
values[def.key] ?? def.default,
|
||||
previewingHandlers<SceneObject>(host, {
|
||||
target: () => currentObject(host.state, sceneId, objectId),
|
||||
target: () => currentObject(host.state, holderId, objectId),
|
||||
read: (object) => object.overrides[def.key],
|
||||
write: (object, value) => (object.overrides[def.key] = value),
|
||||
remove: (object) => delete object.overrides[def.key],
|
||||
commit: (value) => new SetOverrideCommand(sceneId, objectId, def.key, value),
|
||||
commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
|
||||
}),
|
||||
{
|
||||
extra: aspectActions(
|
||||
host,
|
||||
owner,
|
||||
def,
|
||||
() => currentProps(host.state, sceneId, objectId),
|
||||
(value) => host.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value)),
|
||||
() => currentProps(host.state, holderId, objectId),
|
||||
(value) => host.state.execute(new SetOverrideCommand(holderId, objectId, def.key, value)),
|
||||
),
|
||||
reset: () => {
|
||||
const { template, asset } = bases();
|
||||
@@ -327,7 +327,7 @@ function propertyRow(
|
||||
{
|
||||
label: 'Reset to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)),
|
||||
host.state.execute(new RevertOverrideToTemplateCommand(holderId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -335,7 +335,7 @@ function propertyRow(
|
||||
? [
|
||||
{
|
||||
label: 'Reset to asset',
|
||||
onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
|
||||
onSelect: () => host.state.execute(new ClearOverrideCommand(holderId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -349,7 +349,7 @@ function propertyRow(
|
||||
{
|
||||
label: 'Apply to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)),
|
||||
host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -358,7 +358,7 @@ function propertyRow(
|
||||
{
|
||||
label: 'Apply to asset',
|
||||
onSelect: () =>
|
||||
host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)),
|
||||
host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -372,27 +372,27 @@ function propertyRow(
|
||||
}
|
||||
|
||||
/**
|
||||
* "Parent: <name> ▾" – picks another object of the same scene, or none. The object's own
|
||||
* "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.
|
||||
*/
|
||||
function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject) {
|
||||
function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject) {
|
||||
const { row } = labelledRow('Parent');
|
||||
const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?';
|
||||
const parent = findObject(scene, object.parentId);
|
||||
const parent = findObject(holder, object.parentId);
|
||||
const button = new Dropdown(parent ? nameOf(parent) : 'None');
|
||||
button.onClick = () => {
|
||||
const items: MenuItem[] = [
|
||||
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) },
|
||||
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, '')) },
|
||||
];
|
||||
for (const { object: candidate, depth } of treeOrder(scene)) {
|
||||
if (isSelfOrAncestor(scene, object.id, candidate.id)) continue;
|
||||
for (const { object: candidate, depth } of treeOrder(holder)) {
|
||||
if (isSelfOrAncestor(holder, object.id, candidate.id)) continue;
|
||||
const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
|
||||
items.push({
|
||||
label: candidate.id === object.parentId ? `● ${name}` : name,
|
||||
onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)),
|
||||
onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, candidate.id)),
|
||||
});
|
||||
}
|
||||
// Searchable: this lists every object of the scene, which grows with the project.
|
||||
// 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);
|
||||
@@ -431,15 +431,15 @@ function markObjectRows(
|
||||
*/
|
||||
function deviations(
|
||||
state: InspectorHost['state'],
|
||||
sceneId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
key: string,
|
||||
): { template: boolean; asset: boolean } {
|
||||
const scene = findScene(state.project, sceneId);
|
||||
const object = scene && findObject(scene, objectId);
|
||||
const holder = findObjectHolder(state.project, holderId);
|
||||
const object = holder && findObject(holder, objectId);
|
||||
const asset = object && findAsset(state.project, object.assetId);
|
||||
if (!scene || !object || !asset) return { template: false, asset: false };
|
||||
const link = templateLinkOf(state.project, scene, object);
|
||||
if (!holder || !object || !asset) return { template: false, asset: false };
|
||||
const link = templateLinkOf(state.project, holder, object);
|
||||
return {
|
||||
template: Boolean(link) && deviatesFromTemplate(object, link!.template, key),
|
||||
asset: deviatesFromAsset(object, asset, key),
|
||||
@@ -449,28 +449,28 @@ function deviations(
|
||||
/** Whether the object sits somewhere else on `axis` than its container template says. */
|
||||
function positionMoved(
|
||||
state: InspectorHost['state'],
|
||||
sceneId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
axis: Axis,
|
||||
): boolean {
|
||||
const scene = findScene(state.project, sceneId);
|
||||
const object = scene && findObject(scene, objectId);
|
||||
const link = scene && object && templateLinkOf(state.project, scene, object);
|
||||
const holder = findObjectHolder(state.project, holderId);
|
||||
const object = holder && findObject(holder, objectId);
|
||||
const link = holder && object && templateLinkOf(state.project, holder, object);
|
||||
return Boolean(link) && object!.position[axis] !== link!.template.position[axis];
|
||||
}
|
||||
|
||||
function currentObject(
|
||||
state: InspectorHost['state'],
|
||||
sceneId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
): SceneObject | undefined {
|
||||
const scene = findScene(state.project, sceneId);
|
||||
return scene && findObject(scene, objectId);
|
||||
const holder = findObjectHolder(state.project, holderId);
|
||||
return holder && findObject(holder, objectId);
|
||||
}
|
||||
|
||||
/** The values the object shows right now, asset defaults and overrides merged. */
|
||||
function currentProps(state: InspectorHost['state'], sceneId: string, objectId: string): PropertyBag | null {
|
||||
const object = currentObject(state, sceneId, objectId);
|
||||
function currentProps(state: InspectorHost['state'], holderId: string, objectId: string): PropertyBag | null {
|
||||
const object = currentObject(state, holderId, objectId);
|
||||
const asset = object && findAsset(state.project, object.assetId);
|
||||
return object && asset ? resolveSceneObjectProps(asset, object) : null;
|
||||
}
|
||||
Reference in new issue
Block a user