diff --git a/README.md b/README.md
index 6221da63..c254f5bd 100644
--- a/README.md
+++ b/README.md
@@ -18,9 +18,9 @@ Live: · Widget playground: ();
+ while (current?.parentId && !seen.has(current.id)) {
+ seen.add(current.id);
+ const parent = findObject(tree, current.parentId);
+ if (!parent || seen.has(parent.id)) break;
+ result.push(parent.id);
+ current = parent;
+ }
+ return result;
+}
+
/** Whether `ancestorId` is `objectId` itself or one of its ancestors (guards against cycles). */
export function isSelfOrAncestor(tree: ObjectTree, ancestorId: string, objectId: string): boolean {
let current: SceneObject | undefined = findObject(tree, objectId);
@@ -114,3 +129,31 @@ export function treeOrder(tree: ObjectTree): TreeEntry[] {
}
return result;
}
+
+export interface VisibleEntry extends TreeEntry {
+ /** Whether the object has children at all – only those rows offer a expand/collapse chevron. */
+ hasChildren: boolean;
+}
+
+/**
+ * Tree order with the subtrees of collapsed objects left out: a row is shown when *every*
+ * ancestor is expanded. Expansion is a view state (which ids are open), never stored in the
+ * project – anything not listed counts as collapsed, so a freshly loaded scene shows only its
+ * top level.
+ */
+export function visibleTreeOrder(tree: ObjectTree, expanded: ReadonlySet): VisibleEntry[] {
+ const parents = new Set(tree.objects.map((o) => o.parentId).filter(Boolean));
+ const result: VisibleEntry[] = [];
+ // Depth of the collapsed row we are currently skipping the subtree of (-1 = showing rows).
+ let collapsedAt = -1;
+ for (const entry of treeOrder(tree)) {
+ if (collapsedAt >= 0) {
+ if (entry.depth > collapsedAt) continue;
+ collapsedAt = -1;
+ }
+ const hasChildren = parents.has(entry.object.id);
+ result.push({ ...entry, hasChildren });
+ if (hasChildren && !expanded.has(entry.object.id)) collapsedAt = entry.depth;
+ }
+ return result;
+}
diff --git a/src/core/properties/resolve.ts b/src/core/properties/resolve.ts
index 9858336b..9eba2894 100644
--- a/src/core/properties/resolve.ts
+++ b/src/core/properties/resolve.ts
@@ -48,3 +48,22 @@ export function isModified(asset: Asset, key: string): boolean {
export function inheritedValue(asset: Asset, key: string): PropertyValue | undefined {
return resolveAssetProps(asset)[key];
}
+
+/**
+ * Whether the object's value for `key` really differs from the asset's – an override that
+ * happens to carry the asset's own value is none. The inspector offers "reset"/"apply" only
+ * where they would change something, so this is what decides whether the buttons appear.
+ */
+export function deviatesFromAsset(object: SceneObject, asset: Asset, key: string): boolean {
+ const own = object.overrides[key];
+ return own !== undefined && own !== inheritedValue(asset, key);
+}
+
+/**
+ * Whether the object's override for `key` differs from the template it was instantiated from.
+ * 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];
+}
diff --git a/src/editor/App.ts b/src/editor/App.ts
index 27298724..4bbc55cd 100644
--- a/src/editor/App.ts
+++ b/src/editor/App.ts
@@ -81,7 +81,11 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
},
},
world: () => editor.worldSnapshot(),
+ /** Rows the hierarchy shows – collapsed subtrees are not in here. */
hierarchyOrder: () => editor.hierarchy.rowOrder(),
+ hierarchyChevron: (id: string) => editor.hierarchy.chevronPosition(id),
+ hierarchyExpandAll: () => editor.hierarchy.expandAll(),
+ hierarchyCollapseAll: () => editor.hierarchy.collapseAll(),
fps: () => editor.fps,
tab: () => editor.tab,
showTab: (tab: CentreTab) => editor.showTab(tab),
diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts
index 72ecb91f..7ba5b23a 100644
--- a/src/editor/inspector/fieldFactory.ts
+++ b/src/editor/inspector/fieldFactory.ts
@@ -14,29 +14,36 @@ export interface FieldRow {
/** Push a model value into the widget (no events fired). */
setValue(value: PropertyValue): 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.
+ * Highlight the row as deviating from its base value (an override on scene objects, a
+ * non-default value on assets) and re-evaluate which actions currently apply.
*/
setModified(modified: boolean): void;
}
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 {
+/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */
+export interface FieldAction {
label: string;
onSelect: () => void;
}
+/**
+ * The actions available *right now* – asked again on every refresh and on every click.
+ * Anything that would not change the value is left out, so a row never offers a dead entry.
+ */
+export type FieldActions = () => FieldAction[];
+
/**
* 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.
+ * A row shows its actions at the right: ↺ resets to the base value, ↑ applies the value to
+ * its source. Which of them exist is asked from `reset`/`apply` on every refresh: no entry
+ * means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾).
*/
-export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, onReset?: () => void, apply?: ApplyAction[]): FieldRow {
+export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, reset?: FieldActions, apply?: FieldActions): FieldRow {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const label = new Label(def.label, { style: 'muted' });
@@ -47,32 +54,50 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Label(''), 1);
- 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 });
+ const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
+ const addActions = (glyph: string, available: FieldActions): void => {
+ const button = new Button(glyph, { variant: 'ghost', width: Theme.controlHeight });
+ // Asked again on click: the menu must not act on a state that has moved on since the
+ // last refresh (an undo between refreshes, a value edited in the viewport).
button.onClick = () => {
- if (single) return apply[0]!.onSelect();
+ const items = available();
+ if (items.length === 0) return;
+ if (items.length === 1) return items[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);
+ new Menu(items.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;
+ actions.push({ button, glyph, available });
row.add(button);
- }
+ };
+ if (apply) addActions('↑', apply);
+ if (reset) addActions('↺', reset);
+
+ const refreshActions = (): void => {
+ let changed = false;
+ for (const { button, glyph, available } of actions) {
+ const count = available().length;
+ const text = count > 1 ? `${glyph} ▾` : glyph;
+ const visible = count > 0;
+ if (button.visible !== visible) {
+ button.visible = visible;
+ changed = true;
+ }
+ if (visible && button.text !== text) {
+ button.setText(text);
+ changed = true;
+ }
+ }
+ if (changed) row.relayout();
+ };
+ refreshActions();
return {
row,
setValue: field.setValue,
setModified(modified) {
label.setStyle(modified ? 'bodyBold' : 'muted');
- if (actions.length > 0 && actions[0]!.visible !== modified) {
- for (const button of actions) button.visible = modified;
- row.relayout();
- }
+ refreshActions();
},
};
}
diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts
index 9be33d3e..fb198f00 100644
--- a/src/editor/panels/HierarchyPanel.ts
+++ b/src/editor/panels/HierarchyPanel.ts
@@ -1,6 +1,6 @@
import { AddColliderCommand } from '../../core/commands/colliderCommands';
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
-import { treeOrder } from '../../core/model/hierarchy';
+import { ancestorIdsOf, visibleTreeOrder } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
@@ -12,10 +12,16 @@ import { type EditorState, objectSelection } from '../state';
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
* another to parent it there; drop it on the empty space below the rows to move it to the
* top level. The object keeps its world position either way.
+ *
+ * Rows with children fold: the chevron (or ←/→, or a double-click on the row) toggles them.
+ * Everything starts collapsed – which ids are open is view state kept here, never part of the
+ * project, and selecting an object always unfolds the way down to it.
*/
export class HierarchyPanel extends Panel {
private readonly list = new ListView();
private readonly scroll = new ScrollContainer();
+ /** Ids of the objects whose children are shown. Anything missing is collapsed. */
+ private readonly expanded = new Set();
constructor(private readonly state: EditorState) {
super({ title: 'Scene objects', padding: 0, gap: 0 });
@@ -32,8 +38,12 @@ export class HierarchyPanel extends Panel {
if (!scene) return;
const object = scene.objects.find((o) => o.id === draggedId);
if (!object || object.parentId === (targetId ?? '')) return;
+ // Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
+ if (targetId) this.expanded.add(targetId);
state.execute(new SetParentCommand(scene.id, draggedId, targetId ?? ''));
};
+ this.list.onToggle = (id, expand) => this.setExpanded(id, expand);
+ this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id));
this.list.onContextMenu = (id, x, y) => {
const scene = state.activeScene;
if (!scene) return;
@@ -54,6 +64,8 @@ export class HierarchyPanel extends Panel {
},
{ label: many ? `Create container from ${selected.length} objects` : 'Create container', onSelect: () => createContainer(state, scene.id, selected) },
{ label: 'Unparent (move to top level)', disabled: !object?.parentId || many, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) },
+ { label: 'Expand all', onSelect: () => this.expandAll() },
+ { label: 'Collapse all', onSelect: () => this.collapseAll() },
{ label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene', danger: true, onSelect: () => removeObjects(state, scene.id, selected) },
]).open(x, y);
};
@@ -61,22 +73,62 @@ export class HierarchyPanel extends Panel {
this.content.add(this.scroll, 1);
state.on('project', () => this.refresh());
- state.on('load', () => this.refresh());
- state.on('selection', () => this.syncSelection());
+ // A different project brings different objects – start folded again.
+ state.on('load', () => { this.expanded.clear(); this.refresh(); });
+ state.on('selection', () => (this.revealSelection() ? this.refresh() : this.syncSelection()));
this.refresh();
}
- /** Ids in the order the rows are shown (test hook). */
+ /** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */
rowOrder(): string[] {
const scene = this.state.activeScene;
- return scene ? treeOrder(scene).map((e) => e.object.id) : [];
+ return scene ? visibleTreeOrder(scene, this.expanded).map((e) => e.object.id) : [];
+ }
+
+ /** Global centre of a row's chevron, so tests can click the real hit area (test hook). */
+ chevronPosition(id: string): { x: number; y: number } | null {
+ return this.list.chevronGlobal(id);
+ }
+
+ expandAll(): void {
+ const scene = this.state.activeScene;
+ for (const object of scene?.objects ?? []) this.expanded.add(object.id);
+ this.refresh();
+ }
+
+ collapseAll(): void {
+ this.expanded.clear();
+ // Keep the selected object reachable instead of folding it away under the user's hands.
+ this.revealSelection();
+ this.refresh();
+ }
+
+ private setExpanded(id: string, expand: boolean): void {
+ if (expand) this.expanded.add(id);
+ else this.expanded.delete(id);
+ this.refresh();
+ }
+
+ /** Unfolds the ancestors of every selected object. Returns whether that changed anything. */
+ private revealSelection(): boolean {
+ const scene = this.state.activeScene;
+ if (!scene) return false;
+ let changed = false;
+ for (const id of this.state.selectedObjectIds) {
+ for (const ancestorId of ancestorIdsOf(scene, id)) {
+ if (this.expanded.has(ancestorId)) continue;
+ this.expanded.add(ancestorId);
+ changed = true;
+ }
+ }
+ return changed;
}
private refresh(): void {
const scene = this.state.activeScene;
this.setTitle(scene ? `Scene: ${scene.name}` : 'Scene objects');
this.list.setItems(
- (scene ? treeOrder(scene) : []).map(({ object, depth }) => {
+ (scene ? visibleTreeOrder(scene, this.expanded) : []).map(({ object, depth, hasChildren }) => {
const asset = findAsset(this.state.project, object.assetId);
const props = asset ? resolveSceneObjectProps(asset, object) : {};
return {
@@ -85,11 +137,13 @@ export class HierarchyPanel extends Panel {
hint: asset?.type ?? '',
depth,
swatch: asset?.type === 'sprite' ? Number(props.color ?? 0) : undefined,
+ expanded: hasChildren ? this.expanded.has(object.id) : undefined,
};
}),
);
this.syncSelection();
this.scroll.relayoutContent();
+ this.scrollToSelection();
}
private syncSelection(): void {
@@ -97,4 +151,12 @@ export class HierarchyPanel extends Panel {
this.list.select(s?.kind === 'object' ? s.objectId : null, true);
this.list.highlight(s?.kind === 'object' ? s.objectIds : []);
}
+
+ /** Keeps the primary selection in view after unfolding brought it back into the list. */
+ private scrollToSelection(): void {
+ const s = this.state.selection;
+ if (s?.kind !== 'object' || !s.objectId) return;
+ const index = this.rowOrder().indexOf(s.objectId);
+ if (index >= 0) this.scroll.ensureVisible(index * this.list.rowHeight, this.list.rowHeight);
+ }
}
diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts
index c586d74d..6284d036 100644
--- a/src/editor/panels/Inspector.ts
+++ b/src/editor/panels/Inspector.ts
@@ -26,7 +26,7 @@ import type { Position, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
-import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
+import { deviatesFromAsset, deviatesFromTemplate, isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, ui, VStack } from '../../ui';
import { createContainer, removeObjects } from '../actions';
import { createField, createFieldRow, FIELD_LABEL_WIDTH, type FieldRow } from '../inspector/fieldFactory';
@@ -148,7 +148,11 @@ export class Inspector extends Panel {
const assetId = asset.id;
const values = resolveAssetProps(asset);
for (const def of schema) {
- const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)));
+ const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => {
+ const target = findAsset(this.state.project, assetId);
+ if (!target || !isModified(target, def.key)) return [];
+ return [{ label: 'Reset to default', onSelect: () => 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);
@@ -277,7 +281,7 @@ export class Inspector extends Panel {
// The master switch, right where it is looked for: above everything type-specific.
for (const def of COMMON_PROPS) {
- this.body.add(this.objectPropertyRow(sceneId, objectId, def, resolveSceneObjectProps(asset, object), templateLinkOf(this.state.project, scene, object) ?? null).row);
+ this.body.add(this.objectPropertyRow(sceneId, objectId, def, resolveSceneObjectProps(asset, object)).row);
}
// Position – belongs to the scene object only, local to the parent. Z is the draw order (higher = on top).
@@ -306,8 +310,14 @@ export class Inspector extends Panel {
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.positionMoved(sceneId, objectId, axis)
+ ? [{ label: 'Reset to container', onSelect: () => this.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)) }]
+ : [],
+ () =>
+ this.positionMoved(sceneId, objectId, axis)
+ ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)) }]
+ : [],
);
this.fields.set(axis, row);
this.body.add(row.row);
@@ -321,7 +331,7 @@ export class Inspector extends Panel {
const own = schema.filter((def) => !isCommonProp(def.key));
if (own.length > 0 || asset.type === 'behavior') this.body.add(new Label(asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES', { style: 'title' }));
if (own.length === 0 && asset.type === 'behavior') this.body.add(new Label('The behavior declares no variables.', { style: 'muted' }));
- for (const def of own) this.body.add(this.objectPropertyRow(sceneId, objectId, def, values, link ?? null).row);
+ for (const def of own) this.body.add(this.objectPropertyRow(sceneId, objectId, def, values).row);
this.markObjectRows(object, asset, link);
if (asset.type === 'container') {
@@ -350,8 +360,14 @@ export class Inspector extends Panel {
* One editable property of a scene object: writes an override, offers ↺ (back to the asset
* or, inside a container, to the template) and ↑ (apply upwards). Registered in `fields`
* so live refreshes and `markObjectRows` find it.
+ *
+ * Inside a container a value has two bases – the template it was instantiated from and, one
+ * step further up, the asset – so ↺/↑ can become a menu. Only bases the value really differs
+ * from are listed, so a menu never contains an entry that would change nothing. Dropping the
+ * override is what makes the object follow the asset again: the template is only read when
+ * the container is placed, never at draw time.
*/
- private objectPropertyRow(sceneId: string, objectId: string, def: PropertyDef, values: Record, link: TemplateLink | null): FieldRow {
+ private objectPropertyRow(sceneId: string, objectId: string, def: PropertyDef, values: Record): FieldRow {
let original: PropertyValue | undefined;
let hadOverride = false;
const row = createFieldRow(
@@ -379,11 +395,20 @@ export class Inspector extends Panel {
this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value));
},
},
- () => 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)) },
- ],
+ () => {
+ const { template, asset } = this.deviations(sceneId, objectId, def.key);
+ return [
+ ...(template ? [{ label: 'Reset to container', onSelect: () => this.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)) }] : []),
+ ...(asset ? [{ label: 'Reset to asset', onSelect: () => this.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)) }] : []),
+ ];
+ },
+ () => {
+ const { template, asset } = this.deviations(sceneId, objectId, def.key);
+ return [
+ ...(template ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
+ ...(asset ? [{ label: 'Apply to asset', onSelect: () => this.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) }] : []),
+ ];
+ },
);
this.fields.set(def.key, row);
return row;
@@ -539,21 +564,54 @@ export class Inspector extends Panel {
/**
* 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.
+ * (position and overrides), for any other object the asset. Bold marks exactly what the
+ * ↺/↑ buttons offer – a value that matches its base is neither highlighted nor actionable.
*/
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 modified = link ? object.overrides[def.key] !== link.template.overrides[def.key] : isOverridden(object, def.key);
- this.fields.get(def.key)?.setModified(modified);
+ const deviates = link ? deviatesFromTemplate(object, link.template, def.key) : deviatesFromAsset(object, asset, def.key);
+ this.fields.get(def.key)?.setModified(deviates);
}
}
+ /**
+ * The two bases a property of a scene object can be reset to or applied upwards, each only
+ * listed when it actually differs: the template of the container it belongs to (a value
+ * copied when the container was placed) and the asset behind it. Inside a container both
+ * can apply at once – then the row shows a menu, otherwise a single button, and nothing
+ * at all when the value matches everywhere.
+ */
+ private deviations(sceneId: string, objectId: string, key: string): { template: boolean; asset: boolean } {
+ const object = this.currentObject(sceneId, objectId);
+ const asset = object && findAsset(this.state.project, object.assetId);
+ if (!object || !asset) return { template: false, asset: false };
+ const link = this.linkOf(sceneId, objectId);
+ return {
+ template: Boolean(link) && deviatesFromTemplate(object, link!.template, key),
+ asset: deviatesFromAsset(object, asset, key),
+ };
+ }
+
+ /** Whether the object sits somewhere else on `axis` than the container template says. */
+ private positionMoved(sceneId: string, objectId: string, axis: 'x' | 'y' | 'z'): boolean {
+ const object = this.currentObject(sceneId, objectId);
+ const link = this.linkOf(sceneId, objectId);
+ return Boolean(object && link) && object!.position[axis] !== link!.template.position[axis];
+ }
+
private currentObject(sceneId: string, objectId: string) {
const scene = findScene(this.state.project, sceneId);
return scene && findSceneObject(scene, objectId);
}
+ /** The object's template link as it is *now* – actions must not work off a stale snapshot. */
+ private linkOf(sceneId: string, objectId: string): TemplateLink | undefined {
+ const scene = findScene(this.state.project, sceneId);
+ const object = this.currentObject(sceneId, objectId);
+ return scene && object ? templateLinkOf(this.state.project, scene, object) : undefined;
+ }
+
private currentNode(assetId: string, nodeId: string) {
const asset = findAsset(this.state.project, assetId);
return asset?.type === 'behavior' ? asset.graph.nodes.find((n) => n.id === nodeId) : undefined;
diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts
index 3a9bb3be..dca643c8 100644
--- a/src/ui/widgets/ListView.ts
+++ b/src/ui/widgets/ListView.ts
@@ -12,11 +12,19 @@ export interface ListItem {
depth?: number;
/** Optional marker color drawn as a small square before the label. */
swatch?: number;
+ /**
+ * Rows that have children: whether their subtree is currently shown. Such rows get a
+ * chevron that reports clicks through `onToggle`. Left undefined for leaves.
+ */
+ expanded?: boolean;
}
/** Pixels the pointer must travel before a press turns into a drag. */
const DRAG_THRESHOLD = 4;
+/** Width reserved in front of every row of a tree list for the expand/collapse chevron. */
+const CHEVRON_SLOT = 14;
+
/** Modifier keys held while a row was clicked. */
export interface PickModifiers {
/** Ctrl (or ⌘) – toggle the row in a multi-selection. */
@@ -41,6 +49,11 @@ export class ListView extends Widget {
onContextMenu?: (id: string, globalX: number, globalY: number) => void;
/** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */
onDrop?: (draggedId: string, targetId: string | null) => void;
+ /**
+ * Called when a row's chevron is clicked or ←/→ is pressed on it. Setting this makes the list
+ * a tree: every row reserves the chevron slot, so labels stay aligned across a level.
+ */
+ onToggle?: (id: string, expand: boolean) => void;
rowHeight: number = Theme.rowHeight;
emptyText = 'Nothing here yet';
private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') });
@@ -74,10 +87,13 @@ export class ListView extends Widget {
this.items = items;
for (const row of this.rows) row.destroy({ children: true });
this.rows = items.map((item, index) => {
- const row = new Row(item, this.rowHeight);
+ const row = new Row(item, this.rowHeight, Boolean(this.onToggle));
row.on('pointerdown', (e: FederatedPointerEvent) => {
const modifiers: PickModifiers = { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey };
- if (e.button === 2) {
+ // The chevron only folds the row – it never selects, so folding keeps the selection put.
+ if (e.button === 0 && row.hitsChevron(row.toLocal(e.global).x)) {
+ this.onToggle?.(item.id, !item.expanded);
+ } else if (e.button === 2) {
// A right-click inside a multi-selection keeps it (the menu acts on all of it).
if (!this.highlighted.has(item.id)) this.select(item.id);
this.onContextMenu?.(item.id, e.global.x, e.global.y);
@@ -117,6 +133,11 @@ export class ListView extends Widget {
return [...this.highlighted];
}
+ /** Global centre of a row's chevron – null when the row has none (test hook). */
+ chevronGlobal(id: string): { x: number; y: number } | null {
+ return this.rows.find((r) => r.item.id === id)?.chevronGlobal() ?? null;
+ }
+
override measure(): Size {
return { w: this.minWidth, h: Math.max(this.minHeight, this.items.length * this.rowHeight) };
}
@@ -133,6 +154,13 @@ export class ListView extends Widget {
if (prev) this.select(prev.id);
return true;
}
+ if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
+ const item = this.items[index];
+ const expand = event.key === 'ArrowRight';
+ if (!item || item.expanded === undefined || item.expanded === expand) return false;
+ this.onToggle?.(item.id, expand);
+ return true;
+ }
if (event.key === 'Enter' && this.selectedId) {
this.onActivate?.(this.selectedId);
return true;
@@ -200,7 +228,7 @@ class Row extends Widget {
private selected = false;
private listFocused = false;
- constructor(readonly item: ListItem, height: number) {
+ constructor(readonly item: ListItem, height: number, private readonly chevronSlot: boolean) {
super();
this.cursor = 'pointer';
this.minHeight = height;
@@ -216,10 +244,27 @@ class Row extends Widget {
this.invalidate();
}
+ /** Left edge of the chevron slot (or of the content itself in a flat list). */
+ private get indent(): number {
+ return Theme.padding + (this.item.depth ?? 0) * 14;
+ }
+
+ chevronGlobal(): { x: number; y: number } | null {
+ if (!this.chevronSlot || this.item.expanded === undefined) return null;
+ const p = this.getGlobalPosition();
+ return { x: p.x + this.indent + CHEVRON_SLOT / 2, y: p.y + this.h / 2 };
+ }
+
+ /** Whether a click at this local x lands on the chevron rather than on the row. */
+ hitsChevron(localX: number): boolean {
+ if (!this.chevronSlot || this.item.expanded === undefined) return false;
+ return localX >= this.indent - 2 && localX < this.indent + CHEVRON_SLOT;
+ }
+
override redraw(): void {
const c = Theme.colors;
const depth = this.item.depth ?? 0;
- const indent = Theme.padding + depth * 14;
+ const indent = this.indent;
this.bg.clear();
if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive);
else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover);
@@ -229,6 +274,10 @@ class Row extends Widget {
this.bg.moveTo(gx, 0).lineTo(gx, this.h / 2).lineTo(indent - 2, this.h / 2).stroke({ width: 1, color: c.border });
}
let x = indent;
+ if (this.chevronSlot) {
+ if (this.item.expanded !== undefined) this.drawChevron(x + 4, this.h / 2, this.item.expanded);
+ x += CHEVRON_SLOT;
+ }
if (this.item.swatch !== undefined) {
this.bg.roundRect(x, (this.h - 12) / 2, 12, 12, 2).fill(this.item.swatch);
this.bg.roundRect(x + 0.5, (this.h - 12) / 2 + 0.5, 11, 11, 2).stroke({ width: 1, color: c.border });
@@ -240,4 +289,11 @@ class Row extends Widget {
this.labelText.position.set(x, Math.round((this.h - this.labelText.height) / 2));
this.hint.position.set(Math.round(this.w - Theme.padding - this.hint.width), Math.round((this.h - this.hint.height) / 2));
}
+
+ /** ▸ when collapsed, ▾ when expanded – drawn, not glyphs, so it stays crisp at any size. */
+ private drawChevron(x: number, y: number, expanded: boolean): void {
+ const color = this.selected ? Theme.colors.text : Theme.colors.textMuted;
+ if (expanded) this.bg.moveTo(x - 3, y - 2).lineTo(x + 5, y - 2).lineTo(x + 1, y + 3).fill(color);
+ else this.bg.moveTo(x - 1, y - 4).lineTo(x + 4, y).lineTo(x - 1, y + 4).fill(color);
+ }
}
diff --git a/src/ui/widgets/ScrollContainer.ts b/src/ui/widgets/ScrollContainer.ts
index 1de6d902..c5d7f4f5 100644
--- a/src/ui/widgets/ScrollContainer.ts
+++ b/src/ui/widgets/ScrollContainer.ts
@@ -64,6 +64,12 @@ export class ScrollContainer extends Widget {
this.applyScroll();
}
+ /** Scrolls just far enough for the content band [top, top + height) to be inside the view. */
+ ensureVisible(top: number, height: number): void {
+ if (top < this.scrollY) this.scrollTo(top);
+ else if (top + height > this.scrollY + this.h) this.scrollTo(top + height - this.h);
+ }
+
/** Re-measure the content after it changed (rows added/removed). */
relayoutContent(): void {
if (!this.content) return;
diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts
index 3c700810..5c230d4e 100644
--- a/tests/core/hierarchy.test.ts
+++ b/tests/core/hierarchy.test.ts
@@ -3,7 +3,7 @@ import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/
import { CommandStack } from '../../src/core/commands/CommandStack';
import { MoveSceneObjectCommand, PlaceAssetCommand, RemoveSceneObjectCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import { GraphInterpreter, type NodeContext, worldPositionOf } from '../../src/core/graph';
-import { descendantsOf, localPositionFor, treeOrder, worldPosition } from '../../src/core/model/hierarchy';
+import { ancestorIdsOf, descendantsOf, localPositionFor, treeOrder, visibleTreeOrder, worldPosition } from '../../src/core/model/hierarchy';
import { createEmptyProject, findScene } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { World } from '../../src/runtime/World';
@@ -72,6 +72,29 @@ describe('scene hierarchy', () => {
expect(descendantsOf(sceneOf(), a).map((o) => o.id)).toEqual([c, b]);
});
+ it('hides the subtrees of collapsed objects and marks the rows that can fold', () => {
+ const { stack, sceneId, a, b, c, sceneOf } = setup();
+ // a > c > b: two levels, so folding a hides both descendants.
+ stack.execute(new SetParentCommand(sceneId, c, a));
+ stack.execute(new SetParentCommand(sceneId, b, c));
+ const rows = (expanded: string[]) => visibleTreeOrder(sceneOf(), new Set(expanded)).map((e) => [e.object.id, e.depth, e.hasChildren]);
+
+ // Nothing expanded (the default when a scene is opened) – only the top level is listed.
+ expect(rows([])).toEqual([[a, 0, true]]);
+ expect(rows([a])).toEqual([[a, 0, true], [c, 1, true]]);
+ expect(rows([a, c])).toEqual([[a, 0, true], [c, 1, true], [b, 2, false]]);
+ // Expanding a nested object without its parent shows nothing extra: every ancestor must be open.
+ expect(rows([c])).toEqual([[a, 0, true]]);
+ });
+
+ it('lists the ancestors of an object nearest first, so a selection can be unfolded', () => {
+ const { stack, sceneId, a, b, c, sceneOf } = setup();
+ stack.execute(new SetParentCommand(sceneId, c, a));
+ stack.execute(new SetParentCommand(sceneId, b, c));
+ expect(ancestorIdsOf(sceneOf(), b)).toEqual([c, a]);
+ expect(ancestorIdsOf(sceneOf(), a)).toEqual([]);
+ });
+
it('removing an object or deleting an asset takes the subtree along, undo restores it', () => {
const { project, stack, sceneId, spriteId, a, b, c, sceneOf } = setup();
const behavior = new CreateAssetCommand('behavior', 'Logic');
diff --git a/tests/core/resolve.test.ts b/tests/core/resolve.test.ts
index f68f1f12..26bc0916 100644
--- a/tests/core/resolve.test.ts
+++ b/tests/core/resolve.test.ts
@@ -2,7 +2,15 @@ import { describe, expect, it } from 'vitest';
import { buildAsset } from '../../src/core/commands/assetCommands';
import { createSceneObject } from '../../src/core/model/SceneObject';
import { coerceValue, defaultsFor, schemaFor } from '../../src/core/properties/PropertySchema';
-import { inheritedValue, isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../src/core/properties/resolve';
+import {
+ deviatesFromAsset,
+ deviatesFromTemplate,
+ inheritedValue,
+ isModified,
+ isOverridden,
+ resolveAssetProps,
+ resolveSceneObjectProps,
+} from '../../src/core/properties/resolve';
describe('PropertySchema', () => {
it('has no position on any asset type', () => {
@@ -59,6 +67,40 @@ describe('resolve', () => {
expect(isModified(sprite, 'bogus')).toBe(false);
});
+ it('ignores an override that carries the asset value – nothing to reset or apply', () => {
+ const sprite = buildAsset('sprite', 'Box');
+ sprite.props.width = 250;
+ const object = createSceneObject('o1', sprite.id, { x: 0, y: 0 });
+ expect(deviatesFromAsset(object, sprite, 'width')).toBe(false); // no override at all
+
+ object.overrides.width = 250; // same value as the asset → not a deviation
+ expect(isOverridden(object, 'width')).toBe(true);
+ expect(deviatesFromAsset(object, sprite, 'width')).toBe(false);
+
+ object.overrides.width = 300;
+ expect(deviatesFromAsset(object, sprite, 'width')).toBe(true);
+
+ // The asset moving to the object's value settles the difference again.
+ sprite.props.width = 300;
+ expect(deviatesFromAsset(object, sprite, 'width')).toBe(false);
+ });
+
+ it('compares an instance against its template as overrides, not as values', () => {
+ const sprite = buildAsset('sprite', 'Box');
+ const template = createSceneObject('t1', sprite.id, { x: 0, y: 0 });
+ const object = createSceneObject('o1', sprite.id, { x: 0, y: 0 });
+ expect(deviatesFromTemplate(object, template, 'width')).toBe(false); // neither overrides
+
+ template.overrides.width = 140;
+ expect(deviatesFromTemplate(object, template, 'width')).toBe(true);
+
+ object.overrides.width = 140;
+ expect(deviatesFromTemplate(object, template, 'width')).toBe(false);
+
+ object.overrides.width = 77;
+ expect(deviatesFromTemplate(object, template, 'width')).toBe(true);
+ });
+
it('keeps position on the scene object only', () => {
const sprite = buildAsset('sprite', 'Box');
const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 });
diff --git a/tools/e2e.mjs b/tools/e2e.mjs
index 4dcb9999..2f766582 100644
--- a/tools/e2e.mjs
+++ b/tools/e2e.mjs
@@ -25,6 +25,33 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async
check('page loaded without exceptions', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).join(' | '));
check('debug hook present', Boolean(await evaluate('Boolean(window.__gm)')));
+ // 0a. Hierarchy folding. A freshly opened scene is fully collapsed, so only its top-level
+ // objects are listed (o4/oc1 hang under o1, oc2 under o2). The chevron folds a row open
+ // and shut without touching the selection, and selecting a nested object unfolds the way
+ // down to it.
+ const rowIds = () => evaluate('JSON.stringify(__gm.hierarchyOrder())').then(JSON.parse);
+ const selectionBefore = JSON.parse(await stateJson()).selection;
+ check('hierarchy starts collapsed – only top-level objects are listed', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds()));
+ check('a leaf row offers no chevron', (await evaluate(`__gm.hierarchyChevron('o3')`)) === null);
+ const chevron = JSON.parse(await evaluate(`JSON.stringify(__gm.hierarchyChevron('o2'))`));
+ await click(chevron.x, chevron.y);
+ await sleep(150);
+ check(
+ 'clicking the chevron unfolds the row, leaving the selection alone',
+ JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'oc2', 'o3', 'o5']) && JSON.stringify(JSON.parse(await stateJson()).selection) === JSON.stringify(selectionBefore),
+ JSON.stringify(await rowIds()),
+ );
+ await shot('01a-hierarchy-unfolded');
+ await click(chevron.x, chevron.y);
+ await sleep(150);
+ check('clicking it again folds the subtree away', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds()));
+ await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'oc1' })`);
+ await sleep(150);
+ check('selecting a nested object unfolds its ancestors', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds()));
+ await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
+ await sleep(150);
+ check('Collapse all folds everything back', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds()));
+
// 1. Select the blue crate in the hierarchy.
await clickText('Blue crate');
let s = JSON.parse(await stateJson());
@@ -129,7 +156,7 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async
await clickText('▶ Play');
await sleep(300);
check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game');
- check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyOrder().length')) === 7);
+ check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7);
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' })`);
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400; __gm.state.preview()`);
await sleep(100);
@@ -155,7 +182,7 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async
check('Spawn node adds a crate object to the runtime scene at (0, 150)', spawned && spawned.assetId === 'crate' && spawned.parentId === '' && spawned.position.x === 0 && spawned.position.y === 150 && spawnedObjects.length === 8, JSON.stringify(spawned));
const spawnWorld = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
check('the spawned entity lives in the world and its name reached the Log node', spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log }));
- check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyOrder().length')) === 8);
+ check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8);
info = await evaluate('JSON.stringify(__gm.gameInfo())');
check('the Game view renders the spawned crate', JSON.parse(info).visibleItems === visibleBeforeSpawn + 1, `${info} before=${visibleBeforeSpawn}`);
await shot('08c-spawned');
@@ -426,7 +453,7 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async
const objectJson = (id) => evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === '${id}'))`);
let o2 = JSON.parse(await objectJson('o2'));
check('dropping a row onto another parents it, keeping the world position', o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, JSON.stringify(o2));
- const order = JSON.parse(await evaluate(`JSON.stringify(__gm.hierarchyOrder())`));
+ const order = JSON.parse(await evaluate(`__gm.hierarchyExpandAll(); JSON.stringify(__gm.hierarchyOrder())`));
// o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider.
const o1At = order.indexOf('o1');
check('children are listed directly below their parent', JSON.stringify(order.slice(o1At + 1, o1At + 5)) === JSON.stringify(['o2', 'oc2', 'o4', 'oc1']), JSON.stringify(order));
@@ -609,7 +636,7 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async
const o2c = JSON.parse(await objectJson('o2'));
check('the crates are re-parented below the container with local positions relative to its centre', o1c.parentId === containerObj?.id && o1c.position.x === -135 && o1c.position.y === 40 && o2c.parentId === containerObj?.id && o2c.position.x === 135 && o2c.position.y === -40, JSON.stringify({ o1: o1c.position, o2: o2c.position }));
check('the template holds the whole subtree (2 crates, 2 colliders, the Mover)', containerAsset?.objects.length === 5 && containerAsset.objects.filter((t) => t.parentId === '').length === 2 && o1c.templateId && containerAsset.objects.some((t) => t.id === o1c.templateId), JSON.stringify(containerAsset?.objects.map((t) => [t.assetId, t.parentId === '' ? 'root' : 'child'])));
- const orderC = JSON.parse(await evaluate('JSON.stringify(__gm.hierarchyOrder())'));
+ const orderC = JSON.parse(await evaluate('__gm.hierarchyExpandAll(); JSON.stringify(__gm.hierarchyOrder())'));
const cAt = orderC.indexOf(containerObj?.id);
check('hierarchy lists the container with its content indented below', cAt >= 0 && JSON.stringify(orderC.slice(cAt)) === JSON.stringify([containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']), JSON.stringify(orderC));
check('container symbol is drawn in the overlay layer', containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay');
@@ -715,6 +742,37 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async
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);
+ // 16i. o1 now matches its template (both 77) but still overrides the asset (100). Only bases
+ // the value really differs from are offered, so "Reset to container" is left out and the
+ // menu collapses to a direct ↺. Deviating from both brings the menu back.
+ await sleep(200);
+ check(
+ 'a reset that would change nothing is left out – a direct button instead of a menu',
+ Boolean(await evaluate(`__gm.locate('↺')`)) && !(await evaluate(`__gm.locate('↺ ▾')`)) && !(await evaluate(`__gm.locate('↑ ▾')`)),
+ );
+ await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 55; __gm.state.preview(); })()`);
+ await sleep(200);
+ const resetMenu = await evaluate(`__gm.locate('↺ ▾')`);
+ check('deviating from template and asset brings the reset menu back', Boolean(resetMenu));
+ await click(resetMenu.x, resetMenu.y);
+ await sleep(150);
+ check('the reset menu offers container and asset', Boolean(await evaluate(`__gm.locate('Reset to container')`)) && Boolean(await evaluate(`__gm.locate('Reset to asset')`)));
+ await shot('28b-reset-menu');
+ await clickText('Reset to container');
+ await sleep(200);
+ check('Reset to container puts the override back on the template value', JSON.parse(await objectJson('o1')).overrides.width === 77);
+ const singleReset = await evaluate(`__gm.locate('↺')`);
+ check('with only the asset left to differ from, the menu is a single button again', Boolean(singleReset) && !(await evaluate(`__gm.locate('↺ ▾')`)));
+ await click(singleReset.x, singleReset.y);
+ await sleep(200);
+ check('↺ drops the override so the object follows the asset again', JSON.parse(await objectJson('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77);
+ // Dropping the override left the object on the asset value (100) while the template still
+ // says 77 – that is a real deviation, so exactly one entry remains: "Reset to container".
+ check('the remaining deviation from the template keeps a single button, not a menu', Boolean(await evaluate(`__gm.locate('↺')`)) && !(await evaluate(`__gm.locate('↺ ▾')`)));
+ await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
+ await sleep(200);
+ check('undo restores the override', JSON.parse(await objectJson('o1')).overrides.width === 77);
+
// 17. Text assets: created through the + menu, placed, and rendered as real text in the scene.
await clickText('+');
await sleep(150);
diff --git a/tools/smoke-demo.mjs b/tools/smoke-demo.mjs
index f0554f3f..f6745908 100644
--- a/tools/smoke-demo.mjs
+++ b/tools/smoke-demo.mjs
@@ -39,7 +39,8 @@ await runChecks({ projectId, baseUrl, port: 9335, shotPrefix: 'gm-smoke' }, asyn
const objs = await objects();
const roots = objs.filter((o) => o.parentId === '').map((o) => o.id);
check('demo scene has the seeded objects and roots', objs.length === OBJECT_COUNT && ROOTS.every((id) => roots.includes(id)), `${objs.length} objects, roots=${JSON.stringify(roots)}`);
- check('hierarchy lists every object', (await evaluate('__gm.hierarchyOrder().length')) === OBJECT_COUNT);
+ check('hierarchy opens collapsed – one row per root', (await evaluate('__gm.hierarchyOrder().length')) === ROOTS.length, JSON.stringify(await evaluate('JSON.stringify(__gm.hierarchyOrder())')));
+ check('hierarchy lists every object once expanded', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === OBJECT_COUNT);
// 2. Every child of a container instance points at an object of that container's template.
const templateLinks = JSON.parse(await evaluate(`JSON.stringify((() => {