diff --git a/tests/domain/build-runtime-scene.test.ts b/tests/domain/build-runtime-scene.test.ts index a43bf0db..7545f73c 100644 --- a/tests/domain/build-runtime-scene.test.ts +++ b/tests/domain/build-runtime-scene.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { BoxGeometry } from "three"; +import { BoxGeometry, PlaneGeometry } from "three"; import { createBoxBrush } from "../../src/document/brushes"; import { createEmptySceneDocument } from "../../src/document/scene-document"; @@ -789,6 +789,59 @@ describe("buildRuntimeSceneFromDocument", () => { expect(runtimeScene.sceneBounds?.max.y).toBeGreaterThanOrEqual(2); }); + it("adds static-simple imported-model colliders as compound box pieces", () => { + const wallGeometry = new PlaneGeometry(4, 4, 4, 4); + wallGeometry.rotateY(Math.PI * 0.5); + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-runtime-static-simple", wallGeometry); + const modelInstance = createModelInstance({ + id: "model-instance-runtime-static-simple", + assetId: asset.id, + position: { + x: 2, + y: 2, + z: 0 + }, + collision: { + mode: "static-simple", + visible: true + } + }); + + const runtimeScene = buildRuntimeSceneFromDocument( + { + ...createEmptySceneDocument({ name: "Imported Static Simple Collider Scene" }), + assets: { + [asset.id]: asset + }, + modelInstances: { + [modelInstance.id]: modelInstance + } + }, + { + loadedModelAssets: { + [asset.id]: loadedAsset + } + } + ); + + expect(runtimeScene.colliders).toHaveLength(1); + expect(runtimeScene.colliders[0]).toMatchObject({ + source: "modelInstance", + instanceId: modelInstance.id, + assetId: asset.id, + kind: "compound", + mode: "static-simple", + visible: true, + decomposition: "surface-voxel-boxes" + }); + + if (runtimeScene.colliders[0].source !== "modelInstance" || runtimeScene.colliders[0].kind !== "compound") { + throw new Error("Expected the runtime collider to be a generated compound model collider."); + } + + expect(runtimeScene.colliders[0].pieces.every((piece) => piece.kind === "box")).toBe(true); + }); + it("preserves rotated whitebox box transforms for runner rendering and collision bounds", () => { const brush = createBoxBrush({ id: "brush-rotated-room", diff --git a/tests/domain/rapier-collision-world.test.ts b/tests/domain/rapier-collision-world.test.ts index 7c9563f4..c7db5d23 100644 --- a/tests/domain/rapier-collision-world.test.ts +++ b/tests/domain/rapier-collision-world.test.ts @@ -197,6 +197,80 @@ describe("RapierCollisionWorld", () => { } }); + it("blocks motion against static-simple boxified wall colliders derived from open meshes", async () => { + const floorBrush = createBoxBrush({ + id: "brush-floor-static-simple-wall", + center: { + x: 0, + y: -0.5, + z: 0 + }, + size: { + x: 10, + y: 1, + z: 10 + } + }); + const wallGeometry = new PlaneGeometry(4, 4, 4, 4); + wallGeometry.rotateY(Math.PI * 0.5); + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-static-simple-wall", wallGeometry); + const wallInstance = createModelInstance({ + id: "model-instance-static-simple-wall", + assetId: asset.id, + position: { + x: 2, + y: 2, + z: 0 + }, + collision: { + mode: "static-simple", + visible: true + } + }); + const runtimeScene = buildRuntimeSceneFromDocument( + { + ...createEmptySceneDocument({ name: "Static Simple Wall Collision Scene" }), + assets: { + [asset.id]: asset + }, + brushes: { + [floorBrush.id]: floorBrush + }, + modelInstances: { + [wallInstance.id]: wallInstance + } + }, + { + loadedModelAssets: { + [asset.id]: loadedAsset + } + } + ); + const collisionWorld = await RapierCollisionWorld.create(runtimeScene.colliders, runtimeScene.playerCollider); + + try { + const blocked = collisionWorld.resolveFirstPersonMotion( + { + x: 0, + y: 0, + z: 0 + }, + { + x: 3, + y: 0, + z: 0 + }, + runtimeScene.playerCollider + ); + + expect(blocked.collidedAxes.x).toBe(true); + expect(blocked.feetPosition.x).toBeLessThan(1.8); + expect(blocked.feetPosition.y).toBeLessThan(0.02); + } finally { + collisionWorld.dispose(); + } + }); + it("resolves motion against freely rotated whitebox box colliders", async () => { const floorBrush = createBoxBrush({ id: "brush-floor-rotated-wall", diff --git a/tests/geometry/model-instance-collider-generation.test.ts b/tests/geometry/model-instance-collider-generation.test.ts index 602083e0..453f6c32 100644 --- a/tests/geometry/model-instance-collider-generation.test.ts +++ b/tests/geometry/model-instance-collider-generation.test.ts @@ -66,6 +66,43 @@ describe("buildGeneratedModelCollider", () => { expect(Array.from(collider.indices)).toSatisfy((values: number[]) => values.every(Number.isInteger)); }); + it("builds a static-simple compound collider from thin open mesh surfaces", () => { + const geometry = new PlaneGeometry(4, 3, 4, 3); + geometry.rotateY(Math.PI * 0.5); + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-static-simple", geometry); + const modelInstance = createModelInstance({ + id: "model-instance-static-simple", + assetId: asset.id, + collision: { + mode: "static-simple", + visible: true + } + }); + + const collider = buildGeneratedModelCollider(modelInstance, asset, loadedAsset); + + expect(collider).not.toBeNull(); + expect(collider).toMatchObject({ + kind: "compound", + mode: "static-simple", + decomposition: "surface-voxel-boxes", + runtimeBehavior: "fixedQueryOnly" + }); + + if (collider === null || collider.kind !== "compound") { + throw new Error("Expected a compound collider."); + } + + expect(collider.pieces.length).toBeGreaterThanOrEqual(1); + expect(collider.pieces.every((piece) => piece.kind === "box")).toBe(true); + + if (collider.pieces[0]?.kind !== "box") { + throw new Error("Expected the first static-simple collider piece to be a box."); + } + + expect(collider.pieces[0].size.x).toBeGreaterThan(0.01); + }); + it("builds a terrain heightfield from a regular-grid mesh", () => { const geometry = new PlaneGeometry(4, 4, 2, 2); geometry.rotateX(-Math.PI * 0.5); diff --git a/tests/serialization/scene-document-json.test.ts b/tests/serialization/scene-document-json.test.ts index ebcbbc4c..dd77b0c5 100644 --- a/tests/serialization/scene-document-json.test.ts +++ b/tests/serialization/scene-document-json.test.ts @@ -12,6 +12,7 @@ import { PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION, PLAYER_START_INPUT_BINDINGS_SCENE_DOCUMENT_VERSION, PLAYER_START_NAVIGATION_MODE_SCENE_DOCUMENT_VERSION, + SCENE_EDITOR_PREFERENCES_SCENE_DOCUMENT_VERSION, SCENE_TRANSITION_ENTITIES_SCENE_DOCUMENT_VERSION, SCENE_DOCUMENT_VERSION, SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION, @@ -1032,6 +1033,130 @@ describe("scene document JSON", () => { expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); }); + it("round-trips authored static-simple model-instance collision settings", () => { + const asset = { + id: "asset-model-static-simple-collider", + kind: "model", + sourceName: "collision-static-simple.glb", + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey("asset-model-static-simple-collider"), + byteLength: 64, + metadata: { + kind: "model", + format: "glb", + sceneName: "Static Simple Collision Test Scene", + nodeCount: 1, + meshCount: 1, + materialNames: [], + textureNames: [], + animationNames: [], + boundingBox: { + min: { + x: -2, + y: 0, + z: -0.1 + }, + max: { + x: 2, + y: 4, + z: 0.1 + }, + size: { + x: 4, + y: 4, + z: 0.2 + } + }, + warnings: [] + } + } satisfies ModelAssetRecord; + const modelInstance = createModelInstance({ + id: "model-instance-static-simple-collider", + assetId: asset.id, + collision: { + mode: "static-simple", + visible: true + } + }); + const document = { + ...createEmptySceneDocument({ name: "Static Simple Model Collision Scene" }), + assets: { + [asset.id]: asset + }, + modelInstances: { + [modelInstance.id]: modelInstance + } + }; + + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + + it("migrates version 29 scene documents to preserve existing model collision settings", () => { + const asset = { + id: "asset-model-v29-collider", + kind: "model", + sourceName: "v29-collider.glb", + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey("asset-model-v29-collider"), + byteLength: 64, + metadata: { + kind: "model", + format: "glb", + sceneName: "V29 Collider Scene", + nodeCount: 1, + meshCount: 1, + materialNames: [], + textureNames: [], + animationNames: [], + boundingBox: { + min: { + x: -1, + y: 0, + z: -1 + }, + max: { + x: 1, + y: 2, + z: 1 + }, + size: { + x: 2, + y: 2, + z: 2 + } + }, + warnings: [] + } + } satisfies ModelAssetRecord; + const legacyDocument = { + ...createEmptySceneDocument({ name: "Legacy V29 Collider Scene" }), + version: SCENE_EDITOR_PREFERENCES_SCENE_DOCUMENT_VERSION, + assets: { + [asset.id]: asset + }, + modelInstances: { + "model-instance-v29-collider": { + ...createModelInstance({ + id: "model-instance-v29-collider", + assetId: asset.id, + collision: { + mode: "dynamic", + visible: true + } + }) + } + } + }; + + const migratedDocument = migrateSceneDocument(legacyDocument); + + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.modelInstances["model-instance-v29-collider"].collision).toEqual({ + mode: "dynamic", + visible: true + }); + }); + it("round-trips canonical interaction links", () => { const triggerVolume = createTriggerVolumeEntity({ id: "entity-trigger-main"