Measure a deviation against the base, not the innermost template

An instance edit was dropped as soon as its value matched the template
object. With nested containers the template is not what the value falls
back to: the outer container holds an edit for the object below it, and
that one wins. If the inner template happened to carry the same value the
user typed, the edit was thrown away and the value jumped to whatever the
container in between says.

Reported against the Jump-and-Run project: a coin narrowed to 8 inside
"level", then set back to the sprite's own 24 in the scene, snapped back
to 8. Reproduced through the inspector before touching anything.

InstanceTarget now offers `base`: the template with the edits of every
container in between woven in. Everything that judges deviation uses it -
setOverride/setName/setPosition, both Revert commands, and the gold marks
in the inspector. Writing still goes into the real template, which is
where "apply to container" belongs.

`base` is built on every read. Apply writes into the template and then
asks whether the edit still deviates, so a base captured beforehand would
answer for the template as it was - the e2e suite caught exactly that.

Also in here, because it corrects the commit before it: placing an asset
by double-clicking its row is gone, as asked for in asset-doppelklick.md.
Dragging is the only way to place now. That took AssetPanel.place, the
PlacementTarget interface, Editor.placementTarget and the two worldCenter()
methods that existed only to feed it. A double-click on a scene asset still
makes it the active scene.
This commit is contained in:
root committed 2026-09-09 13:45:27 +02:00
1 parent 75d377af39
commit d7c8d3e579
16 files changed
+180 -77

No files matched your search

