From cbf7e16dd48466e30c139dd447340a1aecc78190 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 31 Mar 2026 02:39:12 +0200 Subject: [PATCH] Add tests for box brush face editing and update existing tests --- .../box-brush-face-editing.command.test.ts | 99 +++++++++++++++++ tests/domain/create-box-brush.command.test.ts | 16 +++ .../create-empty-scene-document.test.ts | 2 + tests/geometry/box-face-uvs.test.ts | 78 ++++++++++++++ .../serialization/scene-document-json.test.ts | 100 +++++++++++++++++- 5 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 tests/domain/box-brush-face-editing.command.test.ts create mode 100644 tests/geometry/box-face-uvs.test.ts diff --git a/tests/domain/box-brush-face-editing.command.test.ts b/tests/domain/box-brush-face-editing.command.test.ts new file mode 100644 index 00000000..8db24737 --- /dev/null +++ b/tests/domain/box-brush-face-editing.command.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { createEditorStore } from "../../src/app/editor-store"; +import { createCreateBoxBrushCommand } from "../../src/commands/create-box-brush-command"; +import { createSetBoxBrushFaceMaterialCommand } from "../../src/commands/set-box-brush-face-material-command"; +import { createSetBoxBrushFaceUvStateCommand } from "../../src/commands/set-box-brush-face-uv-state-command"; + +describe("box brush face editing commands", () => { + it("applies a material to one box face and supports undo/redo", () => { + const store = createEditorStore(); + + store.executeCommand(createCreateBoxBrushCommand()); + + const createdBrush = Object.values(store.getState().document.brushes)[0]; + + store.executeCommand( + createSetBoxBrushFaceMaterialCommand({ + brushId: createdBrush.id, + faceId: "posZ", + materialId: "starter-amber-grid" + }) + ); + + expect(store.getState().document.brushes[createdBrush.id].faces.posZ.materialId).toBe("starter-amber-grid"); + expect(store.getState().selection).toEqual({ + kind: "brushFace", + brushId: createdBrush.id, + faceId: "posZ" + }); + + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].faces.posZ.materialId).toBeNull(); + + expect(store.redo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].faces.posZ.materialId).toBe("starter-amber-grid"); + }); + + it("updates face UV state through an undoable command", () => { + const store = createEditorStore(); + + store.executeCommand(createCreateBoxBrushCommand()); + + const createdBrush = Object.values(store.getState().document.brushes)[0]; + + store.executeCommand( + createSetBoxBrushFaceUvStateCommand({ + brushId: createdBrush.id, + faceId: "posY", + uvState: { + offset: { + x: 0.5, + y: -0.25 + }, + scale: { + x: 0.25, + y: 0.5 + }, + rotationQuarterTurns: 1, + flipU: true, + flipV: false + }, + label: "Adjust top face UVs" + }) + ); + + expect(store.getState().document.brushes[createdBrush.id].faces.posY.uv).toEqual({ + offset: { + x: 0.5, + y: -0.25 + }, + scale: { + x: 0.25, + y: 0.5 + }, + rotationQuarterTurns: 1, + flipU: true, + flipV: false + }); + + expect(store.undo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].faces.posY.uv).toEqual({ + offset: { + x: 0, + y: 0 + }, + scale: { + x: 1, + y: 1 + }, + rotationQuarterTurns: 0, + flipU: false, + flipV: false + }); + + expect(store.redo()).toBe(true); + expect(store.getState().document.brushes[createdBrush.id].faces.posY.uv.rotationQuarterTurns).toBe(1); + expect(store.getState().document.brushes[createdBrush.id].faces.posY.uv.flipU).toBe(true); + }); +}); diff --git a/tests/domain/create-box-brush.command.test.ts b/tests/domain/create-box-brush.command.test.ts index 58816d3b..c6347031 100644 --- a/tests/domain/create-box-brush.command.test.ts +++ b/tests/domain/create-box-brush.command.test.ts @@ -39,6 +39,22 @@ describe("box brush commands", () => { z: 4 }); expect(Object.keys(brush.faces)).toEqual(["posX", "negX", "posY", "negY", "posZ", "negZ"]); + expect(brush.faces.posX).toEqual({ + materialId: null, + uv: { + offset: { + x: 0, + y: 0 + }, + scale: { + x: 1, + y: 1 + }, + rotationQuarterTurns: 0, + flipU: false, + flipV: false + } + }); expect(store.getState().selection).toEqual({ kind: "brushes", ids: [brush.id] diff --git a/tests/domain/create-empty-scene-document.test.ts b/tests/domain/create-empty-scene-document.test.ts index 756da148..f50f9bc6 100644 --- a/tests/domain/create-empty-scene-document.test.ts +++ b/tests/domain/create-empty-scene-document.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { SCENE_DOCUMENT_VERSION, createEmptySceneDocument } from "../../src/document/scene-document"; +import { STARTER_MATERIAL_LIBRARY } from "../../src/materials/starter-material-library"; describe("createEmptySceneDocument", () => { it("creates a versioned empty scene document", () => { @@ -11,5 +12,6 @@ describe("createEmptySceneDocument", () => { expect(document.brushes).toEqual({}); expect(document.entities).toEqual({}); expect(document.modelInstances).toEqual({}); + expect(Object.keys(document.materials)).toEqual(STARTER_MATERIAL_LIBRARY.map((material) => material.id)); }); }); diff --git a/tests/geometry/box-face-uvs.test.ts b/tests/geometry/box-face-uvs.test.ts new file mode 100644 index 00000000..b5f54bce --- /dev/null +++ b/tests/geometry/box-face-uvs.test.ts @@ -0,0 +1,78 @@ +import { BoxGeometry } from "three"; +import { describe, expect, it } from "vitest"; + +import { createBoxBrush } from "../../src/document/brushes"; +import { applyBoxBrushFaceUvsToGeometry, createFitToFaceBoxBrushFaceUvState, transformProjectedFaceUv } from "../../src/geometry/box-face-uvs"; + +describe("box face UV projection", () => { + it("fit-to-face produces finite UVs normalized across the target face", () => { + const brush = createBoxBrush({ + size: { + x: 4, + y: 2, + z: 6 + } + }); + + brush.faces.posZ.uv = createFitToFaceBoxBrushFaceUvState(brush, "posZ"); + + const geometry = new BoxGeometry(brush.size.x, brush.size.y, brush.size.z); + applyBoxBrushFaceUvsToGeometry(geometry, brush); + + const uvAttribute = geometry.getAttribute("uv"); + const indexAttribute = geometry.getIndex(); + const posZGroup = geometry.groups.find((group) => group.materialIndex === 4); + + expect(indexAttribute).not.toBeNull(); + expect(posZGroup).toBeDefined(); + + const uniqueVertexIndices = new Set(); + + for (let indexOffset = posZGroup!.start; indexOffset < posZGroup!.start + posZGroup!.count; indexOffset += 1) { + uniqueVertexIndices.add(indexAttribute!.getX(indexOffset)); + } + + const uvValues = Array.from(uniqueVertexIndices, (vertexIndex) => ({ + u: uvAttribute.getX(vertexIndex), + v: uvAttribute.getY(vertexIndex) + })); + + expect(uvValues).toHaveLength(4); + expect(uvValues.every((uv) => Number.isFinite(uv.u) && Number.isFinite(uv.v))).toBe(true); + expect(Math.min(...uvValues.map((uv) => uv.u))).toBeCloseTo(0); + expect(Math.max(...uvValues.map((uv) => uv.u))).toBeCloseTo(1); + expect(Math.min(...uvValues.map((uv) => uv.v))).toBeCloseTo(0); + expect(Math.max(...uvValues.map((uv) => uv.v))).toBeCloseTo(1); + }); + + it("applies rotation, scale, and offset deterministically to projected UVs", () => { + const transformedUv = transformProjectedFaceUv( + { + x: 4, + y: 0 + }, + { + x: 4, + y: 2 + }, + { + offset: { + x: 0.5, + y: -0.25 + }, + scale: { + x: 0.5, + y: 1 + }, + rotationQuarterTurns: 1, + flipU: true, + flipV: false + } + ); + + expect(transformedUv).toEqual({ + x: expect.closeTo(1.5), + y: expect.closeTo(1.75) + }); + }); +}); diff --git a/tests/serialization/scene-document-json.test.ts b/tests/serialization/scene-document-json.test.ts index 7ce35212..8fc7cb7b 100644 --- a/tests/serialization/scene-document-json.test.ts +++ b/tests/serialization/scene-document-json.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { createBoxBrush } from "../../src/document/brushes"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { migrateSceneDocument } from "../../src/document/migrate-scene-document"; +import { STARTER_MATERIAL_LIBRARY } from "../../src/materials/starter-material-library"; import { parseSceneDocumentJson, serializeSceneDocument } from "../../src/serialization/scene-document-json"; describe("scene document JSON", () => { @@ -37,12 +38,51 @@ describe("scene document JSON", () => { expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); }); + it("round-trips per-face material and UV state", () => { + const brush = createBoxBrush({ + id: "brush-face-room", + center: { + x: 2, + y: 2, + z: -1 + }, + size: { + x: 6, + y: 4, + z: 8 + } + }); + + brush.faces.posX.materialId = "starter-amber-grid"; + brush.faces.posX.uv = { + offset: { + x: 0.5, + y: -0.25 + }, + scale: { + x: 0.25, + y: 0.5 + }, + rotationQuarterTurns: 3, + flipU: true, + flipV: true + }; + + const document = { + ...createEmptySceneDocument({ name: "Face UV Scene" }), + brushes: { + [brush.id]: brush + } + }; + + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("migrates the foundation schema to the current schema version", () => { const migratedDocument = migrateSceneDocument({ version: 1, name: "Foundation Scene", world: createEmptySceneDocument().world, - materials: {}, textures: {}, assets: {}, brushes: {}, @@ -51,9 +91,64 @@ describe("scene document JSON", () => { interactionLinks: {} }); - expect(migratedDocument.version).toBe(2); + expect(migratedDocument.version).toBe(3); expect(migratedDocument.brushes).toEqual({}); expect(migratedDocument.name).toBe("Foundation Scene"); + expect(Object.keys(migratedDocument.materials)).toEqual(STARTER_MATERIAL_LIBRARY.map((material) => material.id)); + }); + + it("migrates slice 1.1 box brushes to explicit per-face UV state", () => { + const migratedDocument = migrateSceneDocument({ + version: 2, + name: "Legacy Brush Scene", + world: createEmptySceneDocument().world, + materials: {}, + textures: {}, + assets: {}, + brushes: { + "brush-legacy": { + id: "brush-legacy", + kind: "box", + center: { + x: 0, + y: 1, + z: 0 + }, + size: { + x: 4, + y: 2, + z: 6 + }, + faces: { + posX: { materialId: null }, + negX: { materialId: null }, + posY: { materialId: null }, + negY: { materialId: null }, + posZ: { materialId: "starter-amber-grid" }, + negZ: { materialId: null } + } + } + }, + modelInstances: {}, + entities: {}, + interactionLinks: {} + }); + + expect(migratedDocument.version).toBe(3); + expect(migratedDocument.brushes["brush-legacy"].faces.posZ.materialId).toBe("starter-amber-grid"); + expect(migratedDocument.brushes["brush-legacy"].faces.posZ.uv).toEqual({ + offset: { + x: 0, + y: 0 + }, + scale: { + x: 1, + y: 1 + }, + rotationQuarterTurns: 0, + flipU: false, + flipV: false + }); }); it("rejects unsupported versions", () => { @@ -62,7 +157,6 @@ describe("scene document JSON", () => { version: 99, name: "Legacy", world: {}, - materials: {}, textures: {}, assets: {}, brushes: {},