Z-order via position.z and reset-to-default for asset properties

- Position is now { x, y, z }; higher z draws on top, ties keep hierarchy order
- Viewport sorts the objects layer deterministically (syncDrawOrder)
- Inspector shows a Z field; asset properties that differ from the schema
  default are marked bold with a reset button (ResetAssetPropertyCommand)
- isModified() helper, unit tests, E2E checks and seed updated

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 11:42:18 +02:00
1 parent aaa8154adf
commit d4b1ffa051
18 files changed
+232 -63

No files matched your search

+24
View File
@@ -127,6 +127,30 @@ export class SetAssetPropertyCommand implements Command {
}
}
/** Puts an asset property back to its schema default (the asset-level "clear override"). */
export class ResetAssetPropertyCommand implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
constructor(private readonly assetId: string, private readonly key: string) {
this.label = `Reset ${key}`;
}
execute(project: Project): void {
const asset = requireAsset(project, this.assetId);
const def = schemaFor(asset.type).find((d) => d.key === this.key);
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
this.previous = asset.props[this.key];
asset.props[this.key] = def.default;
}
undo(project: Project): void {
const asset = requireAsset(project, this.assetId);
if (this.previous === undefined) delete asset.props[this.key];
else asset.props[this.key] = this.previous;
}
}
export class SetActiveSceneCommand implements Command {
readonly label = 'Switch scene';
private previous = '';
+7 -6
View File
@@ -1,5 +1,5 @@
import { isPlaceableType } from '../model/AssetTypes';
import type { PropertyValue, Vec2 } from '../model/Properties';
import type { Position, PropertyValue, Vec2 } from '../model/Properties';
import { findScene, findSceneObject, type Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue, schemaFor } from '../properties/PropertySchema';
@@ -24,7 +24,7 @@ export class PlaceAssetCommand implements Command {
readonly label = 'Place asset';
readonly objectId = newId();
constructor(private readonly sceneId: string, private readonly assetId: string, private readonly position: Vec2) {}
constructor(private readonly sceneId: string, private readonly assetId: string, private readonly position: Vec2 | Position) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
@@ -60,15 +60,16 @@ export class RemoveSceneObjectCommand implements Command {
}
}
/** Sets the full position (x, y and draw order z) of a scene object. */
export class MoveSceneObjectCommand implements Command {
readonly label = 'Move object';
private previous: Vec2 = { x: 0, y: 0 };
private previous: Position = { x: 0, y: 0, z: 0 };
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly position: Vec2,
previous?: Vec2,
private readonly position: Position,
previous?: Position,
) {
if (previous) this.previous = { ...previous };
}
@@ -76,7 +77,7 @@ export class MoveSceneObjectCommand implements Command {
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
this.previous = { ...object.position };
object.position = { x: this.position.x, y: this.position.y };
object.position = { x: this.position.x, y: this.position.y, z: this.position.z };
}
undo(project: Project): void {
+14
View File
@@ -4,7 +4,21 @@ export type PropertyValue = number | string | boolean;
/** Sparse map of property key → value (asset properties or scene-object overrides). */
export type PropertyBag = Record<string, PropertyValue>;
/** A point in world or screen space. */
export interface Vec2 {
x: number;
y: number;
}
/**
* Where a scene object sits: `x`/`y` in world units, `z` is the draw order. Higher `z` is
* drawn on top; objects with equal `z` keep their hierarchy order.
*/
export interface Position extends Vec2 {
z: number;
}
/** Stable draw order: ascending `z`, ties keep the input (hierarchy) order. */
export function sortByZ<T extends { z: number }>(items: readonly T[]): T[] {
return [...items].sort((a, b) => a.z - b.z);
}
+6 -4
View File
@@ -1,4 +1,4 @@
import type { PropertyBag, Vec2 } from './Properties';
import type { Position, PropertyBag, Vec2 } from './Properties';
/**
* An asset placed inside a scene. It links to the asset by id, owns a position
@@ -10,10 +10,12 @@ export interface SceneObject {
assetId: string;
/** Optional display name; falls back to the asset name when empty. */
name: string;
position: Vec2;
/** World position plus draw order (`z`). */
position: Position;
overrides: PropertyBag;
}
export function createSceneObject(id: string, assetId: string, position: Vec2): SceneObject {
return { id, assetId, name: '', position: { x: position.x, y: position.y }, overrides: {} };
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
export function createSceneObject(id: string, assetId: string, position: Vec2 | Position): SceneObject {
return { id, assetId, name: '', position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 }, overrides: {} };
}
+11
View File
@@ -33,6 +33,17 @@ export function isOverridden(object: SceneObject, key: string): boolean {
return object.overrides[key] !== undefined;
}
/**
* Whether the asset's own value for `key` differs from the schema default – the asset-level
* counterpart of an override. The inspector highlights such values and offers a reset.
*/
export function isModified(asset: Asset, key: string): boolean {
const def = schemaFor(asset.type).find((d) => d.key === key);
if (!def) return false;
const own = asset.props[key];
return own !== undefined && own !== def.default;
}
/** Value the object would have without its override for `key` (the asset's value). */
export function inheritedValue(asset: Asset, key: string): PropertyValue | undefined {
return resolveAssetProps(asset)[key];
+1 -1
View File
@@ -67,7 +67,7 @@ function normalizeObjects(raw: unknown, types: Map<string, AssetType>): SceneObj
id: o.id,
assetId: o.assetId,
name: typeof o.name === 'string' ? o.name : '',
position: { x: toNumber(p.x), y: toNumber(p.y) },
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
overrides: normalizeBag(o.overrides, type, true),
});
}
+1 -1
View File
@@ -50,7 +50,7 @@ function installDebugHook(app: Application, state: EditorState, viewport: Viewpo
const p = node.getGlobalPosition();
return { x: p.x + node.width / 2, y: p.y + node.height / 2 };
};
(window as unknown as { __gm: unknown }).__gm = { state, locate, viewportCenter, viewportInfo: () => viewport.debugInfo(), layerOf: (id: string) => viewport.layerOf(id) };
(window as unknown as { __gm: unknown }).__gm = { state, locate, viewportCenter, viewportInfo: () => viewport.debugInfo(), layerOf: (id: string) => viewport.layerOf(id), drawOrder: () => viewport.drawOrder() };
}
class Editor {
+9 -6
View File
@@ -13,8 +13,11 @@ export interface FieldRow {
row: Widget;
/** Push a model value into the widget (no events fired). */
setValue(value: PropertyValue): void;
/** Highlight the row as overridden (scene objects) and show the reset button. */
setOverridden(overridden: boolean): void;
/**
* Highlight the row as deviating from its base value and show the reset button:
* an override on scene objects, a non-default value on assets.
*/
setModified(modified: boolean): void;
}
/**
@@ -76,10 +79,10 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
return {
row,
setValue,
setOverridden(overridden) {
label.setStyle(overridden ? 'bodyBold' : 'muted');
if (reset && reset.visible !== overridden) {
reset.visible = overridden;
setModified(modified) {
label.setStyle(modified ? 'bodyBold' : 'muted');
if (reset && reset.visible !== modified) {
reset.visible = modified;
row.relayout();
}
},
+35 -24
View File
@@ -1,14 +1,14 @@
import { RenameAssetCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
RenameSceneObjectCommand,
SetOverrideCommand,
} from '../../core/commands/sceneObjectCommands';
import type { PropertyValue } from '../../core/model/Properties';
import type { Position, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
import { schemaFor } from '../../core/properties/PropertySchema';
import { isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Panel, ScrollContainer, TextField, Theme, VStack } from '../../ui';
import { createFieldRow, type FieldRow } from '../inspector/fieldFactory';
import type { EditorState, Selection } from '../state';
@@ -66,25 +66,32 @@ export class Inspector extends Panel {
});
this.body.add(this.labelled('Name', this.nameField));
// Asset properties: values that differ from the schema default are marked and resettable.
const values = resolveAssetProps(asset);
for (const def of schemaFor(asset.type)) {
let original: PropertyValue | undefined;
const row = createFieldRow(def, values[def.key] ?? def.default, {
onInput: (value) => {
const target = findAsset(this.state.project, assetId);
if (!target) return;
if (original === undefined) original = target.props[def.key];
target.props[def.key] = value;
this.state.preview();
const row = createFieldRow(
def,
values[def.key] ?? def.default,
{
onInput: (value) => {
const target = findAsset(this.state.project, assetId);
if (!target) return;
if (original === undefined) original = target.props[def.key];
target.props[def.key] = value;
this.state.preview();
},
onChange: (value) => {
const target = findAsset(this.state.project, assetId);
if (!target) return;
if (original !== undefined) target.props[def.key] = original;
original = undefined;
this.state.execute(new SetAssetPropertyCommand(assetId, def.key, value));
},
},
onChange: (value) => {
const target = findAsset(this.state.project, assetId);
if (!target) return;
if (original !== undefined) target.props[def.key] = original;
original = undefined;
this.state.execute(new SetAssetPropertyCommand(assetId, def.key, value));
},
});
() => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)),
);
row.setModified(isModified(asset, def.key));
this.fields.set(def.key, row);
this.body.add(row.row);
}
@@ -110,10 +117,10 @@ export class Inspector extends Panel {
assetRow.add(new Button(asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
this.body.add(assetRow);
// Position – belongs to the scene object only.
// Position – belongs to the scene object only. Z is the draw order (higher = on top).
this.body.add(new Label('POSITION', { style: 'title' }));
for (const axis of ['x', 'y'] as const) {
let original: { x: number; y: number } | undefined;
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);
@@ -169,7 +176,7 @@ export class Inspector extends Panel {
},
() => this.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
);
row.setOverridden(isOverridden(object, def.key));
row.setModified(isOverridden(object, def.key));
this.fields.set(def.key, row);
this.body.add(row.row);
}
@@ -188,7 +195,10 @@ export class Inspector extends Panel {
if (!asset) return this.rebuild();
this.nameField?.setValue(asset.name);
const values = resolveAssetProps(asset);
for (const [key, row] of this.fields) row.setValue(values[key] ?? 0);
for (const [key, row] of this.fields) {
row.setValue(values[key] ?? 0);
row.setModified(isModified(asset, key));
}
return;
}
const object = this.currentObject(s.sceneId, s.objectId);
@@ -197,11 +207,12 @@ export class Inspector extends Panel {
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 schemaFor(asset.type)) {
const row = this.fields.get(def.key);
row?.setValue(values[def.key] ?? def.default);
row?.setOverridden(isOverridden(object, def.key));
row?.setModified(isOverridden(object, def.key));
}
}
+2
View File
@@ -15,5 +15,7 @@ export interface Entity {
name: string;
x: number;
y: number;
/** Draw order; higher is drawn on top. */
z: number;
props: PropertyBag;
}
+1
View File
@@ -34,6 +34,7 @@ export class World {
name: object.name || asset.name,
x: object.position.x,
y: object.position.y,
z: object.position.z,
props: resolveSceneObjectProps(asset, object),
});
}
+29 -6
View File
@@ -1,7 +1,7 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import type { AssetType } from '../core/model/AssetTypes';
import type { PropertyBag, Vec2 } from '../core/model/Properties';
import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties';
import { findAsset } from '../core/model/Project';
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
import { resolveSceneObjectProps } from '../core/properties/resolve';
@@ -16,6 +16,8 @@ interface DrawItem {
name: string;
x: number;
y: number;
/** Draw order (see `Position.z`). */
z: number;
props: PropertyBag;
}
@@ -48,7 +50,7 @@ export class Viewport extends Widget {
/** Everything that may extend past the viewport (world and gizmos) is clipped through `content`. */
private readonly content = new Container();
private readonly world = new Container();
/** Scene objects (sprites) in hierarchy order. */
/** Scene objects (sprites), ordered by `z` then hierarchy order (see `syncDrawOrder`). */
private readonly objects = new Container();
/**
* Editor symbols (camera icon, labels). Always drawn above `objects` so that a sprite can never
@@ -63,7 +65,7 @@ export class Viewport extends Widget {
/** Edit-mode view: world point at the viewport centre and zoom. */
private view = { x: 0, y: 0, zoom: 1 };
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
private dragging: { objectId: string; sceneId: string; start: Vec2; pointer: Vec2; moved: boolean } | null = null;
private dragging: { objectId: string; sceneId: string; start: Position; pointer: Vec2; moved: boolean } | null = null;
private visibleCount = 0;
constructor(private readonly state: EditorState) {
@@ -95,7 +97,7 @@ export class Viewport extends Widget {
this.dragging.moved = true;
const object = this.state.activeScene?.objects.find((o) => o.id === this.dragging?.objectId);
if (!object) return;
object.position = { x: Math.round(this.dragging.start.x + dx), y: Math.round(this.dragging.start.y + dy) };
object.position = { x: Math.round(this.dragging.start.x + dx), y: Math.round(this.dragging.start.y + dy), z: this.dragging.start.z };
this.state.preview();
}
});
@@ -154,6 +156,11 @@ export class Viewport extends Widget {
return node.parent === this.overlay ? 'overlay' : 'objects';
}
/** Object ids in the order they are drawn, back to front (test hook). */
drawOrder(): string[] {
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
@@ -199,7 +206,7 @@ export class Viewport extends Widget {
if (this.playWorld) {
return {
background: this.playWorld.backgroundColor,
items: this.playWorld.entities.filter((e) => e.type !== 'camera').map((e) => ({ id: e.objectId, type: e.type, name: e.name, x: e.x, y: e.y, props: e.props })),
items: this.playWorld.entities.filter((e) => e.type !== 'camera').map((e) => ({ id: e.objectId, type: e.type, name: e.name, x: e.x, y: e.y, z: e.z, props: e.props })),
};
}
const scene = this.state.activeScene;
@@ -208,7 +215,7 @@ export class Viewport extends Widget {
for (const object of scene.objects) {
const asset = findAsset(this.state.project, object.assetId);
if (!asset) continue;
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, x: object.position.x, y: object.position.y, props: resolveSceneObjectProps(asset, object) });
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, x: object.position.x, y: object.position.y, z: object.position.z, props: resolveSceneObjectProps(asset, object) });
}
return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) };
}
@@ -250,6 +257,7 @@ export class Viewport extends Widget {
let node = this.nodes.get(item.id);
if (!node) {
node = new Graphics();
node.label = item.id;
node.cursor = 'move';
node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
this.nodes.set(item.id, node);
@@ -278,10 +286,25 @@ export class Viewport extends Widget {
this.labels.delete(id);
}
}
this.syncDrawOrder(items);
if (editing) this.drawGizmos(items, view);
}
/**
* Re-attach the object nodes so that Pixi draws them by ascending `z`, ties in hierarchy
* order. Done explicitly (instead of Pixi's lazy `sortableChildren`) so the order is
* deterministic and hierarchy changes are honoured too.
*/
private syncDrawOrder(items: DrawItem[]): void {
const ordered = sortByZ(items.filter((item) => !this.isSymbol(item)));
this.objects.removeChildren();
for (const item of ordered) {
const node = this.nodes.get(item.id);
if (node) this.objects.addChild(node);
}
}
/** Items drawn as editor symbols rather than as scene content. */
private isSymbol(item: DrawItem): boolean {
return item.type === 'camera';