diff --git a/README.md b/README.md
index d5011ec2..3815b35e 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ Live: · Widget playground: 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);
}),
};
diff --git a/src/core/properties/resolve.ts b/src/core/properties/resolve.ts
index cc4a4054..38c69ded 100644
--- a/src/core/properties/resolve.ts
+++ b/src/core/properties/resolve.ts
@@ -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];
}
diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts
index 7d2872c3..17775379 100644
--- a/src/editor/Editor.ts
+++ b/src/editor/Editor.ts
@@ -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 {
return this.projects.boot();
}
diff --git a/src/editor/container/ContainerEditor.ts b/src/editor/container/ContainerEditor.ts
index d3c4e76c..a5f3c8d7 100644
--- a/src/editor/container/ContainerEditor.ts
+++ b/src/editor/container/ContainerEditor.ts
@@ -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);
diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts
index a87aeb61..f6e01339 100644
--- a/src/editor/inspector/objectSection.ts
+++ b/src/editor/inspector/objectSection.ts
@@ -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(
diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts
index e9d226f6..1f36eb2d 100644
--- a/src/editor/panels/AssetPanel.ts
+++ b/src/editor/panels/AssetPanel.ts
@@ -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;
diff --git a/src/viewport/SceneViewport.ts b/src/viewport/SceneViewport.ts
index 091afa35..a6cc3d6a 100644
--- a/src/viewport/SceneViewport.ts
+++ b/src/viewport/SceneViewport.ts
@@ -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.
diff --git a/tests/core/apply.test.ts b/tests/core/apply.test.ts
index 389a0af4..ee628b05 100644
--- a/tests/core/apply.test.ts
+++ b/tests/core/apply.test.ts
@@ -107,12 +107,15 @@ describe('position apply / revert', () => {
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();
+ const { stack, sceneId, a, a2, object, templateOfA, sceneOf, instanceId } = 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);
+ // The template carries it now, so nothing is left to deviate – the edit has to be gone, not
+ // merely equal. It is dropped by comparing against a base read *after* the write.
+ expect(sceneOf().objects.find((o) => o.id === instanceId)!.instanceEdits).toEqual({});
stack.undo();
expect(templateOfA().overrides.width).toBeUndefined();
expect(object(a2).overrides.width).toBeUndefined();
@@ -173,3 +176,57 @@ describe('override apply / revert', () => {
expect(object(a2).overrides.height).toBe(9);
});
});
+
+/**
+ * A container inside a container: the value a scene edit falls back to is the template *plus* the
+ * edits of every container in between – not the innermost template alone.
+ */
+function nested() {
+ const f = makeSpriteScene();
+ const inner = new CreateContainerCommand(f.sceneId, [place(f, f.spriteId, { x: 0, y: 0 })]);
+ f.stack.execute(inner);
+ const innerAsset = () => findAsset(f.project, inner.assetId) as ContainerAsset;
+ const leaf = innerAsset().objects[0]!.id;
+ // The sprite is 100 wide anyway: an override carrying the asset's own value, the way putting a
+ // value back by hand in a container editor leaves one behind.
+ f.stack.execute(new SetOverrideCommand(inner.assetId, leaf, 'width', 100));
+
+ const outer = new CreateContainerCommand(f.sceneId, [inner.objectId]);
+ f.stack.execute(outer);
+ const outerAsset = () => findAsset(f.project, outer.assetId) as ContainerAsset;
+ const middle = outerAsset().objects[0]!.id;
+ // Inside the outer container, that one sprite is narrowed to 8.
+ f.stack.execute(new SetOverrideCommand(outer.assetId, instanceChildId(middle, leaf), 'width', 8));
+
+ return { ...f, leaf, deep: instanceChildId(outer.objectId, `${middle}/${leaf}`) };
+}
+
+describe('a base two levels up', () => {
+ it('keeps a scene value that equals the inner template but not the container in between', () => {
+ const { stack, sceneId, deep, object } = nested();
+ expect(object(deep).overrides.width).toBe(8);
+ stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 10));
+ expect(object(deep).overrides.width).toBe(10);
+ // 100 is what the innermost template says – but the container in between says 8, so this is
+ // a deviation and has to be kept. Comparing against the template alone dropped it.
+ stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 100));
+ expect(object(deep).overrides.width).toBe(100);
+ stack.undo();
+ expect(object(deep).overrides.width).toBe(10);
+ });
+
+ it('resets to the value the container in between gives, not to the inner template', () => {
+ const { stack, sceneId, deep, object } = nested();
+ stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 100));
+ stack.execute(new RevertOverrideToTemplateCommand(sceneId, deep, 'width'));
+ expect(object(deep).overrides.width).toBe(8);
+ });
+
+ it('drops an edit that matches the base, so the value follows again', () => {
+ const { stack, sceneId, deep, object, sceneOf } = nested();
+ stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 8));
+ const instance = sceneOf().objects.find((o) => o.id === deep.split('~')[0])!;
+ expect(instance.instanceEdits).toEqual({});
+ expect(object(deep).overrides.width).toBe(8);
+ });
+});
diff --git a/todo-notes/done/asset-doppelklick.md b/todo-notes/done/asset-doppelklick.md
new file mode 100644
index 00000000..31552689
--- /dev/null
+++ b/todo-notes/done/asset-doppelklick.md
@@ -0,0 +1,18 @@
+Angeblich kann man Assets per Doppelklick in die Scene/Container platzieren. Das funktioniert aber nicht.
+Falls es irgendwie implementiert ist gehört es sowieso raus => Das Doppelklick Feature brauchen wir nicht!
+
+---
+
+**Umgesetzt (2026-09-09).** Raus, wie gewuenscht.
+
+Es *war* implementiert: `ListView` meldete einen Doppelklick (und Enter) als `onActivate`, und
+`AssetPanel.activate` platzierte damit in die Mitte der sichtbaren Ansicht — der Weg, den ich in
+`asset-drag-and-drop.md` noch als "bleibt bestehen" beschrieben hatte. Warum er bei dir nichts
+tat, habe ich nicht weiter verfolgt: das Feature faellt ohnehin weg.
+
+Entfernt sind damit auch die Teile, die nur dafuer existierten: `AssetPanel.place`, das
+`PlacementTarget`-Interface samt `Editor.placementTarget`, und die beiden `worldCenter()`-Methoden
+in `ContainerEditor` und `SceneViewport`, die sonst tot im Baum stehengeblieben waeren.
+
+**Geblieben:** Doppelklick (oder Enter) auf ein **Szenen**-Asset setzt es weiterhin als aktive
+Szene — das ist kein Platzieren, sondern derselbe Befehl wie im Kontextmenue.
diff --git a/todo-notes/done/asset-drag-and-drop.md b/todo-notes/done/asset-drag-and-drop.md
index b7308e4e..2e2024f0 100644
--- a/todo-notes/done/asset-drag-and-drop.md
+++ b/todo-notes/done/asset-drag-and-drop.md
@@ -5,7 +5,8 @@ zukünftig soll es per drag and drop passieren
**Umgesetzt (2026-09-09).** Drag and Drop ersetzt den Menueintrag vollstaendig — auf deine
Ansage hin ist "Place in scene"/"Place in container" aus dem Kontextmenue verschwunden.
-Doppelklick auf die Zeile platziert weiterhin in die Mitte der sichtbaren Ansicht.
+Doppelklick auf die Zeile platzierte zunaechst weiterhin in die Mitte der Ansicht — auf deine Notiz
+`asset-doppelklick.md` hin ist auch das entfernt.
- **Ziehen:** die Zeile im Assets-Panel wird nach 4 px Weg zum Drag; ein Ghost mit dem
Asset-Namen haengt am Cursor (`src/ui/drag.ts`). Die `ListView` erkennt die Geste nur und
diff --git a/todo-notes/done/overwrite-reset-nested.md b/todo-notes/done/overwrite-reset-nested.md
new file mode 100644
index 00000000..31f982af
--- /dev/null
+++ b/todo-notes/done/overwrite-reset-nested.md
@@ -0,0 +1,30 @@
+folgende fehlverhalten, beispiel jump and run projekt:
+- wenn ich IM container asset "level" von einem einzigen coin sprite die breite ändere von 24 => 8
+- wenn ich dann in der level scene bin und auf das geänderte coin sprite gehe und von 8 => 10 ändere funktioniert es noch
+- sobald ich aber auf den original standard wert 24 ändere (oder auf den reset button klicke) dann springt es zurück auf 8 (ist korrekt bei reset, aber wenn ich explizit die ursprünglichen 24 haben will, geht das nicht, weil dieser wert nicht mehr als overwrite gilt)
+
+---
+
+**Umgesetzt (2026-09-09).** Reproduziert im Browser am echten Jump-and-Run-Stand: nach dem Tippen
+von 24 stand `{"ov":8}` da, genau wie gemeldet. Deine Deutung war richtig — der Wert galt nicht
+mehr als Override und wurde weggeworfen.
+
+**Ursache.** Ein Instanz-Edit wurde geloescht, sobald der Wert dem Template-Objekt gleicht
+(`instanceRef.setOverride`). Bei **verschachtelten** Containern ist das Template aber nicht die
+Basis: `level` haelt auf seinem `c1`-Objekt einen Edit fuer das darunterliegende `coin-body`
+(die 8), und der gewinnt gegen das Template im `coin`-Asset. Traegt dieses Template zufaellig
+schon die 24 — was jede Ruecknahme auf den Asset-Wert in einem Container-Editor hinterlaesst,
+weil ein gespeicherter Override nie geloescht wird — dann galten 24 als "gleich dem Template",
+der Edit fiel weg und der Wert landete auf der Zwischenebene: **8**.
+
+**Fix.** `InstanceTarget` hat jetzt `base`: das Template mit den Edits **jedes Containers
+dazwischen** eingewoben. Nur das ist ein Wert, auf den etwas zurueckfallen kann. Alles, was eine
+Abweichung beurteilt, benutzt es: `setOverride`/`setName`/`setPosition`, die beiden
+`Revert…ToTemplate`-Kommandos und die Gold-Markierung im Inspector. Das **Schreiben** von
+"Apply to container" geht weiterhin ins echte Template — dort gehoert der Wert hin.
+
+Bei einer einzigen Ebene sind `base` und `template` identisch, das Verhalten aendert sich dort
+nicht. Reset landet weiterhin auf 8 (deine Erwartung), 24 bleibt jetzt als Override stehen.
+
+Drei Unit-Tests in `tests/core/apply.test.ts` halten es fest; zwei davon schlagen ohne den Fix
+fehl (gegengeprueft).
diff --git a/todo-notes/flaky-pixel-art-filter.md b/todo-notes/flaky-pixel-art-filter.md
new file mode 100644
index 00000000..f4feee72
--- /dev/null
+++ b/todo-notes/flaky-pixel-art-filter.md
@@ -0,0 +1,14 @@
+Der E2E-Check `pixel-art filtering keeps the border between two quadrants hard` faellt
+**sporadisch** um — heute (2026-09-09) zweimal in vier Laeufen, jeweils ohne dass eine Aenderung
+Texturen beruehrt haette.
+
+- gruen: `["#e63c3c","#e63c3c","#3cc85a","#3cc85a","#3cc85a"]` — harte Kante, `nearest` greift
+- rot: `["#c65641","#a67047","#868a4c","#66a552","#3cc85a"]` — weicher Verlauf, also `linear`
+
+Der Folge-Check (`switching the filter back to smooth …`) meldet im Fehlerfall fuer **beide**
+Filter denselben Verlauf, ist aber trotzdem gruen — er vergleicht nicht gegen den Erwartungswert.
+Das heisst: wenn der Filterwechsel haengt, faellt genau ein Check, und der zweite deckt es zu.
+
+Zu klaeren: laedt der Test die Textur zu frueh aus (Timing), oder wird `scaleMode` beim ersten
+Setzen manchmal nicht auf die schon hochgeladene GPU-Textur durchgereicht (dann ist es ein echter
+Produktfehler und kein Testproblem)?
diff --git a/todo-notes/overwrite-reset-nested.md b/todo-notes/overwrite-reset-nested.md
deleted file mode 100644
index 0612ae90..00000000
--- a/todo-notes/overwrite-reset-nested.md
+++ /dev/null
@@ -1,4 +0,0 @@
-folgende fehlverhalten, beispiel jump and run projekt:
-- wenn ich IM container asset "level" von einem einzigen coin sprite die breite ändere von 24 => 8
-- wenn ich dann in der level scene bin und auf das geänderte coin sprite gehe und von 8 => 10 ändere funktioniert es noch
-- sobald ich aber auf den original standard wert 24 ändere (oder auf den reset button klicke) dann springt es zurück auf 8 (ist korrekt bei reset, aber wenn ich explizit die ursprünglichen 24 haben will, geht das nicht, weil dieser wert nicht mehr als overwrite gilt)
diff --git a/todo-notes/overwrite-textfarbe-auch-bei-assets.md b/todo-notes/overwrite-textfarbe-auch-bei-assets.md
index 687e0c68..5ec01b89 100644
--- a/todo-notes/overwrite-textfarbe-auch-bei-assets.md
+++ b/todo-notes/overwrite-textfarbe-auch-bei-assets.md
@@ -1 +1 @@
-die textfarbe beim asset soll auch in diesem gold dargestellt werden, falls ein property des assets einen override hat
\ No newline at end of file
+die textfarbe beim asset soll auch in diesem gold dargestellt werden, falls ein property des assets einen override hat