import { colliderFit, FitColliderCommand } from '../core/commands/colliderCommands'; import { UnpackContainerCommand } from '../core/commands/containerCommands'; import { SetParentCommand } from '../core/commands/sceneObjectCommands'; import { findObject } from '../core/model/hierarchy'; import { isLiveInstance, isTemplateContent } from '../core/model/instance'; import type { PropertyBag, Vec2 } from '../core/model/Properties'; import { findAsset, type ObjectHolder } from '../core/model/Project'; import { type PropertyDef, schemaForObject } from '../core/properties/PropertySchema'; import { referencesOf } from '../core/properties/references'; import { resolveSceneObjectProps } from '../core/properties/resolve'; import { Menu, type MenuItem } from '../ui'; import { activateAsset, addCollider, addRigidbody, canAddCollider, canAddRigidbody, createContainer, removeObjects, } from './actions'; import { hasAssetEditor } from './assetTabs'; import { canPasteObjects, copyObjectIds, pasteObjects } from './clipboard'; import { groupMenuItems } from './objectGroups'; import type { EditorState } from './state'; /** * The menu of a placed object, the same from the hierarchy and the scene view (#204). `source` is * the stored holder, `holder` the one with container content resolved in; `extras` go above * removing, where only the hierarchy has anything to add. `pasteAt` is where a paste lands in the * world – the spot clicked in a view, none from the hierarchy. */ export function openObjectMenu( state: EditorState, source: ObjectHolder, holder: ObjectHolder, id: string, x: number, y: number, extras: readonly MenuItem[] = [], pasteAt: Vec2 | null = null, ): Menu { 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 fromTemplate = isTemplateContent(source, id); const instance = Boolean(object && 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), }, ...(object && asset ? openReferenceItems( state, schemaForObject(state.project, asset, object), resolveSceneObjectProps(state.project, asset, object), ) : []), { separator: true }, { label: many ? `Copy ${selected.length} objects` : 'Copy', onSelect: () => copyObjectIds(state, holder.id, selected), }, { label: 'Paste', disabled: !canPasteObjects(state), onSelect: () => pasteObjects(state, holder.id, pasteAt, many ? '' : id), }, { separator: true }, { label: 'Add collider…', disabled: !object || many || !canAddCollider(state.project, source, object), onSelect: () => openPartMenu(state, 'collider', holder, id, x, y), }, { label: 'Add rigidbody…', disabled: !object || many || !canAddRigidbody(state.project, source, object), onSelect: () => openPartMenu(state, 'rigidbody', holder, id, x, y), }, ...(object && asset?.type === 'collider' ? [ { label: 'Fit to sprite', disabled: many || typeof colliderFit(state.project, source, object) === 'string', onSelect: () => state.execute(new FitColliderCommand(holder.id, id)), }, ] : []), { label: many ? `Create container from ${selected.length} objects` : 'Create container', disabled: 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, '')), }, ...groupMenuItems(state, source, selected), ...extras, { label: many ? `Remove ${selected.length} objects from ${inScene ? 'scene' : 'container'}` : `Remove from ${inScene ? 'scene' : 'container'}`, danger: true, disabled: selected.some((oid) => isTemplateContent(source, oid)), onSelect: () => removeObjects(state, holder.id, selected), }, ]); menu.open(x, y); return menu; } /** * Which collider or rigidbody to add (#222): a new one, or any the project has, found by typing. One * the object wears already is greyed out – a second copy of it would only double up. */ function openPartMenu( state: EditorState, type: 'collider' | 'rigidbody', holder: ObjectHolder, id: string, x: number, y: number, ): Menu { const add = type === 'collider' ? addCollider : addRigidbody; const worn = new Set(holder.objects.filter((o) => o.parentId === id).map((o) => o.assetId)); const existing = state.project.assets .filter((a) => a.type === type) .sort((a, b) => a.name.localeCompare(b.name)); const menu = new Menu( [ { label: `Create new ${type}`, onSelect: () => add(state, holder.id, id) }, { separator: true }, ...existing.map((a) => ({ label: a.name, disabled: worn.has(a.id), onSelect: () => add(state, holder.id, id, a.id), })), ], { searchable: true }, ); menu.open(x, y); return menu; } /** * "Open texture", "Open shader", … – one entry per asset the values point at (#221), skipping kinds * that have no tab to open. The asset panel offers the same for an asset's own values. */ export function openReferenceItems( state: EditorState, schema: readonly PropertyDef[], values: PropertyBag, ): MenuItem[] { return referencesOf(state.project, schema, values).flatMap(({ label, assetId }) => { const target = findAsset(state.project, assetId); if (!target || !hasAssetEditor(target.type)) return []; return [ { label: `Open ${label.charAt(0).toLowerCase()}${label.slice(1)}`, onSelect: () => activateAsset(state, assetId), }, ]; }); }