+2 -2
View File
@@ -117,7 +117,7 @@ export class RevertOverrideToTemplateCommand implements Command {
const target = requireTarget(project, this.holderId, this.objectId);
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
ref.setOverride(this.key, target.template.overrides[this.key]);
ref.setOverride(this.key, target.base.overrides[this.key]);
}
undo(): void {
@@ -174,7 +174,7 @@ export class RevertPositionToTemplateCommand implements Command {
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
const position = { ...ref.object.position };
for (const axis of this.axis ? [this.axis] : ALL_AXES) position[axis] = target.template.position[axis];
for (const axis of this.axis ? [this.axis] : ALL_AXES) position[axis] = target.base.position[axis];
ref.setPosition(position);
}
+33 -5
View File
@@ -201,6 +201,15 @@ export interface InstanceTarget {
/** The container asset that declares `template` – the one to open to change it for good. */
asset: ContainerAsset;
template: SceneObject;
/**
* What the object would show without this instance's edit: the template with the edits of every
* container in between already woven in. Only this is a base a value can fall back to, so it –
* not `template` – decides whether an edit deviates. The two differ once containers nest.
*
* Built fresh on every read, because "apply" writes into `template` and then asks whether the
* edit still deviates – a base captured beforehand would answer for the old template.
*/
readonly base: SceneObject;
}
/** Where a derived id points: the instance holding the edits, and the template object behind it. */
@@ -215,16 +224,35 @@ export function instanceTargetOf(
const split = splitInstanceId(objectId);
const instance = split && holder.objects.find((o) => o.id === split.instanceId);
if (!split || !instance) return undefined;
const segments = split.path.split(PATH_SEP);
let asset = containerAssetOf(project, instance);
let template: SceneObject | undefined;
for (const segment of split.path.split(PATH_SEP)) {
// Each container on the way holds edits for what lies below it, keyed relative to itself.
const between: { holder: SceneObject; key: string }[] = [];
for (const [index, segment] of segments.entries()) {
const next = template ? containerAssetOf(project, template) : asset;
if (!next) return undefined;
asset = next;
template = next.objects.find((t) => t.id === segment);
if (!template) return undefined;
if (index < segments.length - 1)
between.push({ holder: template, key: segments.slice(index + 1).join(PATH_SEP) });
}
return asset && template ? { instance, path: split.path, asset, template } : undefined;
if (!asset || !template) return undefined;
const leaf = template;
return {
instance,
path: split.path,
asset,
template: leaf,
get base() {
let edit: InstanceEdit | undefined;
// Outermost wins, as in `expand`: it sits closest to the instance being edited.
for (let i = between.length - 1; i >= 0; i--)
edit = mergeEdits(edit, between[i]!.holder.instanceEdits[between[i]!.key]);
return resolveChild(leaf, edit, leaf.id, leaf.parentId);
},
};
}
// --- writing -----------------------------------------------------------------------------------
@@ -267,13 +295,13 @@ function instanceRef(target: InstanceTarget, object: SceneObject): ObjectRef {
target,
setName: (name) =>
write((edit) => {
if (name === target.template.name) delete edit.name;
if (name === target.base.name) delete edit.name;
else edit.name = name;
}),
setPosition: (position) =>
write((edit) => {
for (const axis of AXES) {
if (position[axis] === target.template.position[axis]) delete edit.position[axis];
if (position[axis] === target.base.position[axis]) delete edit.position[axis];
else edit.position[axis] = position[axis];
}
}),
@@ -284,7 +312,7 @@ function instanceRef(target: InstanceTarget, object: SceneObject): ObjectRef {
}),
setOverride: (key, value) =>
write((edit) => {
if (value === target.template.overrides[key]) delete edit.overrides[key];
if (value === target.base.overrides[key]) delete edit.overrides[key];
else setBagValue(edit.overrides, key, value);
}),
};
+2 -2
View File
@@ -53,6 +53,6 @@ export function deviatesFromAsset(object: SceneObject, asset: Asset, key: string
* Compared as overrides, not as effective values: both sides resolve against the same asset, so
* "neither overrides" means equal even when the asset value changed since.
*/
export function deviatesFromTemplate(object: SceneObject, template: SceneObject, key: string): boolean {
return object.overrides[key] !== template.overrides[key];
export function deviatesFromTemplate(object: SceneObject, base: SceneObject, key: string): boolean {
return object.overrides[key] !== base.overrides[key];
}
+2 -12
View File
@@ -9,7 +9,7 @@ import { attachAutosave } from './autosave';
import { ContainerEditor } from './container/ContainerEditor';
import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector';
import { AssetPanel, type PlacementTarget } from './panels/AssetPanel';
import { AssetPanel } from './panels/AssetPanel';
import { HierarchyPanel } from './panels/HierarchyPanel';
import { Toolbar } from './panels/Toolbar';
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
@@ -92,7 +92,7 @@ export class Editor implements PlayHost {
this.workspace = new HStack();
this.leftColumn = new VStack({ gap: 1 });
this.assets = new AssetPanel(state, () => this.placementTarget(), textures);
this.assets = new AssetPanel(state, textures);
this.leftColumn.add(this.assets, 1);
installTextureImport(app.canvas, { state, textures });
this.leftColumn.add(this.hierarchy, 1);
@@ -185,16 +185,6 @@ export class Editor implements PlayHost {
ui().focus.validate();
}
/** Where the Assets panel places: into the container being edited, otherwise into the scene. */
private placementTarget(): PlacementTarget | undefined {
if (this.tab === 'container') {
const holder = findObjectHolder(this.state.project, this.containerEditor.currentAssetId);
return holder ? { holder, at: this.containerEditor.worldCenter() } : undefined;
}
const scene = this.state.activeScene;
return scene ? { holder: scene, at: this.viewport.worldCenter() } : undefined;
}
boot(): Promise<void> {
return this.projects.boot();
}
-6
View File
@@ -1,6 +1,5 @@
import { Graphics } from 'pixi.js';
import type { ContainerAsset } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset, listContainers } from '../../core/model/Project';
import {
activeDrag,
@@ -79,11 +78,6 @@ export class ContainerEditor extends VStack {
return this.canvas.dropInfo();
}
/** Middle of the view – where a double-click in the Assets panel places into this template. */
worldCenter(): Vec2 {
return this.canvas.worldCenter();
}
/** Where a template object is drawn, in global screen coordinates – for the check scripts. */
objectScreen(objectId: string): { x: number; y: number } | null {
return this.canvas.objectScreen(objectId);
+4 -4
View File
@@ -487,12 +487,12 @@ function markObjectRows(
for (const axis of POSITION_AXES)
host.fields
.get(axis)
?.setModified(Boolean(target) && object.position[axis] !== target!.template.position[axis]);
?.setModified(Boolean(target) && object.position[axis] !== target!.base.position[axis]);
for (const axis of OFFSET_AXES)
host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0);
for (const def of schemaForAsset(asset)) {
const deviates = target
? deviatesFromTemplate(object, target.template, def.key)
? deviatesFromTemplate(object, target.base, def.key)
: deviatesFromAsset(object, asset, def.key);
host.fields.get(def.key)?.setModified(deviates);
}
@@ -516,7 +516,7 @@ function deviations(
const target = targetOf(state.project, holderId, objectId);
const fromAsset = deviatesFromAsset(object, asset, key);
return {
template: Boolean(target) && deviatesFromTemplate(object, target!.template, key),
template: Boolean(target) && deviatesFromTemplate(object, target!.base, key),
asset: !target && fromAsset,
applyToAsset: fromAsset,
};
@@ -531,7 +531,7 @@ function positionMoved(
): boolean {
const target = targetOf(state.project, holderId, objectId);
const object = currentRef(state, holderId, objectId)?.object;
return Boolean(target && object) && object!.position[axis] !== target!.template.position[axis];
return Boolean(target && object) && object!.position[axis] !== target!.base.position[axis];
}
function currentRef(
+5 -30
View File
@@ -1,6 +1,5 @@
import { CreateAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands';
import { nextAssetName } from '../../core/commands/naming';
import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import {
ASSET_TYPE_LABELS,
ASSET_TYPES,
@@ -8,31 +7,20 @@ import {
hasColorSwatch,
isPlaceableType,
} from '../../core/model/AssetTypes';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { findAsset } from '../../core/model/Project';
import type { TextureStore } from '../../core/store/TextureStore';
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer, startDrag } from '../../ui';
import { confirmDeleteAsset } from '../actions';
import { type EditorState, objectSelection } from '../state';
import type { EditorState } from '../state';
import { pickAndImport } from '../textureImport';
/**
* Where a placed asset lands. The tab in front decides, so it appears where the user is
* looking: the Scene tab places into the active scene, the Container tab into the template.
*/
export interface PlacementTarget {
holder: ObjectHolder;
at: Vec2;
}
/** Lists all assets of the project; creates, places and deletes them. */
/** Lists all assets of the project; creates and deletes them. Placing is a drag into a view. */
export class AssetPanel extends Panel {
private readonly list = new ListView();
private readonly scroll = new ScrollContainer();
constructor(
private readonly state: EditorState,
private readonly target: () => PlacementTarget | undefined,
/** Where imported images go. Without it the Texture entry has nothing to upload to. */
private readonly textures: TextureStore | null = null,
) {
@@ -99,12 +87,10 @@ export class AssetPanel extends Panel {
pickAndImport({ state: this.state, textures: this.textures });
}
/** Double-click / Enter: scenes become active, everything else is placed. */
/** Double-click / Enter on a scene makes it the active one. Placing is a drag, not a click. */
private activate(id: string): void {
const asset = findAsset(this.state.project, id);
if (!asset) return;
if (asset.type === 'scene') this.state.execute(new SetActiveSceneCommand(id));
else this.place(id);
if (asset?.type === 'scene') this.state.execute(new SetActiveSceneCommand(id));
}
/** Where an asset's row sits on screen, so a check can pick it up by id (test hook). */
@@ -119,17 +105,6 @@ export class AssetPanel extends Panel {
startDrag({ kind: 'asset', id: assetId }, asset.name);
}
private place(assetId: string): void {
const target = this.target();
if (!target) {
this.state.setNotice('Nothing to place into – create a scene, or open a container first.');
return;
}
const { holder, at } = target;
const command = new PlaceAssetCommand(holder.id, assetId, at);
if (this.state.execute(command)) this.state.select(objectSelection(holder.id, [command.objectId]));
}
private openContextMenu(id: string, x: number, y: number): Menu | undefined {
const asset = findAsset(this.state.project, id);
if (!asset) return undefined;
-5
View File
@@ -116,11 +116,6 @@ export class SceneViewport extends WorldViewport {
return p ? { assetId: p.assetId, x: p.at.x, y: p.at.y, rejection: p.rejection } : null;
}
/** World coordinates at the centre of the view. */
worldCenter(): Vec2 {
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
}
/**
* Frames everything the holder contains. The origin is always included: it is where a container
* is grabbed when placed, so a template drawn far off to one side would otherwise hide it.