import type { Command } from '../../core/commands/Command'; import { oneStep } from '../../core/commands/CompoundCommand'; import { UnpackContainerCommand } from '../../core/commands/containerCommands'; import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands'; import { ancestorIdsOf, childIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy'; import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { deviationMarks } from '../../core/properties/deviation'; import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui'; import { addCollider, addRigidbody, canAddCollider, canAddRigidbody, createContainer, removeObjects, } from '../actions'; import { hasAssetEditor } from '../assetTabs'; import { ASSET_ICON_SVG } from './assetIcons'; import { type EditorState, objectSelection } from '../state'; /** * Objects of the scene or container being edited, as a tree; dragging re-parents or reorders and always * keeps world position. Fold state is view state kept here, never in the project. */ 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(); /** * Which objects are listed. A function, not an id, so a container renamed or swapped under the * panel needs no notification; the Container tab points this at the template it edits. */ private source: () => ObjectHolder | undefined = () => this.state.activeScene; /** Id of the holder the rows were built from, to notice when the source starts naming another. */ private shownHolderId = ''; constructor(private readonly state: EditorState) { super({ title: 'Scene objects', padding: 0, gap: 0 }); this.minWidth = 160; this.list.emptyText = 'Place assets here'; this.list.onSelect = (id, modifiers) => { const holder = this.holder(); if (!holder) return; if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id); else state.select(id ? objectSelection(holder.id, [id]) : null); }; this.list.onDrop = (draggedId, spot) => this.drop(draggedId, spot); 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) => this.openContextMenu(id, x, y); this.scroll.setContent(this.list); this.content.add(this.scroll, 1); state.on('project', () => this.refresh()); // A different project brings different objects – start folded again. state.on('load', () => { this.expanded.clear(); this.refresh(); }); state.on('selection', () => { // The Container tab's picker only changes the selection, which swaps the holder under this // panel; without the id check the title and rows would keep naming the previous one. const swapped = (this.holder()?.id ?? '') !== this.shownHolderId; if (this.revealSelection() || swapped) this.refresh(); else this.syncSelection(); }); this.refresh(); } /** A move that leaves the order as it is does nothing, so a drag back to its start costs no undo step. */ private drop(draggedId: string, spot: DropSpot): void { const holder = this.holder(); const object = holder && findObject(holder, draggedId); if (!holder || !object) return; if (spot.at === 'onto') { if (object.parentId === spot.targetId) return; // Unfold the new parent, otherwise the dragged row would seem to vanish on drop. this.expanded.add(spot.targetId); this.state.execute(new SetParentCommand(holder.id, draggedId, spot.targetId)); return; } let parentId = ''; let beforeId = ''; if (spot.at !== 'end') { const target = findObject(holder, spot.targetId); if (!target) return; parentId = target.parentId; const order = childIdsOf(holder, parentId); beforeId = spot.at === 'before' ? target.id : (order[order.indexOf(target.id) + 1] ?? ''); } if (beforeId === draggedId) return; const siblings = childIdsOf(holder, parentId); const rest = siblings.filter((id) => id !== draggedId); const at = beforeId ? rest.indexOf(beforeId) : rest.length; const wanted = [...rest.slice(0, at), draggedId, ...rest.slice(at)]; if (object.parentId === parentId && wanted.join() === siblings.join()) return; const commands: Command[] = []; if (object.parentId !== parentId) commands.push(new SetParentCommand(holder.id, draggedId, parentId)); commands.push(new ReorderObjectCommand(holder.id, draggedId, beforeId)); if (parentId) this.expanded.add(parentId); this.state.execute(oneStep('Move object', commands)); } /** * Lists another holder's objects – the Container tab hands in its template, the Scene tab the * active scene again. */ showObjectsOf(source: () => ObjectHolder | undefined): void { this.source = source; this.refresh(); } /** With the content of placed containers resolved in – the rows show it like any other object. */ private holder(): ObjectHolder | undefined { const holder = this.source(); return holder && resolvedHolder(this.state.project, holder); } /** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */ rowOrder(): string[] { const holder = this.holder(); return holder ? visibleTreeOrder(holder, this.expanded).map((e) => e.object.id) : []; } /** Label colour of a row as `#rrggbb` – gold means it or something below it deviates (test hook). */ rowColor(id: string): string | null { return this.list.rowColor(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); } /** Global centre of a row, away from its chevron (test hook). */ rowPosition(id: string): { x: number; y: number } | null { return this.list.rowGlobal(id); } /** Global centre of the type icon at a row's right edge (test hook). */ iconPosition(id: string): { x: number; y: number } | null { return this.list.iconGlobal(id); } expandAll(): void { for (const object of this.holder()?.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 openContextMenu(id: string, x: number, y: number): Menu | undefined { const { state } = this; const holder = this.holder(); if (!holder) return undefined; const inScene = holder.type === 'scene'; const object = findObject(holder, id); const asset = object && findAsset(state.project, object.assetId); // Content of a placed container belongs to its template: values can be changed here, the // structure cannot (see `model/instance.ts`). const source = this.source(); const fromTemplate = Boolean(source && isTemplateContent(source, id)); const instance = Boolean(object && source && isLiveInstance(state.project, source, object)); // Inside a multi-selection the menu acts on all selected objects. const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id]; const many = selected.length > 1; const menu = new Menu([ { label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }), }, { // The same as double-clicking the asset in the asset panel: selects it and opens its tab. // A kind without an editor of its own would only be selected, so it is offered greyed out. label: 'Open asset', disabled: !asset || !hasAssetEditor(asset.type), onSelect: () => object && state.openAsset(object.assetId), }, { label: 'Add collider', disabled: !object || !source || many || !canAddCollider(state.project, source, object), onSelect: () => addCollider(state, holder.id, id), }, { label: 'Add rigidbody', disabled: !object || !source || many || !canAddRigidbody(state.project, source, object), onSelect: () => addRigidbody(state, holder.id, id), }, { label: many ? `Create container from ${selected.length} objects` : 'Create container', disabled: !source || selected.some((oid) => isTemplateContent(source, oid)), onSelect: () => createContainer(state, holder.id, selected), }, { label: 'Unpack container', disabled: !instance, onSelect: () => state.execute(new UnpackContainerCommand(holder.id, id)), }, { label: 'Unparent (move to top level)', disabled: !object?.parentId || many || fromTemplate, onSelect: () => state.execute(new SetParentCommand(holder.id, id, '')), }, { label: 'Expand all', onSelect: () => this.expandAll() }, { label: 'Collapse all', onSelect: () => this.collapseAll() }, { label: many ? `Remove ${selected.length} objects from ${inScene ? 'scene' : 'container'}` : `Remove from ${inScene ? 'scene' : 'container'}`, danger: true, disabled: !source || selected.some((oid) => isTemplateContent(source, oid)), onSelect: () => removeObjects(state, holder.id, selected), }, ]); menu.open(x, y); return menu; } 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 holder = this.holder(); if (!holder) return false; let changed = false; for (const id of this.state.selectedObjectIds) { for (const ancestorId of ancestorIdsOf(holder, id)) { if (this.expanded.has(ancestorId)) continue; this.expanded.add(ancestorId); changed = true; } } return changed; } private refresh(): void { const holder = this.holder(); this.shownHolderId = holder?.id ?? ''; const kind = holder?.type === 'container' ? 'Container' : 'Scene'; this.setTitle(holder ? `${kind}: ${holder.name}` : 'Scene objects'); // From the stored holder, not the resolved one: what an object follows depends on where it // lives, and in a resolved view container content looks stored. const source = this.source(); const marks = source ? deviationMarks(this.state.project, source) : new Set(); this.list.setItems( (holder ? visibleTreeOrder(holder, this.expanded) : []).map(({ object, depth, hasChildren }) => { const asset = findAsset(this.state.project, object.assetId); return { id: object.id, label: object.name || asset?.name || '?', icon: asset && ASSET_ICON_SVG[asset.type], iconAtEnd: true, depth, expanded: hasChildren ? this.expanded.has(object.id) : undefined, overridden: marks.has(object.id), }; }), ); this.syncSelection(); this.scroll.relayoutContent(); this.scrollToSelection(); } private syncSelection(): void { const s = this.state.selection; 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); } }