import { describe, expect, it } from "vitest"; import { createEditorStore } from "../../src/app/editor-store"; import { createModelInstance } from "../../src/assets/model-instances"; import { createProjectAssetStorageKey, type ModelAssetRecord } from "../../src/assets/project-assets"; import { createCommitTransformSessionCommand } from "../../src/commands/commit-transform-session-command"; import { createTransformSession, resolveTransformTarget, supportsLocalTransformAxisConstraint, supportsTransformAxisConstraint, supportsTransformOperation } from "../../src/core/transform-session"; import { cloneBoxBrushGeometry, createBoxBrush } from "../../src/document/brushes"; import { createScenePath } from "../../src/document/paths"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { createPlayerStartEntity, createTriggerVolumeEntity } from "../../src/entities/entity-instances"; import { getBoxBrushLocalVertexPosition } from "../../src/geometry/box-brush-mesh"; const modelAsset = { id: "asset-model-transform-fixture", kind: "model", sourceName: "transform-fixture.glb", mimeType: "model/gltf-binary", storageKey: createProjectAssetStorageKey("asset-model-transform-fixture"), byteLength: 64, metadata: { kind: "model", format: "glb", sceneName: "Transform Fixture", nodeCount: 1, meshCount: 1, materialNames: [], textureNames: [], animationNames: [], boundingBox: { min: { x: -0.5, y: 0, z: -0.5 }, max: { x: 0.5, y: 1, z: 0.5 }, size: { x: 1, y: 1, z: 1 } }, warnings: [] } } satisfies ModelAssetRecord; describe("transform session commit commands", () => { it("resolves component transform targets in matching mode and enforces operation support", () => { const brush = createBoxBrush({ id: "brush-main" }); const path = createScenePath({ id: "path-main", points: [ { id: "path-point-a", position: { x: -1, y: 0, z: 0 } }, { id: "path-point-b", position: { x: 2, y: 1, z: 0 } } ] }); const document = { ...createEmptySceneDocument(), brushes: { [brush.id]: brush }, paths: { [path.id]: path } }; const faceWrongModeResolved = resolveTransformTarget(document, { kind: "brushFace", brushId: brush.id, faceId: "posZ" }); const faceResolved = resolveTransformTarget( document, { kind: "brushFace", brushId: brush.id, faceId: "posZ" }, "face" ); const edgeResolved = resolveTransformTarget( document, { kind: "brushEdge", brushId: brush.id, edgeId: "edgeX_posY_negZ" }, "edge" ); const vertexResolved = resolveTransformTarget( document, { kind: "brushVertex", brushId: brush.id, vertexId: "posX_posY_negZ" }, "vertex" ); const faceModeBrushResolved = resolveTransformTarget( document, { kind: "brushes", ids: [brush.id] }, "face" ); const objectResolved = resolveTransformTarget(document, { kind: "brushes", ids: [brush.id] }); const pathObjectResolved = resolveTransformTarget(document, { kind: "paths", ids: [path.id] }); const pathPointResolved = resolveTransformTarget(document, { kind: "pathPoint", pathId: path.id, pointId: path.points[1].id }); expect(faceWrongModeResolved.target).toBeNull(); expect(faceWrongModeResolved.message).toContain("Face mode"); expect(faceResolved.target).toMatchObject({ kind: "brushFace", brushId: brush.id, faceId: "posZ" }); expect(edgeResolved.target).toMatchObject({ kind: "brushEdge", brushId: brush.id, edgeId: "edgeX_posY_negZ" }); expect(vertexResolved.target).toMatchObject({ kind: "brushVertex", brushId: brush.id, vertexId: "posX_posY_negZ" }); expect(faceModeBrushResolved.target).toBeNull(); expect(faceModeBrushResolved.message).toContain("Object mode"); expect(objectResolved.target).toMatchObject({ kind: "brush", brushId: brush.id, initialCenter: brush.center, initialRotationDegrees: brush.rotationDegrees, initialSize: brush.size }); expect(pathObjectResolved.target).toBeNull(); expect(pathObjectResolved.message).toContain("path point"); expect(pathPointResolved.target).toMatchObject({ kind: "pathPoint", pathId: path.id, pointId: path.points[1].id, initialPosition: path.points[1].position }); expect(objectResolved.target).not.toBeNull(); expect( supportsTransformOperation( objectResolved.target as NonNullable, "translate" ) ).toBe(true); expect( supportsTransformOperation( objectResolved.target as NonNullable, "rotate" ) ).toBe(true); expect( supportsTransformOperation( objectResolved.target as NonNullable, "scale" ) ).toBe(true); expect( supportsTransformOperation( faceResolved.target as NonNullable, "translate" ) ).toBe(true); expect( supportsTransformOperation( faceResolved.target as NonNullable, "rotate" ) ).toBe(true); expect( supportsTransformOperation( faceResolved.target as NonNullable, "scale" ) ).toBe(true); expect( supportsTransformOperation( vertexResolved.target as NonNullable, "translate" ) ).toBe(true); expect( supportsTransformOperation( vertexResolved.target as NonNullable, "rotate" ) ).toBe(false); expect( supportsTransformOperation( vertexResolved.target as NonNullable, "scale" ) ).toBe(false); expect( supportsTransformOperation( pathPointResolved.target as NonNullable, "translate" ) ).toBe(true); expect( supportsTransformOperation( pathPointResolved.target as NonNullable, "rotate" ) ).toBe(false); expect( supportsTransformOperation( pathPointResolved.target as NonNullable, "scale" ) ).toBe(false); }); it("applies axis-constraint rules across object and component transform sessions", () => { const brush = createBoxBrush({ id: "brush-axis-rules" }); const document = { ...createEmptySceneDocument(), brushes: { [brush.id]: brush } }; const faceTarget = resolveTransformTarget( document, { kind: "brushFace", brushId: brush.id, faceId: "posX" }, "face" ).target; const edgeTarget = resolveTransformTarget( document, { kind: "brushEdge", brushId: brush.id, edgeId: "edgeY_posX_posZ" }, "edge" ).target; const vertexTarget = resolveTransformTarget( document, { kind: "brushVertex", brushId: brush.id, vertexId: "posX_posY_posZ" }, "vertex" ).target; if (faceTarget === null || faceTarget.kind !== "brushFace") { throw new Error("Expected a face transform target."); } if (edgeTarget === null || edgeTarget.kind !== "brushEdge") { throw new Error("Expected an edge transform target."); } if (vertexTarget === null || vertexTarget.kind !== "brushVertex") { throw new Error("Expected a vertex transform target."); } const faceRotateSession = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "rotate", target: faceTarget }); const edgeScaleSession = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "scale", target: edgeTarget }); const vertexTranslateSession = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "translate", target: vertexTarget }); expect(supportsTransformAxisConstraint(faceRotateSession, "x")).toBe(true); expect(supportsTransformAxisConstraint(faceRotateSession, "y")).toBe(false); expect(supportsTransformAxisConstraint(faceRotateSession, "z")).toBe(false); expect(supportsTransformAxisConstraint(edgeScaleSession, "x")).toBe(true); expect(supportsTransformAxisConstraint(edgeScaleSession, "y")).toBe(false); expect(supportsTransformAxisConstraint(edgeScaleSession, "z")).toBe(true); expect(supportsTransformAxisConstraint(vertexTranslateSession, "x")).toBe( true ); expect(supportsTransformAxisConstraint(vertexTranslateSession, "y")).toBe( true ); expect(supportsTransformAxisConstraint(vertexTranslateSession, "z")).toBe( true ); }); it("only enables local axis toggling on supported transform targets", () => { const brush = createBoxBrush({ id: "brush-local-axis" }); const path = createScenePath({ id: "path-local-axis", points: [ { id: "path-local-point-a", position: { x: -2, y: 0, z: 0 } }, { id: "path-local-point-b", position: { x: 1, y: 0, z: 3 } } ] }); const playerStart = createPlayerStartEntity({ id: "entity-local-axis-player", position: { x: 1, y: 0, z: 1 }, yawDegrees: 45 }); const document = { ...createEmptySceneDocument(), brushes: { [brush.id]: brush }, paths: { [path.id]: path }, entities: { [playerStart.id]: playerStart } }; const brushTarget = resolveTransformTarget(document, { kind: "brushes", ids: [brush.id] }).target; const faceTarget = resolveTransformTarget( document, { kind: "brushFace", brushId: brush.id, faceId: "posX" }, "face" ).target; const entityTarget = resolveTransformTarget(document, { kind: "entities", ids: [playerStart.id] }).target; const pathPointTarget = resolveTransformTarget(document, { kind: "pathPoint", pathId: path.id, pointId: path.points[0].id }).target; if (brushTarget === null || brushTarget.kind !== "brush") { throw new Error("Expected a brush transform target."); } if (faceTarget === null || faceTarget.kind !== "brushFace") { throw new Error("Expected a face transform target."); } if (entityTarget === null || entityTarget.kind !== "entity") { throw new Error("Expected an entity transform target."); } if (pathPointTarget === null || pathPointTarget.kind !== "pathPoint") { throw new Error("Expected a path point transform target."); } const brushTranslateSession = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "translate", target: brushTarget }); const brushScaleSession = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "scale", target: brushTarget }); const faceRotateSession = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "rotate", target: faceTarget }); const entityTranslateSession = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "translate", target: entityTarget }); const pathPointTranslateSession = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "translate", target: pathPointTarget }); expect( supportsLocalTransformAxisConstraint(brushTranslateSession, "z") ).toBe(true); expect(supportsLocalTransformAxisConstraint(brushScaleSession, "z")).toBe( false ); expect(supportsLocalTransformAxisConstraint(faceRotateSession, "x")).toBe( false ); expect( supportsLocalTransformAxisConstraint(entityTranslateSession, "x") ).toBe(true); expect( supportsTransformAxisConstraint(pathPointTranslateSession, "z") ).toBe(true); expect( supportsLocalTransformAxisConstraint(pathPointTranslateSession, "z") ).toBe(false); }); it("commits translated path points through the shared transform command path", () => { const path = createScenePath({ id: "path-transform-main", points: [ { id: "path-transform-point-a", position: { x: -1, y: 0, z: 0 } }, { id: "path-transform-point-b", position: { x: 1, y: 0, z: 1 } } ] }); const store = createEditorStore({ initialDocument: { ...createEmptySceneDocument({ name: "Path Transform Fixture" }), paths: { [path.id]: path } } }); const target = resolveTransformTarget(store.getState().document, { kind: "pathPoint", pathId: path.id, pointId: path.points[1].id }).target; if (target === null || target.kind !== "pathPoint") { throw new Error("Expected a path point transform target."); } const translateSession = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "translate", target }); translateSession.preview = { kind: "pathPoint", position: { x: 4, y: 2, z: -3 } }; store.executeCommand( createCommitTransformSessionCommand( store.getState().document, translateSession ) ); expect( store.getState().document.paths[path.id]?.points[1]?.position ).toEqual({ x: 4, y: 2, z: -3 }); expect(store.getState().selection).toEqual({ kind: "pathPoint", pathId: path.id, pointId: path.points[1].id }); expect(store.undo()).toBe(true); expect( store.getState().document.paths[path.id]?.points[1]?.position ).toEqual(path.points[1].position); expect(store.redo()).toBe(true); expect( store.getState().document.paths[path.id]?.points[1]?.position ).toEqual({ x: 4, y: 2, z: -3 }); }); it("commits whitebox box rotate and scale transforms with undo and redo", () => { const brush = createBoxBrush({ id: "brush-transform-main", center: { x: 1.25, y: 1.5, z: -0.75 }, size: { x: 2.5, y: 2, z: 4 } }); const store = createEditorStore({ initialDocument: { ...createEmptySceneDocument({ name: "Brush Transform Fixture" }), brushes: { [brush.id]: brush } } }); const target = resolveTransformTarget(store.getState().document, { kind: "brushes", ids: [brush.id] }).target; if (target === null || target.kind !== "brush") { throw new Error("Expected a whitebox box transform target."); } const rotateSession = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "rotate", target }); rotateSession.preview = { kind: "brush", center: { ...brush.center }, rotationDegrees: { x: 0, y: 37.5, z: 12.5 }, size: { ...brush.size }, geometry: target.initialGeometry }; store.executeCommand( createCommitTransformSessionCommand( store.getState().document, rotateSession ) ); expect(store.getState().document.brushes[brush.id].rotationDegrees).toEqual( { x: 0, y: 37.5, z: 12.5 } ); const scaleTarget = resolveTransformTarget(store.getState().document, { kind: "brushes", ids: [brush.id] }).target; if (scaleTarget === null || scaleTarget.kind !== "brush") { throw new Error( "Expected a whitebox box transform target after rotation." ); } const scaleSession = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "scale", target: scaleTarget }); scaleSession.preview = { kind: "brush", center: { ...brush.center }, rotationDegrees: { x: 0, y: 37.5, z: 12.5 }, size: { x: 3.25, y: 1.75, z: 5.5 }, geometry: scaleTarget.initialGeometry }; store.executeCommand( createCommitTransformSessionCommand( store.getState().document, scaleSession ) ); expect(store.getState().document.brushes[brush.id]).toMatchObject({ rotationDegrees: { x: 0, y: 37.5, z: 12.5 }, size: { x: 3.25, y: 1.75, z: 5.5 } }); expect(store.undo()).toBe(true); expect(store.getState().document.brushes[brush.id]).toMatchObject({ rotationDegrees: { x: 0, y: 37.5, z: 12.5 }, size: { x: 2.5, y: 2, z: 4 } }); expect(store.undo()).toBe(true); expect(store.getState().document.brushes[brush.id]).toEqual(brush); expect(store.redo()).toBe(true); expect(store.redo()).toBe(true); expect(store.getState().document.brushes[brush.id]).toMatchObject({ rotationDegrees: { x: 0, y: 37.5, z: 12.5 }, size: { x: 3.25, y: 1.75, z: 5.5 } }); }); it("commits a face transform preview and restores it through undo/redo", () => { const brush = createBoxBrush({ id: "brush-face-transform", center: { x: 0, y: 1, z: 0 }, size: { x: 2, y: 2, z: 2 } }); const store = createEditorStore({ initialDocument: { ...createEmptySceneDocument({ name: "Face Transform Fixture" }), brushes: { [brush.id]: brush } } }); const target = resolveTransformTarget( store.getState().document, { kind: "brushFace", brushId: brush.id, faceId: "posX" }, "face" ).target; if (target === null || target.kind !== "brushFace") { throw new Error("Expected a whitebox face transform target."); } const session = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "translate", target }); session.preview = { kind: "brush", center: { x: 0.5, y: 1, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 3, y: 2, z: 2 }, geometry: createBoxBrush({ size: { x: 3, y: 2, z: 2 } }).geometry }; store.executeCommand( createCommitTransformSessionCommand(store.getState().document, session) ); expect(store.getState().document.brushes[brush.id]).toMatchObject({ center: { x: 0.5, y: 1, z: 0 }, size: { x: 3, y: 2, z: 2 } }); expect(store.getState().selection).toEqual({ kind: "brushFace", brushId: brush.id, faceId: "posX" }); expect(store.undo()).toBe(true); expect(store.getState().document.brushes[brush.id]).toEqual(brush); expect(store.redo()).toBe(true); expect(store.getState().document.brushes[brush.id]).toMatchObject({ center: { x: 0.5, y: 1, z: 0 }, size: { x: 3, y: 2, z: 2 } }); }); it("commits a vertex transform preview and preserves vertex selection through undo/redo", () => { const brush = createBoxBrush({ id: "brush-vertex-transform", center: { x: 0, y: 1, z: 0 }, size: { x: 2, y: 2, z: 2 } }); const store = createEditorStore({ initialDocument: { ...createEmptySceneDocument({ name: "Vertex Transform Fixture" }), brushes: { [brush.id]: brush } } }); const target = resolveTransformTarget( store.getState().document, { kind: "brushVertex", brushId: brush.id, vertexId: "posX_posY_posZ" }, "vertex" ).target; if (target === null || target.kind !== "brushVertex") { throw new Error("Expected a whitebox vertex transform target."); } const session = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "translate", target }); session.preview = { kind: "brush", center: { x: 0.5, y: 1.5, z: 0.5 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 3, y: 3, z: 3 }, geometry: createBoxBrush({ size: { x: 3, y: 3, z: 3 } }).geometry }; store.executeCommand( createCommitTransformSessionCommand(store.getState().document, session) ); expect(store.getState().document.brushes[brush.id]).toMatchObject({ center: { x: 0.5, y: 1.5, z: 0.5 }, size: { x: 3, y: 3, z: 3 } }); expect(store.getState().selection).toEqual({ kind: "brushVertex", brushId: brush.id, vertexId: "posX_posY_posZ" }); expect(store.undo()).toBe(true); expect(store.getState().document.brushes[brush.id]).toEqual(brush); expect(store.redo()).toBe(true); expect(store.getState().selection).toEqual({ kind: "brushVertex", brushId: brush.id, vertexId: "posX_posY_posZ" }); }); it("commits deformed vertex geometry without forcing all vertices onto box extents", () => { const brush = createBoxBrush({ id: "brush-vertex-deform", center: { x: 0, y: 0, z: 0 }, size: { x: 2, y: 2, z: 2 } }); const store = createEditorStore({ initialDocument: { ...createEmptySceneDocument({ name: "Vertex Deform Fixture" }), brushes: { [brush.id]: brush } } }); const target = resolveTransformTarget( store.getState().document, { kind: "brushVertex", brushId: brush.id, vertexId: "posX_posY_posZ" }, "vertex" ).target; if (target === null || target.kind !== "brushVertex") { throw new Error("Expected a whitebox vertex transform target."); } const deformedGeometry = cloneBoxBrushGeometry( target.initialGeometry as ReturnType ); deformedGeometry.vertices.posX_posY_posZ = { ...deformedGeometry.vertices.posX_posY_posZ, x: deformedGeometry.vertices.posX_posY_posZ.x + 1 }; const session = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "translate", target }); session.preview = { kind: "brush", center: { ...target.initialCenter }, rotationDegrees: { ...target.initialRotationDegrees }, size: { ...target.initialSize }, geometry: deformedGeometry }; store.executeCommand( createCommitTransformSessionCommand(store.getState().document, session) ); const committedBrush = store.getState().document.brushes[brush.id]; expect( getBoxBrushLocalVertexPosition(committedBrush, "posX_posY_posZ").x ).toBe(2); expect( getBoxBrushLocalVertexPosition(committedBrush, "posX_posY_negZ").x ).toBe(1); }); it("commits a model instance translate/rotate/scale transform with undo and redo", () => { const modelInstance = createModelInstance({ id: "model-instance-main", assetId: modelAsset.id, position: { x: 0, y: 1, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 } }); const store = createEditorStore({ initialDocument: { ...createEmptySceneDocument({ name: "Transform Fixture" }), assets: { [modelAsset.id]: modelAsset }, modelInstances: { [modelInstance.id]: modelInstance } } }); const target = resolveTransformTarget(store.getState().document, { kind: "modelInstances", ids: [modelInstance.id] }).target; if (target === null || target.kind !== "modelInstance") { throw new Error("Expected a model instance transform target."); } const session = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "scale", target }); session.preview = { kind: "modelInstance", position: { x: 4, y: 1, z: -2 }, rotationDegrees: { x: 0, y: 90, z: 0 }, scale: { x: 1.5, y: 2, z: 1.5 } }; store.executeCommand( createCommitTransformSessionCommand(store.getState().document, session) ); expect( store.getState().document.modelInstances[modelInstance.id] ).toMatchObject({ position: { x: 4, y: 1, z: -2 }, rotationDegrees: { x: 0, y: 90, z: 0 }, scale: { x: 1.5, y: 2, z: 1.5 } }); expect(store.undo()).toBe(true); expect(store.getState().document.modelInstances[modelInstance.id]).toEqual( modelInstance ); expect(store.redo()).toBe(true); expect( store.getState().document.modelInstances[modelInstance.id] ).toMatchObject({ position: { x: 4, y: 1, z: -2 }, rotationDegrees: { x: 0, y: 90, z: 0 }, scale: { x: 1.5, y: 2, z: 1.5 } }); }); it("commits a rotatable entity transform with undo and redo", () => { const playerStart = createPlayerStartEntity({ id: "entity-player-start-main", position: { x: 0, y: 0, z: 0 }, yawDegrees: 0 }); const store = createEditorStore({ initialDocument: { ...createEmptySceneDocument({ name: "Entity Transform Fixture" }), entities: { [playerStart.id]: playerStart } } }); const target = resolveTransformTarget(store.getState().document, { kind: "entities", ids: [playerStart.id] }).target; if (target === null || target.kind !== "entity") { throw new Error("Expected an entity transform target."); } const session = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "rotate", target }); session.preview = { kind: "entity", position: { x: 6, y: 0, z: -4 }, rotation: { kind: "yaw", yawDegrees: 90 } }; store.executeCommand( createCommitTransformSessionCommand(store.getState().document, session) ); expect(store.getState().document.entities[playerStart.id]).toMatchObject({ position: { x: 6, y: 0, z: -4 }, yawDegrees: 90 }); expect(store.undo()).toBe(true); expect(store.getState().document.entities[playerStart.id]).toEqual( playerStart ); expect(store.redo()).toBe(true); expect(store.getState().document.entities[playerStart.id]).toMatchObject({ position: { x: 6, y: 0, z: -4 }, yawDegrees: 90 }); }); it("resolves same-kind multi targets with averaged pivots and deterministic active ids", () => { const brushA = createBoxBrush({ id: "brush-multi-a", center: { x: -2, y: 1, z: 4 } }); const brushB = createBoxBrush({ id: "brush-multi-b", center: { x: 4, y: 3, z: -2 } }); const modelInstanceA = createModelInstance({ id: "model-multi-a", assetId: modelAsset.id, position: { x: -3, y: 0, z: 1 } }); const modelInstanceB = createModelInstance({ id: "model-multi-b", assetId: modelAsset.id, position: { x: 5, y: 2, z: -1 } }); const entityA = createPlayerStartEntity({ id: "entity-multi-a", position: { x: -4, y: 0, z: 0 } }); const entityB = createPlayerStartEntity({ id: "entity-multi-b", position: { x: 2, y: 0, z: 6 } }); const document = { ...createEmptySceneDocument(), assets: { [modelAsset.id]: modelAsset }, brushes: { [brushA.id]: brushA, [brushB.id]: brushB }, entities: { [entityA.id]: entityA, [entityB.id]: entityB }, modelInstances: { [modelInstanceA.id]: modelInstanceA, [modelInstanceB.id]: modelInstanceB } }; const brushTarget = resolveTransformTarget( document, { kind: "brushes", ids: [brushA.id, brushB.id] }, "object", brushA.id ).target; const modelTarget = resolveTransformTarget( document, { kind: "modelInstances", ids: [modelInstanceA.id, modelInstanceB.id] }, "object", modelInstanceA.id ).target; const entityTarget = resolveTransformTarget( document, { kind: "entities", ids: [entityA.id, entityB.id] }, "object", entityA.id ).target; expect(brushTarget).toMatchObject({ kind: "brushes", activeBrushId: brushA.id, initialPivot: { x: 1, y: 2, z: 1 } }); expect(modelTarget).toMatchObject({ kind: "modelInstances", activeModelInstanceId: modelInstanceA.id, initialPivot: { x: 1, y: 1, z: 0 } }); expect(entityTarget).toMatchObject({ kind: "entities", activeEntityId: entityA.id, initialPivot: { x: -1, y: 0, z: 3 } }); }); it("commits a multi-brush translate transform in one undoable command", () => { const brushA = createBoxBrush({ id: "brush-batch-translate-a", center: { x: 0, y: 1, z: 0 } }); const brushB = createBoxBrush({ id: "brush-batch-translate-b", center: { x: 6, y: 2, z: -4 } }); const store = createEditorStore({ initialDocument: { ...createEmptySceneDocument({ name: "Multi Brush Translate" }), brushes: { [brushA.id]: brushA, [brushB.id]: brushB } } }); const selection = { kind: "brushes" as const, ids: [brushA.id, brushB.id] }; const target = resolveTransformTarget( store.getState().document, selection ).target; if (target === null || target.kind !== "brushes") { throw new Error("Expected a multi-brush transform target."); } store.setSelection(selection); const session = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "translate", target }); session.preview = { kind: "brushes", pivot: { x: target.initialPivot.x + 2, y: target.initialPivot.y, z: target.initialPivot.z - 1 }, items: target.items.map((item) => ({ brushId: item.brushId, center: { x: item.initialCenter.x + 2, y: item.initialCenter.y, z: item.initialCenter.z - 1 }, rotationDegrees: { ...item.initialRotationDegrees }, size: { ...item.initialSize }, geometry: cloneBoxBrushGeometry( item.initialGeometry as ReturnType ) })) }; store.executeCommand( createCommitTransformSessionCommand(store.getState().document, session) ); expect(store.getState().document.brushes[brushA.id].center).toEqual({ x: 2, y: 1, z: -1 }); expect(store.getState().document.brushes[brushB.id].center).toEqual({ x: 8, y: 2, z: -5 }); expect(store.getState().selection).toEqual(selection); expect(store.undo()).toBe(true); expect(store.getState().document.brushes[brushA.id].center).toEqual( brushA.center ); expect(store.getState().document.brushes[brushB.id].center).toEqual( brushB.center ); expect(store.redo()).toBe(true); expect(store.getState().document.brushes[brushA.id].center).toEqual({ x: 2, y: 1, z: -1 }); expect(store.getState().document.brushes[brushB.id].center).toEqual({ x: 8, y: 2, z: -5 }); }); it("commits a multi-model-instance scale transform with undo and redo", () => { const modelInstanceA = createModelInstance({ id: "model-batch-scale-a", assetId: modelAsset.id, position: { x: -1, y: 0, z: 0 } }); const modelInstanceB = createModelInstance({ id: "model-batch-scale-b", assetId: modelAsset.id, position: { x: 1, y: 0, z: 0 } }); const store = createEditorStore({ initialDocument: { ...createEmptySceneDocument({ name: "Multi Model Scale" }), assets: { [modelAsset.id]: modelAsset }, modelInstances: { [modelInstanceA.id]: modelInstanceA, [modelInstanceB.id]: modelInstanceB } } }); const selection = { kind: "modelInstances" as const, ids: [modelInstanceA.id, modelInstanceB.id] }; const target = resolveTransformTarget( store.getState().document, selection ).target; if (target === null || target.kind !== "modelInstances") { throw new Error("Expected a multi-model transform target."); } store.setSelection(selection); const session = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "scale", target }); session.preview = { kind: "modelInstances", pivot: { ...target.initialPivot }, items: [ { modelInstanceId: modelInstanceA.id, position: { x: -2, y: 0, z: 0 }, rotationDegrees: { ...modelInstanceA.rotationDegrees }, scale: { x: 2, y: 2, z: 2 } }, { modelInstanceId: modelInstanceB.id, position: { x: 2, y: 0, z: 0 }, rotationDegrees: { ...modelInstanceB.rotationDegrees }, scale: { x: 2, y: 2, z: 2 } } ] }; store.executeCommand( createCommitTransformSessionCommand(store.getState().document, session) ); expect(store.getState().document.modelInstances[modelInstanceA.id]).toMatchObject({ position: { x: -2, y: 0, z: 0 }, scale: { x: 2, y: 2, z: 2 } }); expect(store.getState().document.modelInstances[modelInstanceB.id]).toMatchObject({ position: { x: 2, y: 0, z: 0 }, scale: { x: 2, y: 2, z: 2 } }); expect(store.undo()).toBe(true); expect(store.getState().document.modelInstances[modelInstanceA.id]).toEqual( modelInstanceA ); expect(store.getState().document.modelInstances[modelInstanceB.id]).toEqual( modelInstanceB ); expect(store.redo()).toBe(true); expect(store.getState().document.modelInstances[modelInstanceA.id]).toMatchObject({ position: { x: -2, y: 0, z: 0 }, scale: { x: 2, y: 2, z: 2 } }); }); it("commits a multi-entity rotate transform when the selected kinds support it", () => { const entityA = createPlayerStartEntity({ id: "entity-batch-rotate-a", position: { x: -2, y: 0, z: 0 }, yawDegrees: 0 }); const entityB = createPlayerStartEntity({ id: "entity-batch-rotate-b", position: { x: 2, y: 0, z: 0 }, yawDegrees: 0 }); const store = createEditorStore({ initialDocument: { ...createEmptySceneDocument({ name: "Multi Entity Rotate" }), entities: { [entityA.id]: entityA, [entityB.id]: entityB } } }); const selection = { kind: "entities" as const, ids: [entityA.id, entityB.id] }; const target = resolveTransformTarget( store.getState().document, selection ).target; if (target === null || target.kind !== "entities") { throw new Error("Expected a multi-entity transform target."); } expect(supportsTransformOperation(target, "rotate")).toBe(true); store.setSelection(selection); const session = createTransformSession({ source: "keyboard", sourcePanelId: "topLeft", operation: "rotate", target }); session.preview = { kind: "entities", pivot: { ...target.initialPivot }, items: [ { entityId: entityA.id, position: { x: 0, y: 0, z: 2 }, rotation: { kind: "yaw", yawDegrees: 90 } }, { entityId: entityB.id, position: { x: 0, y: 0, z: -2 }, rotation: { kind: "yaw", yawDegrees: 90 } } ] }; store.executeCommand( createCommitTransformSessionCommand(store.getState().document, session) ); expect(store.getState().document.entities[entityA.id]).toMatchObject({ position: { x: 0, y: 0, z: 2 }, yawDegrees: 90 }); expect(store.getState().document.entities[entityB.id]).toMatchObject({ position: { x: 0, y: 0, z: -2 }, yawDegrees: 90 }); expect(store.undo()).toBe(true); expect(store.getState().document.entities[entityA.id]).toEqual(entityA); expect(store.getState().document.entities[entityB.id]).toEqual(entityB); }); it("disables unsupported mixed-capability entity rotation for batch selections", () => { const playerStart = createPlayerStartEntity({ id: "entity-mixed-player-start" }); const triggerVolume = createTriggerVolumeEntity({ id: "entity-mixed-trigger-volume" }); const document = { ...createEmptySceneDocument(), entities: { [playerStart.id]: playerStart, [triggerVolume.id]: triggerVolume } }; const target = resolveTransformTarget(document, { kind: "entities", ids: [playerStart.id, triggerVolume.id] }).target; if (target === null || target.kind !== "entities") { throw new Error("Expected a mixed entity batch target."); } expect(supportsTransformOperation(target, "translate")).toBe(true); expect(supportsTransformOperation(target, "rotate")).toBe(false); expect(supportsTransformOperation(target, "scale")).toBe(false); }); });