import { newId } from '../ids'; import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy'; import { captureObject, isLiveInstance, type ObjectMemo, type ObjectRef, restoreObject, } from '../model/instance'; import { placementRejection } from '../model/placement'; import { type Position, type PropertyValue, toPosition, type Vec2 } from '../model/Properties'; import type { Project } from '../model/Project'; import { createSceneObject, type SceneObject } from '../model/SceneObject'; import { coerceValue } from '../properties/PropertySchema'; import { type Removed, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; import { requireAsset, requireObject, requireObjectRef, requirePropDef, requireHolder, requireStructuralObject, } from './require'; /** * An edit to one placed object. The base captures the object before the edit and puts it back on * undo, so a subclass only says what changes – and, if it wrote somewhere else too, how to take * that back (`revert`). */ export abstract class ObjectEditCommand implements Command { abstract readonly label: string; private memo: ObjectMemo | undefined; protected constructor( private readonly holderId: string, private readonly objectId: string, ) {} protected abstract apply(ref: ObjectRef, project: Project): void; protected revert(): void {} execute(project: Project): void { const ref = requireObjectRef(project, this.holderId, this.objectId); this.memo = captureObject(ref); this.apply(ref, project); } undo(): void { this.revert(); if (this.memo) restoreObject(this.memo); } } /** * The position is a world position; with a parent it is converted to the parent's local space. * Placing a container adds the container object alone – its content is resolved from the template * (see `model/instance.ts`), so it is never copied into the scene. */ export class PlaceAssetCommand implements Command { readonly label = 'Place asset'; readonly objectId = newId(); constructor( private readonly holderId: string, private readonly assetId: string, private readonly position: Vec2 | Position, private readonly parentId = '', ) {} execute(project: Project): void { const holder = requireHolder(project, this.holderId); const asset = requireAsset(project, this.assetId); const rejection = placementRejection(project, holder, asset); if (rejection) throw new CommandError(rejection); if (this.parentId) requireStructuralObject(project, holder, this.parentId); const local = localPositionFor(holder, this.parentId, toPosition(this.position)); holder.objects.push(createSceneObject(this.objectId, this.assetId, local, this.parentId)); } undo(project: Project): void { const holder = requireHolder(project, this.holderId); holder.objects = holder.objects.filter((o) => o.id !== this.objectId); } } /** Removes objects together with everything parented below them, as one undo step. */ export class RemoveSceneObjectsCommand implements Command { readonly label: string; private removed: Removed[] = []; constructor( private readonly holderId: string, private readonly objectIds: readonly string[], ) { this.label = objectIds.length === 1 ? 'Remove object' : 'Remove objects'; } execute(project: Project): void { const holder = requireHolder(project, this.holderId); for (const id of this.objectIds) requireStructuralObject(project, holder, id); this.removed = removeSubtrees(holder, this.objectIds); } undo(project: Project): void { restoreAt(requireHolder(project, this.holderId).objects, this.removed); } } /** * The previous position is read at execution time, so a caller that previewed the move must * restore the model before executing. */ export class MoveSceneObjectCommand extends ObjectEditCommand { readonly label = 'Move object'; constructor( holderId: string, objectId: string, private readonly position: Position, ) { super(holderId, objectId); } protected override apply(ref: ObjectRef): void { ref.setPosition(this.position); } } /** Editor-only: undoable like any other edit, but it changes nothing the game can see. */ export class SetEditorOffsetCommand extends ObjectEditCommand { readonly label = 'Editor offset'; constructor( holderId: string, objectId: string, private readonly offset: Vec2, ) { super(holderId, objectId); } protected override apply(ref: ObjectRef): void { ref.setEditorOffset(this.offset); } } export class RenameSceneObjectCommand extends ObjectEditCommand { readonly label = 'Rename object'; constructor( holderId: string, objectId: string, private readonly name: string, ) { super(holderId, objectId); } protected override apply(ref: ObjectRef): void { ref.setName(this.name); } } abstract class OverrideCommand extends ObjectEditCommand { protected constructor( holderId: string, objectId: string, protected readonly key: string, readonly label: string, ) { super(holderId, objectId); } protected abstract nextValue(object: SceneObject, project: Project): PropertyValue | undefined; protected override apply(ref: ObjectRef, project: Project): void { ref.setOverride(this.key, this.nextValue(ref.object, project)); } } export class SetOverrideCommand extends OverrideCommand { constructor( holderId: string, objectId: string, key: string, private readonly value: PropertyValue, ) { super(holderId, objectId, key, `Override ${key}`); } protected override nextValue(object: SceneObject, project: Project): PropertyValue { return coerceValue( requirePropDef(project, requireAsset(project, object.assetId), this.key, object), this.value, ); } } export class ClearOverrideCommand extends OverrideCommand { constructor(holderId: string, objectId: string, key: string) { super(holderId, objectId, key, `Reset ${key}`); } // No schema check, unlike setting one: a key the asset no longer declares is exactly what one // wants to be able to clear. protected override nextValue(): undefined { return undefined; } } /** * Moves an object among its siblings. Sibling order is the order the stored list happens to have * (`treeOrder`), so the move is a splice – and because objects sharing a `z` fall back to that * order, it is also how two overlapping objects are put in front of each other. */ export class ReorderObjectCommand implements Command { readonly label = 'Reorder object'; private previousIndex = -1; /** `beforeId` empty means "behind its last sibling". */ constructor( private readonly holderId: string, private readonly objectId: string, private readonly beforeId: string, ) {} execute(project: Project): void { const holder = requireHolder(project, this.holderId); const object = requireStructuralObject(project, holder, this.objectId); if (this.beforeId === this.objectId) throw new CommandError('An object cannot be moved in front of itself'); let at = holder.objects.length; if (this.beforeId) { const before = requireStructuralObject(project, holder, this.beforeId); if (before.parentId !== object.parentId) throw new CommandError('An object can only be reordered among its own siblings'); at = holder.objects.indexOf(before); } this.previousIndex = holder.objects.indexOf(object); holder.objects.splice(this.previousIndex, 1); // The index was read before the removal, so anything behind it has moved up by one. holder.objects.splice(at > this.previousIndex ? at - 1 : at, 0, object); } undo(project: Project): void { const holder = requireHolder(project, this.holderId); const object = requireObject(project, this.holderId, this.objectId); holder.objects.splice(holder.objects.indexOf(object), 1); holder.objects.splice(this.previousIndex, 0, object); } } /** * Re-parents an object ('' = top level). It keeps its world position: the local one is recomputed * for the new parent. */ export class SetParentCommand implements Command { readonly label = 'Set parent'; private previous: { parentId: string; position: Position } = { parentId: '', position: { x: 0, y: 0, z: 0 }, }; constructor( private readonly holderId: string, private readonly objectId: string, private readonly parentId: string, ) {} execute(project: Project): void { const holder = requireHolder(project, this.holderId); const object = requireStructuralObject(project, holder, this.objectId); if (this.parentId) { if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent'); if (isLiveInstance(project, holder, requireStructuralObject(project, holder, this.parentId))) throw new CommandError('A placed container shows its template; unpack it to put objects inside'); if (isSelfOrAncestor(holder, this.objectId, this.parentId)) throw new CommandError('An object cannot be parented to one of its own children'); } this.previous = { parentId: object.parentId, position: { ...object.position } }; const world = worldPosition(holder, object); object.parentId = this.parentId; object.position = localPositionFor(holder, this.parentId, world); } undo(project: Project): void { const object = requireObject(project, this.holderId, this.objectId); object.parentId = this.previous.parentId; object.position = { ...this.previous.position }; } }