diff --git a/README.md b/README.md
index 750d6927..7a6d39f8 100644
--- a/README.md
+++ b/README.md
@@ -12,12 +12,13 @@ Live: · Widget playground: 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 };
+ }
+}
diff --git a/src/core/model/container.ts b/src/core/model/container.ts
index 87e392d3..5d3888ef 100644
--- a/src/core/model/container.ts
+++ b/src/core/model/container.ts
@@ -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([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;
diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts
index 49540597..72ecb91f 100644
--- a/src/editor/inspector/fieldFactory.ts
+++ b/src/editor/inspector/fieldFactory.ts
@@ -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();
}
},
diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts
index f32d00b0..3dbb86e9 100644
--- a/src/editor/panels/Inspector.ts
+++ b/src/editor/panels/Inspector.ts
@@ -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);
}
}
diff --git a/tests/core/apply.test.ts b/tests/core/apply.test.ts
new file mode 100644
index 00000000..64236f33
--- /dev/null
+++ b/tests/core/apply.test.ts
@@ -0,0 +1,165 @@
+import { describe, expect, it } from 'vitest';
+import {
+ ApplyOverrideToAssetCommand,
+ ApplyOverrideToContainerCommand,
+ ApplyPositionToContainerCommand,
+ RevertOverrideToTemplateCommand,
+ RevertPositionToTemplateCommand,
+} from '../../src/core/commands/applyCommands';
+import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
+import { CommandStack } from '../../src/core/commands/CommandStack';
+import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
+import { MoveSceneObjectCommand, PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
+import type { ContainerAsset } from '../../src/core/model/Asset';
+import { templateLinkOf } from '../../src/core/model/container';
+import { createEmptyProject, findAsset, findScene } from '../../src/core/model/Project';
+
+/**
+ * A box asset, a container made from two boxes (a at (100,100), b at (10,0)), and a second
+ * placed instance of that container. `a` is the original, `a2` its copy in the instance.
+ */
+function setup() {
+ const project = createEmptyProject('p', 'Apply');
+ const stack = new CommandStack(project);
+ const scene = new CreateAssetCommand('scene', 'Level');
+ const sprite = new CreateAssetCommand('sprite', 'Box');
+ stack.execute(scene);
+ stack.execute(sprite);
+ const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 100, y: 100 });
+ const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 10, y: 0 });
+ stack.execute(a);
+ stack.execute(b);
+ const create = new CreateContainerCommand(scene.asset.id, [a.objectId, b.objectId]);
+ stack.execute(create);
+ const place = new PlaceAssetCommand(scene.asset.id, create.assetId, { x: 1000, y: 0 });
+ stack.execute(place);
+ const sceneOf = () => findScene(project, scene.asset.id)!;
+ const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!;
+ const container = () => findAsset(project, create.assetId) as ContainerAsset;
+ const templateOfA = () => container().objects.find((t) => t.id === object(a.objectId).templateId)!;
+ const a2 = sceneOf().objects.find((o) => o.parentId === place.objectId && o.templateId === object(a.objectId).templateId)!.id;
+ return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, a2, object, container, templateOfA };
+}
+
+describe('template link', () => {
+ it('resolves the container and template for instance objects only', () => {
+ const { project, sceneId, a, a2, object, container, templateOfA } = setup();
+ const scene = findScene(project, sceneId)!;
+ const link = templateLinkOf(project, scene, object(a2));
+ expect(link?.asset.id).toBe(container().id);
+ expect(link?.template).toBe(templateOfA());
+ expect(templateLinkOf(project, scene, object(a))?.template).toBe(templateOfA());
+ const containerObject = scene.objects.find((o) => o.assetId === container().id)!;
+ expect(templateLinkOf(project, scene, containerObject)).toBeUndefined();
+ });
+});
+
+describe('position apply / revert', () => {
+ it('applies one axis to the template; instances still at the template value follow', () => {
+ const { stack, sceneId, a, a2, object, templateOfA } = setup();
+ expect(templateOfA().position).toEqual({ x: 45, y: 50, z: 0 });
+ // a2 was moved by hand on x → it keeps that; on y it still matches the template.
+ stack.execute(new MoveSceneObjectCommand(sceneId, a2, { x: 0, y: 50, z: 0 }));
+ stack.execute(new MoveSceneObjectCommand(sceneId, a, { x: 60, y: 70, z: 0 }));
+
+ stack.execute(new ApplyPositionToContainerCommand(sceneId, a, 'x'));
+ expect(templateOfA().position.x).toBe(60);
+ expect(object(a2).position.x).toBe(0); // modified on its own → untouched
+ stack.execute(new ApplyPositionToContainerCommand(sceneId, a, 'y'));
+ expect(templateOfA().position.y).toBe(70);
+ expect(object(a2).position.y).toBe(70); // was at the template value → follows
+
+ stack.undo();
+ expect(templateOfA().position.y).toBe(50);
+ expect(object(a2).position.y).toBe(50);
+ stack.undo();
+ expect(templateOfA().position.x).toBe(45);
+ expect(object(a).position.x).toBe(60); // the source object itself is not changed by apply
+ });
+
+ it('reverts an axis (or the whole position) to the template', () => {
+ const { stack, sceneId, a, object } = setup();
+ stack.execute(new MoveSceneObjectCommand(sceneId, a, { x: 1, y: 2, z: 3 }));
+ stack.execute(new RevertPositionToTemplateCommand(sceneId, a, 'z'));
+ expect(object(a).position).toEqual({ x: 1, y: 2, z: 0 });
+ stack.execute(new RevertPositionToTemplateCommand(sceneId, a));
+ expect(object(a).position).toEqual({ x: 45, y: 50, z: 0 });
+ stack.undo();
+ expect(object(a).position).toEqual({ x: 1, y: 2, z: 0 });
+ });
+
+ it('rejects objects that are not part of a container', () => {
+ const { project, stack, sceneId, spriteId } = setup();
+ const loose = new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 });
+ stack.execute(loose);
+ expect(() => stack.execute(new ApplyPositionToContainerCommand(sceneId, loose.objectId))).toThrow(/not part of a placed container/);
+ expect(findScene(project, sceneId)!.objects.find((o) => o.id === loose.objectId)).toBeDefined();
+ });
+});
+
+describe('override apply / revert', () => {
+ it('applies an override to the container template and to instances that had no own value', () => {
+ const { stack, sceneId, a, a2, object, templateOfA } = setup();
+ stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
+ stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
+ expect(templateOfA().overrides.width).toBe(77);
+ expect(object(a2).overrides.width).toBe(77);
+ expect(object(a).overrides.width).toBe(77);
+ stack.undo();
+ expect(templateOfA().overrides.width).toBeUndefined();
+ expect(object(a2).overrides.width).toBeUndefined();
+ expect(object(a).overrides.width).toBe(77);
+ });
+
+ it('leaves instances alone that already had their own override', () => {
+ const { stack, sceneId, a, a2, object, templateOfA } = setup();
+ stack.execute(new SetOverrideCommand(sceneId, a2, 'width', 5));
+ stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
+ stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
+ expect(templateOfA().overrides.width).toBe(77);
+ expect(object(a2).overrides.width).toBe(5);
+ });
+
+ it('applies an override to the asset, dropping the override on object and template', () => {
+ const { project, stack, sceneId, spriteId, a, a2, object, templateOfA } = setup();
+ stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
+ stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
+ stack.execute(new ApplyOverrideToAssetCommand(sceneId, a, 'width'));
+ const sprite = () => findAsset(project, spriteId)!;
+ expect(sprite().props.width).toBe(77);
+ expect(object(a).overrides.width).toBeUndefined();
+ expect(templateOfA().overrides.width).toBeUndefined();
+ expect(object(a2).overrides.width).toBeUndefined(); // shared the template value → follows the asset now
+ stack.undo();
+ expect(sprite().props.width).toBe(100);
+ expect(object(a).overrides.width).toBe(77);
+ expect(templateOfA().overrides.width).toBe(77);
+ expect(object(a2).overrides.width).toBe(77);
+ });
+
+ it('applies to the asset for a plain object too', () => {
+ const { project, stack, sceneId, spriteId, object } = setup();
+ const loose = new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 });
+ stack.execute(loose);
+ stack.execute(new SetOverrideCommand(sceneId, loose.objectId, 'height', 33));
+ stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose.objectId, 'height'));
+ expect(findAsset(project, spriteId)!.props.height).toBe(33);
+ expect(object(loose.objectId).overrides.height).toBeUndefined();
+ expect(() => stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose.objectId, 'nope'))).toThrow(/not defined/);
+ });
+
+ it('reverts an override to the template value (or removes it when the template has none)', () => {
+ const { stack, sceneId, a, a2, object, templateOfA } = setup();
+ stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
+ stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
+ stack.execute(new SetOverrideCommand(sceneId, a2, 'width', 5));
+ stack.execute(new RevertOverrideToTemplateCommand(sceneId, a2, 'width'));
+ expect(object(a2).overrides.width).toBe(77);
+ expect(templateOfA().overrides.width).toBe(77);
+ stack.execute(new SetOverrideCommand(sceneId, a2, 'height', 9));
+ stack.execute(new RevertOverrideToTemplateCommand(sceneId, a2, 'height'));
+ expect(object(a2).overrides.height).toBeUndefined();
+ stack.undo();
+ expect(object(a2).overrides.height).toBe(9);
+ });
+});
diff --git a/tools/e2e.mjs b/tools/e2e.mjs
index c1e9b7af..f25c013f 100644
--- a/tools/e2e.mjs
+++ b/tools/e2e.mjs
@@ -769,6 +769,54 @@ try {
check('placing the container asset instantiates its content below a new container object', afterPlace.length === objectsBeforeDelete + 6 && secondCrate && secondCrate.position.x === -135 && secondCrate.position.y === 40, JSON.stringify({ count: afterPlace.length, secondCrate }));
await shot('26-container-placed');
+ // 16g. Apply / revert on a container instance. The red crate (o1) is part of the first
+ // container; the second instance has an unmodified copy of it. Moving o1 on x shows
+ // ↑ and ↺ on the X row; ↑ writes x into the template and the copy follows; undo takes
+ // both back; ↺ puts o1 back to the template value.
+ const templateA = () => evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === '${containerAsset.id}').objects.find(t => t.id === '${o1c.templateId}'))`).then(JSON.parse);
+ await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' })`);
+ await sleep(200);
+ check('inspector names the container the object is part of', Boolean(await evaluate(`__gm.locate(${JSON.stringify(containerAsset.name)})`)));
+ check('no apply/revert while the instance matches its template', !(await evaluate(`__gm.locate('↑')`)) && !(await evaluate(`__gm.locate('↺')`)));
+ await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'o1').position.x = -100; __gm.state.preview(); })()`);
+ await sleep(200);
+ const applyButton = await evaluate(`__gm.locate('↑')`);
+ check('a moved instance shows apply and revert on its X row', Boolean(applyButton) && Boolean(await evaluate(`__gm.locate('↺')`)));
+ await shot('27-apply-buttons');
+ await click(applyButton.x, applyButton.y);
+ await sleep(200);
+ check('apply writes x into the template and the unmodified copy follows', (await templateA()).position.x === -100 && JSON.parse(await objectJson(secondCrate.id)).position.x === -100, JSON.stringify({ template: (await templateA()).position, copy: JSON.parse(await objectJson(secondCrate.id)).position }));
+ check('apply hides the buttons again', !(await evaluate(`__gm.locate('↑')`)));
+ await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
+ await sleep(200);
+ check('undo restores template and copy', (await templateA()).position.x === -135 && JSON.parse(await objectJson(secondCrate.id)).position.x === -135);
+ const revertButton = await evaluate(`__gm.locate('↺')`);
+ await click(revertButton.x, revertButton.y);
+ await sleep(200);
+ check('revert puts the instance back to the template position', JSON.parse(await objectJson('o1')).position.x === -135);
+
+ // 16h. Overrides on an instance offer a menu: "Apply to container" or "Apply to asset".
+ await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 77; __gm.state.preview(); })()`);
+ await sleep(200);
+ const applyMenu = await evaluate(`__gm.locate('↑ ▾')`);
+ check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu));
+ await click(applyMenu.x, applyMenu.y);
+ await sleep(150);
+ check('the menu offers container and asset', Boolean(await evaluate(`__gm.locate('Apply to container')`)) && Boolean(await evaluate(`__gm.locate('Apply to asset')`)));
+ await shot('28-apply-menu');
+ await clickText('Apply to asset');
+ await sleep(200);
+ const crateAfterApply = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'crate').props)`));
+ check('Apply to asset writes the value into the asset and drops the override', crateAfterApply.width === 77 && JSON.parse(await objectJson('o1')).overrides.width === undefined, JSON.stringify(crateAfterApply));
+ await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
+ await sleep(200);
+ check('undo restores asset and override', (await evaluate(`__gm.state.project.assets.find(a => a.id === 'crate').props.width`)) === 100 && JSON.parse(await objectJson('o1')).overrides.width === 77);
+ await click(applyMenu.x, applyMenu.y);
+ await sleep(150);
+ await clickText('Apply to container');
+ await sleep(200);
+ check('Apply to container writes the override into the template and the copy', (await templateA()).overrides.width === 77 && JSON.parse(await objectJson(secondCrate.id)).overrides.width === 77);
+
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
ws.close();
} catch (error) {