Apply/revert buttons for overrides, container instances and child positions
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b78382bcc
commit
3379232e64
7 files changed
+544
-37
No files matched your search
@@ -0,0 +1,196 @@
|
||||
import { instancesOf, type TemplateLink, templateLinkOf } from '../model/container';
|
||||
import type { Position, PropertyValue } from '../model/Properties';
|
||||
import { findScene, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||
import { schemaForAsset } from '../properties/PropertySchema';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset } from './assetCommands';
|
||||
import { requireObject } from './sceneObjectCommands';
|
||||
|
||||
/**
|
||||
* "Apply" is the opposite direction of an override: a value the user changed on a placed
|
||||
* object is written back to where it came from – the asset, or (for objects that are part
|
||||
* of a placed container) the container's template. The rule for instances follows the usual
|
||||
* prefab semantics: copies that were still at the template's value follow the change,
|
||||
* copies the user modified on their own keep their value.
|
||||
*/
|
||||
|
||||
type Axis = 'x' | 'y' | 'z';
|
||||
|
||||
function requireLink(project: Project, sceneId: string, object: SceneObject): TemplateLink {
|
||||
const scene = findScene(project, sceneId);
|
||||
const link = scene && templateLinkOf(project, scene, object);
|
||||
if (!link) throw new CommandError(`${object.name || object.id} is not part of a placed container`);
|
||||
return link;
|
||||
}
|
||||
|
||||
function setOverride(object: SceneObject, key: string, value: PropertyValue | undefined): void {
|
||||
if (value === undefined) delete object.overrides[key];
|
||||
else object.overrides[key] = value;
|
||||
}
|
||||
|
||||
/** Remembered override values of several objects, for undo. */
|
||||
interface OverrideMemo {
|
||||
object: SceneObject;
|
||||
previous: PropertyValue | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the template's override for `key` and moves every instance that still had the old
|
||||
* template value along with it. Returns what to restore on undo.
|
||||
*/
|
||||
function updateTemplateOverride(project: Project, link: TemplateLink, key: string, next: PropertyValue | undefined): OverrideMemo[] {
|
||||
const previous = link.template.overrides[key];
|
||||
const memos: OverrideMemo[] = [{ object: link.template, previous }];
|
||||
for (const { object } of instancesOf(project, link.template.id)) {
|
||||
if (object.overrides[key] !== previous) continue;
|
||||
memos.push({ object, previous: object.overrides[key] });
|
||||
setOverride(object, key, next);
|
||||
}
|
||||
setOverride(link.template, key, next);
|
||||
return memos;
|
||||
}
|
||||
|
||||
function restoreOverrides(memos: OverrideMemo[], key: string): void {
|
||||
for (const memo of [...memos].reverse()) setOverride(memo.object, key, memo.previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the object's value for `key` into its asset and drops the override – the object
|
||||
* follows the asset again, as does everything else without an override of its own. For a
|
||||
* container instance the template's override is dropped too (and instances that shared it).
|
||||
*/
|
||||
export class ApplyOverrideToAssetCommand implements Command {
|
||||
readonly label: string;
|
||||
private previousAssetValue: PropertyValue | undefined;
|
||||
private previousOverride: PropertyValue | undefined;
|
||||
private templateMemos: OverrideMemo[] = [];
|
||||
|
||||
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
|
||||
this.label = `Apply ${key} to asset`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const scene = findScene(project, this.sceneId);
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const asset = requireAsset(project, object.assetId);
|
||||
if (!schemaForAsset(asset).some((d) => d.key === this.key)) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
|
||||
const value = resolveSceneObjectProps(asset, object)[this.key];
|
||||
this.previousAssetValue = asset.props[this.key];
|
||||
this.previousOverride = object.overrides[this.key];
|
||||
asset.props[this.key] = value!;
|
||||
delete object.overrides[this.key];
|
||||
const link = scene && templateLinkOf(project, scene, object);
|
||||
this.templateMemos = link ? updateTemplateOverride(project, link, this.key, undefined) : [];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const asset = requireAsset(project, object.assetId);
|
||||
restoreOverrides(this.templateMemos, this.key);
|
||||
setOverride(object, this.key, this.previousOverride);
|
||||
if (this.previousAssetValue === undefined) delete asset.props[this.key];
|
||||
else asset.props[this.key] = this.previousAssetValue;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the object's override for `key` into its container template (instances at the old value follow). */
|
||||
export class ApplyOverrideToContainerCommand implements Command {
|
||||
readonly label: string;
|
||||
private memos: OverrideMemo[] = [];
|
||||
|
||||
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
|
||||
this.label = `Apply ${key} to container`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
this.memos = updateTemplateOverride(project, link, this.key, object.overrides[this.key]);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
restoreOverrides(this.memos, this.key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts the object's override for `key` back to what its container template says. */
|
||||
export class RevertOverrideToTemplateCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: PropertyValue | undefined;
|
||||
|
||||
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
|
||||
this.label = `Revert ${key}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
this.previous = object.overrides[this.key];
|
||||
setOverride(object, this.key, link.template.overrides[this.key]);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
setOverride(requireObject(project, this.sceneId, this.objectId), this.key, this.previous);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remembered positions of several objects, for undo. */
|
||||
interface PositionMemo {
|
||||
object: SceneObject;
|
||||
previous: Position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the object's local position (one axis, or all) into its container template.
|
||||
* Instances whose axis still matched the template follow; the others keep their own value.
|
||||
*/
|
||||
export class ApplyPositionToContainerCommand implements Command {
|
||||
readonly label: string;
|
||||
private memos: PositionMemo[] = [];
|
||||
|
||||
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) {
|
||||
this.label = axis ? `Apply ${axis} to container` : 'Apply position to container';
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
const axes: Axis[] = this.axis ? [this.axis] : ['x', 'y', 'z'];
|
||||
const before = { ...link.template.position };
|
||||
this.memos = [{ object: link.template, previous: before }];
|
||||
for (const instance of instancesOf(project, link.template.id)) {
|
||||
const changed = axes.filter((a) => instance.object.position[a] === before[a]);
|
||||
if (changed.length === 0) continue;
|
||||
this.memos.push({ object: instance.object, previous: { ...instance.object.position } });
|
||||
for (const a of changed) instance.object.position[a] = object.position[a];
|
||||
}
|
||||
for (const a of axes) link.template.position[a] = object.position[a];
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
for (const memo of [...this.memos].reverse()) memo.object.position = { ...memo.previous };
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts the object's local position (one axis, or all) back to its container template. */
|
||||
export class RevertPositionToTemplateCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: Position = { x: 0, y: 0, z: 0 };
|
||||
|
||||
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) {
|
||||
this.label = axis ? `Revert ${axis}` : 'Revert position';
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
this.previous = { ...object.position };
|
||||
for (const a of this.axis ? [this.axis] : (['x', 'y', 'z'] as Axis[])) object.position[a] = link.template.position[a];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireObject(project, this.sceneId, this.objectId).position = { ...this.previous };
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,47 @@
|
||||
import { newId } from '../ids';
|
||||
import type { ContainerAsset } from './Asset';
|
||||
import type { ContainerAsset, SceneAsset } from './Asset';
|
||||
import { findObject, type ObjectTree } from './hierarchy';
|
||||
import { findAsset, type Project } from './Project';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
|
||||
/** What a placed container's content object is linked to: the container and its template object. */
|
||||
export interface TemplateLink {
|
||||
containerObject: SceneObject;
|
||||
asset: ContainerAsset;
|
||||
template: SceneObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* For an object that belongs to a placed container: the nearest container ancestor whose
|
||||
* asset holds the template this object was instantiated from. Undefined for everything else
|
||||
* (no template id, or the link is stale).
|
||||
*/
|
||||
export function templateLinkOf(project: Project, tree: ObjectTree, object: SceneObject): TemplateLink | undefined {
|
||||
if (!object.templateId) return undefined;
|
||||
const seen = new Set<string>([object.id]);
|
||||
let current = object.parentId ? findObject(tree, object.parentId) : undefined;
|
||||
while (current && !seen.has(current.id)) {
|
||||
const asset = findAsset(project, current.assetId);
|
||||
if (asset?.type === 'container') {
|
||||
const template = asset.objects.find((t) => t.id === object.templateId);
|
||||
if (template) return { containerObject: current, asset, template };
|
||||
}
|
||||
seen.add(current.id);
|
||||
current = current.parentId ? findObject(tree, current.parentId) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Every placed copy of a template object, across all scenes (template ids are unique per project). */
|
||||
export function instancesOf(project: Project, templateId: string): { scene: SceneAsset; object: SceneObject }[] {
|
||||
const result: { scene: SceneAsset; object: SceneObject }[] = [];
|
||||
for (const asset of project.assets) {
|
||||
if (asset.type !== 'scene') continue;
|
||||
for (const object of asset.objects) if (object.templateId === templateId) result.push({ scene: asset, object });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Nesting depth at which a container inside a container inside … stops being expanded. */
|
||||
const MAX_NESTING = 8;
|
||||
|
||||
|
||||
@@ -22,12 +22,21 @@ export interface FieldRow {
|
||||
|
||||
export const FIELD_LABEL_WIDTH = 64;
|
||||
|
||||
/** One way of writing a changed value back to its source ("Apply to asset", "Apply to container"). */
|
||||
export interface ApplyAction {
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one PropertySchema entry to a labelled row containing the matching widget.
|
||||
* The inspector is entirely generated through this function: adding a new property
|
||||
* kind means adding a case here, adding a property means adding a schema line.
|
||||
*
|
||||
* A modified row shows its actions at the right: ↺ resets to the base value, ↑ applies the
|
||||
* value to its source – directly with one action, via a menu (↑ ▾) with several.
|
||||
*/
|
||||
export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, onReset?: () => void): FieldRow {
|
||||
export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, onReset?: () => void, apply?: ApplyAction[]): FieldRow {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
const label = new Label(def.label, { style: 'muted' });
|
||||
@@ -38,11 +47,21 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
|
||||
row.add(field.widget, field.grow ? 1 : 0);
|
||||
if (!field.grow) row.add(new Label(''), 1);
|
||||
|
||||
let reset: Button | null = null;
|
||||
if (onReset) {
|
||||
reset = new Button('↺', { variant: 'ghost', width: Theme.controlHeight, onClick: onReset });
|
||||
reset.visible = false;
|
||||
row.add(reset);
|
||||
const actions: Button[] = [];
|
||||
if (apply && apply.length > 0) {
|
||||
const single = apply.length === 1;
|
||||
const button = new Button(single ? '↑' : '↑ ▾', { variant: 'ghost', width: single ? Theme.controlHeight : undefined });
|
||||
button.onClick = () => {
|
||||
if (single) return apply[0]!.onSelect();
|
||||
const g = button.getGlobalPosition();
|
||||
new Menu(apply.map((a) => ({ label: a.label, onSelect: a.onSelect }))).open(g.x, g.y + button.h + 2);
|
||||
};
|
||||
actions.push(button);
|
||||
}
|
||||
if (onReset) actions.push(new Button('↺', { variant: 'ghost', width: Theme.controlHeight, onClick: onReset }));
|
||||
for (const button of actions) {
|
||||
button.visible = false;
|
||||
row.add(button);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -50,8 +69,8 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
|
||||
setValue: field.setValue,
|
||||
setModified(modified) {
|
||||
label.setStyle(modified ? 'bodyBold' : 'muted');
|
||||
if (reset && reset.visible !== modified) {
|
||||
reset.visible = modified;
|
||||
if (actions.length > 0 && actions[0]!.visible !== modified) {
|
||||
for (const button of actions) button.visible = modified;
|
||||
row.relayout();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { Container } from 'pixi.js';
|
||||
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import {
|
||||
ApplyOverrideToAssetCommand,
|
||||
ApplyOverrideToContainerCommand,
|
||||
ApplyPositionToContainerCommand,
|
||||
RevertOverrideToTemplateCommand,
|
||||
RevertPositionToTemplateCommand,
|
||||
} from '../../core/commands/applyCommands';
|
||||
import { AddColliderCommand } from '../../core/commands/colliderCommands';
|
||||
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
import {
|
||||
@@ -13,6 +20,7 @@ import { AddVariableCommand, nextVariableName, RemoveVariableCommand, RenameVari
|
||||
import { edgesInto, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph';
|
||||
import type { Asset, BehaviorAsset, SceneAsset } from '../../core/model/Asset';
|
||||
import { isPlaceableType } from '../../core/model/AssetTypes';
|
||||
import { type TemplateLink, templateLinkOf } from '../../core/model/container';
|
||||
import { isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
|
||||
import type { Position, PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
|
||||
@@ -101,7 +109,7 @@ export class Inspector extends Panel {
|
||||
if (s.objectIds.length > 1) return `multi|${s.objectIds.join(',')}`;
|
||||
const object = this.currentObject(s.sceneId, s.objectId);
|
||||
const asset = object && findAsset(this.state.project, object.assetId);
|
||||
return asset ? `${object.parentId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
|
||||
return asset ? `${object.parentId}|${object.templateId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
|
||||
}
|
||||
const asset = findAsset(this.state.project, s.assetId);
|
||||
if (!asset || asset.type !== 'behavior') return 'missing';
|
||||
@@ -250,29 +258,46 @@ export class Inspector extends Panel {
|
||||
|
||||
this.body.add(this.parentRow(scene, object, sceneId));
|
||||
|
||||
// Part of a placed container: values compare against the template, not just the asset.
|
||||
const link = templateLinkOf(this.state.project, scene, object);
|
||||
if (link) {
|
||||
const linkRow = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
const linkLabel = new Label('Part of', { style: 'muted' });
|
||||
linkLabel.minWidth = FIELD_LABEL_WIDTH;
|
||||
linkRow.add(linkLabel);
|
||||
linkRow.add(new Button(link.asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: link.asset.id }) }), 1);
|
||||
this.body.add(linkRow);
|
||||
}
|
||||
|
||||
// Position – belongs to the scene object only, local to the parent. Z is the draw order (higher = on top).
|
||||
const parent = object.parentId ? findSceneObject(scene, object.parentId) : undefined;
|
||||
this.body.add(new Label(parent ? 'POSITION (LOCAL TO PARENT)' : 'POSITION', { style: 'title' }));
|
||||
for (const axis of ['x', 'y', 'z'] as const) {
|
||||
let original: Position | undefined;
|
||||
const row = createFieldRow({ key: axis, label: axis.toUpperCase(), kind: 'number', default: 0, step: 1 }, object.position[axis], {
|
||||
onInput: (value) => {
|
||||
const target = this.currentObject(sceneId, objectId);
|
||||
if (!target) return;
|
||||
if (!original) original = { ...target.position };
|
||||
target.position[axis] = Number(value);
|
||||
this.state.preview();
|
||||
const row = createFieldRow(
|
||||
{ key: axis, label: axis.toUpperCase(), kind: 'number', default: 0, step: 1 },
|
||||
object.position[axis],
|
||||
{
|
||||
onInput: (value) => {
|
||||
const target = this.currentObject(sceneId, objectId);
|
||||
if (!target) return;
|
||||
if (!original) original = { ...target.position };
|
||||
target.position[axis] = Number(value);
|
||||
this.state.preview();
|
||||
},
|
||||
onChange: (value) => {
|
||||
const target = this.currentObject(sceneId, objectId);
|
||||
if (!target) return;
|
||||
const previous = original ?? { ...target.position };
|
||||
if (original) target.position = { ...original };
|
||||
original = undefined;
|
||||
const next = { ...previous, [axis]: Number(value) };
|
||||
this.state.execute(new MoveSceneObjectCommand(sceneId, objectId, next, previous));
|
||||
},
|
||||
},
|
||||
onChange: (value) => {
|
||||
const target = this.currentObject(sceneId, objectId);
|
||||
if (!target) return;
|
||||
const previous = original ?? { ...target.position };
|
||||
if (original) target.position = { ...original };
|
||||
original = undefined;
|
||||
const next = { ...previous, [axis]: Number(value) };
|
||||
this.state.execute(new MoveSceneObjectCommand(sceneId, objectId, next, previous));
|
||||
},
|
||||
});
|
||||
link ? () => this.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)) : undefined,
|
||||
link ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)) }] : undefined,
|
||||
);
|
||||
this.fields.set(axis, row);
|
||||
this.body.add(row.row);
|
||||
}
|
||||
@@ -310,12 +335,16 @@ export class Inspector extends Panel {
|
||||
this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value));
|
||||
},
|
||||
},
|
||||
() => this.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
|
||||
() => this.state.execute(link ? new RevertOverrideToTemplateCommand(sceneId, objectId, def.key) : new ClearOverrideCommand(sceneId, objectId, def.key)),
|
||||
[
|
||||
...(link ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
|
||||
{ label: 'Apply to asset', onSelect: () => this.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) },
|
||||
],
|
||||
);
|
||||
row.setModified(isOverridden(object, def.key));
|
||||
this.fields.set(def.key, row);
|
||||
this.body.add(row.row);
|
||||
}
|
||||
this.markObjectRows(object, asset, link);
|
||||
|
||||
if (asset.type === 'container') {
|
||||
const count = asset.objects.length;
|
||||
@@ -474,18 +503,28 @@ export class Inspector extends Panel {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const scene = findScene(this.state.project, s.sceneId);
|
||||
const object = this.currentObject(s.sceneId, s.objectId);
|
||||
const asset = object && findAsset(this.state.project, object.assetId);
|
||||
if (!object || !asset) return this.rebuild();
|
||||
if (!scene || !object || !asset) return this.rebuild();
|
||||
this.nameField?.setValue(object.name);
|
||||
this.fields.get('x')?.setValue(object.position.x);
|
||||
this.fields.get('y')?.setValue(object.position.y);
|
||||
this.fields.get('z')?.setValue(object.position.z);
|
||||
const values = resolveSceneObjectProps(asset, object);
|
||||
for (const def of schemaForAsset(asset)) this.fields.get(def.key)?.setValue(values[def.key] ?? def.default);
|
||||
this.markObjectRows(object, asset, templateLinkOf(this.state.project, scene, object));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which rows deviate from their base: for a container instance the base is the template
|
||||
* (position and overrides), for any other object only overrides count as a deviation.
|
||||
*/
|
||||
private markObjectRows(object: SceneObject, asset: Asset, link: TemplateLink | undefined): void {
|
||||
for (const axis of ['x', 'y', 'z'] as const) this.fields.get(axis)?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
|
||||
for (const def of schemaForAsset(asset)) {
|
||||
const row = this.fields.get(def.key);
|
||||
row?.setValue(values[def.key] ?? def.default);
|
||||
row?.setModified(isOverridden(object, def.key));
|
||||
const modified = link ? object.overrides[def.key] !== link.template.overrides[def.key] : isOverridden(object, def.key);
|
||||
this.fields.get(def.key)?.setModified(modified);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user