diff --git a/src/app/App.tsx b/src/app/App.tsx index 923ae9b3..03ffe204 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -54,6 +54,7 @@ import { } from "../core/transform-session"; import type { Vec2, Vec3 } from "../core/vector"; import { + MODEL_INSTANCE_COLLISION_MODES, areModelInstancesEqual, createModelInstance, createModelInstancePlacementPosition, @@ -61,7 +62,8 @@ import { DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES, DEFAULT_MODEL_INSTANCE_SCALE, normalizeModelInstanceName, - type ModelInstance + type ModelInstance, + type ModelInstanceCollisionMode } from "../assets/model-instances"; import { getModelInstanceDisplayLabelById, @@ -169,6 +171,7 @@ import { import { STARTER_MATERIAL_LIBRARY, type MaterialDef } from "../materials/starter-material-library"; import { RunnerCanvas } from "../runner-web/RunnerCanvas"; import type { FirstPersonTelemetry } from "../runtime-three/navigation-controller"; +import { initializeRapierCollisionWorld } from "../runtime-three/rapier-collision-world"; import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system"; import { buildRuntimeSceneFromDocument, type RuntimeNavigationMode, type RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build"; import { validateRuntimeSceneBuild } from "../runtime-three/runtime-scene-validation"; @@ -219,6 +222,21 @@ const FACE_LABELS: Record = { negZ: "-Z Back" }; +function getModelInstanceCollisionModeDescription(mode: ModelInstanceCollisionMode): string { + switch (mode) { + case "none": + return "No generated collider is built for this model instance."; + case "terrain": + return "Builds a Rapier heightfield from a regular-grid terrain mesh. Unsupported terrain sources fail with build diagnostics."; + case "static": + return "Builds a fixed Rapier triangle-mesh collider from the imported model geometry."; + case "dynamic": + return "Builds convex compound pieces for Rapier queries. In this slice they participate as fixed world collision, not fully simulated rigid bodies."; + case "simple": + return "Builds one cheap oriented box from the imported model bounds."; + } +} + const STARTER_MATERIAL_ORDER = new Map(STARTER_MATERIAL_LIBRARY.map((material, index) => [material.id, index])); const MIN_VIEWPORT_QUAD_SPLIT = 0.2; const MAX_VIEWPORT_QUAD_SPLIT = 0.8; @@ -961,7 +979,10 @@ export function App({ store, initialStatusMessage }: AppProps) { }); const [viewportQuadResizeMode, setViewportQuadResizeMode] = useState(null); const documentValidation = validateSceneDocument(editorState.document); - const runValidation = validateRuntimeSceneBuild(editorState.document, preferredNavigationMode); + const runValidation = validateRuntimeSceneBuild(editorState.document, { + navigationMode: preferredNavigationMode, + loadedModelAssets + }); const diagnostics = [...documentValidation.errors, ...documentValidation.warnings, ...runValidation.errors, ...runValidation.warnings]; const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error"); const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning"); @@ -1141,6 +1162,10 @@ export function App({ store, initialStatusMessage }: AppProps) { loadedModelAssetsRef.current = loadedModelAssets; }, [loadedModelAssets]); + useEffect(() => { + void initializeRapierCollisionWorld(); + }, []); + useEffect(() => { loadedAudioAssetsRef.current = loadedAudioAssets; }, [loadedAudioAssets]); @@ -2161,9 +2186,12 @@ export function App({ store, initialStatusMessage }: AppProps) { id: selectedModelInstance.id, assetId: selectedModelInstance.assetId, name: selectedModelInstance.name, + collision: selectedModelInstance.collision, position: readVec3Draft(modelPositionDraft, "Model instance position"), rotationDegrees: readVec3Draft(modelRotationDraft, "Model instance rotation"), - scale: readPositiveVec3Draft(modelScaleDraft, "Model instance scale") + scale: readPositiveVec3Draft(modelScaleDraft, "Model instance scale"), + animationClipName: selectedModelInstance.animationClipName, + animationAutoplay: selectedModelInstance.animationAutoplay }); commitModelInstanceChange(selectedModelInstance, nextModelInstance, "Updated model instance."); @@ -4165,7 +4193,8 @@ export function App({ store, initialStatusMessage }: AppProps) { try { const nextRuntimeScene = buildRuntimeSceneFromDocument(editorState.document, { - navigationMode: preferredNavigationMode + navigationMode: preferredNavigationMode, + loadedModelAssets }); const nextNavigationMode = preferredNavigationMode; @@ -5664,6 +5693,61 @@ export function App({ store, initialStatusMessage }: AppProps) { +
+
Collision
+ + +
{getModelInstanceCollisionModeDescription(selectedModelInstance.collision.mode)}
+
+ {selectedModelAssetRecord !== null && selectedModelAssetRecord.metadata.animationNames.length > 0 && (
Animation
diff --git a/src/geometry/model-instance-collider-debug-mesh.ts b/src/geometry/model-instance-collider-debug-mesh.ts new file mode 100644 index 00000000..9f7af410 --- /dev/null +++ b/src/geometry/model-instance-collider-debug-mesh.ts @@ -0,0 +1,165 @@ +import { + BoxGeometry, + BufferGeometry, + Float32BufferAttribute, + Group, + Mesh, + MeshBasicMaterial, + Vector3, + type Material +} from "three"; +import { ConvexGeometry } from "three/examples/jsm/geometries/ConvexGeometry.js"; + +import type { + GeneratedModelBoxCollider, + GeneratedModelCollider, + GeneratedModelCompoundCollider, + GeneratedModelHeightfieldCollider, + GeneratedModelTriMeshCollider +} from "./model-instance-collider-generation"; + +const DEBUG_COLLIDER_COLORS = { + simple: 0x87d2ff, + terrain: 0x7be7b4, + static: 0xffc66d, + dynamic: 0xff8b7a +} as const; + +function createWireframeMaterial(color: number): MeshBasicMaterial { + return new MeshBasicMaterial({ + color, + wireframe: true, + transparent: true, + opacity: 0.85, + depthWrite: false, + toneMapped: false + }); +} + +function markDebugMesh(mesh: Mesh) { + mesh.userData.shadowIgnored = true; + mesh.userData.nonPickable = true; + mesh.renderOrder = 3_500; +} + +function createBoxColliderDebugMesh(collider: GeneratedModelBoxCollider): Mesh { + const mesh = new Mesh(new BoxGeometry(collider.size.x, collider.size.y, collider.size.z), createWireframeMaterial(DEBUG_COLLIDER_COLORS.simple)); + mesh.position.set(collider.center.x, collider.center.y, collider.center.z); + markDebugMesh(mesh); + return mesh; +} + +function createTriMeshColliderDebugMesh(collider: GeneratedModelTriMeshCollider): Mesh { + const geometry = new BufferGeometry(); + + geometry.setAttribute("position", new Float32BufferAttribute(collider.vertices, 3)); + geometry.setIndex(Array.from(collider.indices)); + + const mesh = new Mesh(geometry, createWireframeMaterial(DEBUG_COLLIDER_COLORS.static)); + markDebugMesh(mesh); + return mesh; +} + +function createHeightfieldColliderDebugMesh(collider: GeneratedModelHeightfieldCollider): Mesh { + const vertices: number[] = []; + const indices: number[] = []; + const width = collider.maxX - collider.minX; + const depth = collider.maxZ - collider.minZ; + + for (let zIndex = 0; zIndex < collider.cols; zIndex += 1) { + const zLerp = collider.cols === 1 ? 0 : zIndex / (collider.cols - 1); + const z = collider.minZ + depth * zLerp; + + for (let xIndex = 0; xIndex < collider.rows; xIndex += 1) { + const xLerp = collider.rows === 1 ? 0 : xIndex / (collider.rows - 1); + const x = collider.minX + width * xLerp; + const y = collider.heights[xIndex + zIndex * collider.rows]; + + vertices.push(x, y, z); + } + } + + for (let zIndex = 0; zIndex < collider.cols - 1; zIndex += 1) { + for (let xIndex = 0; xIndex < collider.rows - 1; xIndex += 1) { + const topLeft = xIndex + zIndex * collider.rows; + const topRight = topLeft + 1; + const bottomLeft = topLeft + collider.rows; + const bottomRight = bottomLeft + 1; + + indices.push(topLeft, bottomLeft, bottomRight, topLeft, bottomRight, topRight); + } + } + + const geometry = new BufferGeometry(); + + geometry.setAttribute("position", new Float32BufferAttribute(vertices, 3)); + geometry.setIndex(indices); + + const mesh = new Mesh(geometry, createWireframeMaterial(DEBUG_COLLIDER_COLORS.terrain)); + markDebugMesh(mesh); + return mesh; +} + +function createCompoundColliderDebugGroup(collider: GeneratedModelCompoundCollider): Group { + const group = new Group(); + + for (const piece of collider.pieces) { + const points: Vector3[] = []; + + for (let index = 0; index < piece.points.length; index += 3) { + points.push(new Vector3(piece.points[index], piece.points[index + 1], piece.points[index + 2])); + } + + const mesh = new Mesh(new ConvexGeometry(points), createWireframeMaterial(DEBUG_COLLIDER_COLORS.dynamic)); + markDebugMesh(mesh); + group.add(mesh); + } + + return group; +} + +export function createModelColliderDebugGroup(collider: GeneratedModelCollider): Group { + const group = new Group(); + + switch (collider.kind) { + case "box": + group.add(createBoxColliderDebugMesh(collider)); + break; + case "trimesh": + group.add(createTriMeshColliderDebugMesh(collider)); + break; + case "heightfield": + group.add(createHeightfieldColliderDebugMesh(collider)); + break; + case "compound": + group.add(createCompoundColliderDebugGroup(collider)); + break; + } + + group.userData.nonPickable = true; + return group; +} + +function disposeMaterial(material: Material | Material[]) { + if (Array.isArray(material)) { + for (const item of material) { + item.dispose(); + } + return; + } + + material.dispose(); +} + +export function disposeModelColliderDebugGroup(group: Group) { + group.traverse((object) => { + const maybeMesh = object as Mesh & { isMesh?: boolean }; + + if (maybeMesh.isMesh !== true) { + return; + } + + maybeMesh.geometry.dispose(); + disposeMaterial(maybeMesh.material); + }); +} diff --git a/src/runtime-three/runtime-host.ts b/src/runtime-three/runtime-host.ts index 2dd1ef3b..edeef583 100644 --- a/src/runtime-three/runtime-host.ts +++ b/src/runtime-three/runtime-host.ts @@ -25,6 +25,7 @@ import type { LoadedImageAsset } from "../assets/image-assets"; import type { LoadedAudioAsset } from "../assets/audio-assets"; import type { ProjectAssetRecord } from "../assets/project-assets"; import { applyBoxBrushFaceUvsToGeometry } from "../geometry/box-face-uvs"; +import { createModelColliderDebugGroup } from "../geometry/model-instance-collider-debug-mesh"; import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures"; import { applyAdvancedRenderingLightShadowFlags, @@ -64,6 +65,19 @@ interface LocalLightRenderObjects { const FALLBACK_FACE_COLOR = 0x747d89; +function findVisibleModelCollider( + colliders: RuntimeSceneDefinition["colliders"], + instanceId: string +): Extract | null { + for (const collider of colliders) { + if (collider.source === "modelInstance" && collider.instanceId === instanceId && collider.visible) { + return collider; + } + } + + return null; +} + export class RuntimeHost { private readonly scene = new Scene(); private readonly camera = new PerspectiveCamera(70, 1, 0.05, 1000); @@ -175,7 +189,7 @@ export class RuntimeHost { this.applyWorld(); this.rebuildLocalLights(runtimeScene.localLights); this.rebuildBrushMeshes(runtimeScene.brushes); - this.rebuildModelInstances(runtimeScene.modelInstances); + this.rebuildModelInstances(runtimeScene.modelInstances, runtimeScene.colliders); void this.rebuildCollisionWorld(runtimeScene.colliders); this.audioSystem.loadScene(runtimeScene); } @@ -195,7 +209,7 @@ export class RuntimeHost { } if (this.runtimeScene !== null) { - this.rebuildModelInstances(this.runtimeScene.modelInstances); + this.rebuildModelInstances(this.runtimeScene.modelInstances, this.runtimeScene.colliders); } this.audioSystem.updateAssets(projectAssets, loadedAudioAssets); @@ -482,7 +496,7 @@ export class RuntimeHost { this.applyShadowState(); } - private rebuildModelInstances(modelInstances: RuntimeSceneDefinition["modelInstances"]) { + private rebuildModelInstances(modelInstances: RuntimeSceneDefinition["modelInstances"], colliders: RuntimeSceneDefinition["colliders"]) { this.clearModelInstances(); for (const modelInstance of modelInstances) { @@ -502,6 +516,11 @@ export class RuntimeHost { loadedAsset, false ); + const visibleCollider = findVisibleModelCollider(colliders, modelInstance.instanceId); + + if (visibleCollider !== null) { + renderGroup.add(createModelColliderDebugGroup(visibleCollider)); + } this.modelGroup.add(renderGroup); this.modelRenderObjects.set(modelInstance.instanceId, renderGroup); diff --git a/src/viewport-three/viewport-host.ts b/src/viewport-three/viewport-host.ts index a13cced4..a990c18e 100644 --- a/src/viewport-three/viewport-host.ts +++ b/src/viewport-three/viewport-host.ts @@ -85,6 +85,8 @@ import { } from "../entities/entity-instances"; import { BOX_FACE_IDS, DEFAULT_BOX_BRUSH_SIZE, type BoxBrush, type BoxFaceId } from "../document/brushes"; import { applyBoxBrushFaceUvsToGeometry } from "../geometry/box-face-uvs"; +import { createModelColliderDebugGroup } from "../geometry/model-instance-collider-debug-mesh"; +import { buildGeneratedModelCollider } from "../geometry/model-instance-collider-generation"; import { DEFAULT_GRID_SIZE, snapValueToGrid, snapVec3ToGrid } from "../geometry/grid-snapping"; import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures"; import type { MaterialDef } from "../materials/starter-material-library"; @@ -1680,6 +1682,18 @@ export class ViewportHost { const loadedAsset = this.loadedModelAssets[modelInstance.assetId]; const renderGroup = createModelInstanceRenderGroup(modelInstance, asset, loadedAsset, selected); + if (asset?.kind === "model" && modelInstance.collision.visible) { + try { + const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, loadedAsset); + + if (generatedCollider !== null) { + renderGroup.add(createModelColliderDebugGroup(generatedCollider)); + } + } catch { + // Validation surfaces unsupported collider modes; the viewport keeps rendering the model. + } + } + this.modelGroup.add(renderGroup); this.modelRenderObjects.set(modelInstance.id, renderGroup); } diff --git a/tests/domain/rapier-collision-world.test.ts b/tests/domain/rapier-collision-world.test.ts new file mode 100644 index 00000000..d455e513 --- /dev/null +++ b/tests/domain/rapier-collision-world.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { BoxGeometry } from "three"; + +import { createModelInstance } from "../../src/assets/model-instances"; +import { createBoxBrush } from "../../src/document/brushes"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { FIRST_PERSON_PLAYER_SHAPE } from "../../src/runtime-three/player-collision"; +import { RapierCollisionWorld } from "../../src/runtime-three/rapier-collision-world"; +import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build"; +import { createFixtureLoadedModelAssetFromGeometry } from "../helpers/model-collider-fixtures"; + +describe("RapierCollisionWorld", () => { + it("resolves first-person motion against brush and imported model colliders in one query path", async () => { + const floorBrush = createBoxBrush({ + id: "brush-floor", + center: { + x: 0, + y: -0.5, + z: 0 + }, + size: { + x: 10, + y: 1, + z: 10 + } + }); + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-crate", new BoxGeometry(1, 1, 1)); + const crateInstance = createModelInstance({ + id: "model-instance-crate", + assetId: asset.id, + position: { + x: 2, + y: 0.5, + z: 0 + }, + collision: { + mode: "static", + visible: true + } + }); + const runtimeScene = buildRuntimeSceneFromDocument( + { + ...createEmptySceneDocument({ name: "Brush And Model Collision Scene" }), + assets: { + [asset.id]: asset + }, + brushes: { + [floorBrush.id]: floorBrush + }, + modelInstances: { + [crateInstance.id]: crateInstance + } + }, + { + loadedModelAssets: { + [asset.id]: loadedAsset + } + } + ); + const collisionWorld = await RapierCollisionWorld.create(runtimeScene.colliders, FIRST_PERSON_PLAYER_SHAPE); + + try { + const landing = collisionWorld.resolveFirstPersonMotion( + { + x: 0, + y: 2, + z: 0 + }, + { + x: 0, + y: -3, + z: 0 + }, + FIRST_PERSON_PLAYER_SHAPE + ); + + expect(landing.grounded).toBe(true); + expect(landing.feetPosition.y).toBeCloseTo(0, 4); + + const blocked = collisionWorld.resolveFirstPersonMotion( + { + x: 0, + y: 0, + z: 0 + }, + { + x: 3, + y: 0, + z: 0 + }, + FIRST_PERSON_PLAYER_SHAPE + ); + + expect(blocked.feetPosition.x).toBeLessThan(1.21); + expect(blocked.feetPosition.y).toBeCloseTo(0, 4); + expect(blocked.collidedAxes.x).toBe(true); + } finally { + collisionWorld.dispose(); + } + }); +}); diff --git a/tests/domain/runtime-scene-validation.test.ts b/tests/domain/runtime-scene-validation.test.ts new file mode 100644 index 00000000..948b1449 --- /dev/null +++ b/tests/domain/runtime-scene-validation.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { BoxGeometry } from "three"; + +import { createModelInstance } from "../../src/assets/model-instances"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { validateRuntimeSceneBuild } from "../../src/runtime-three/runtime-scene-validation"; +import { createFixtureLoadedModelAssetFromGeometry } from "../helpers/model-collider-fixtures"; + +describe("validateRuntimeSceneBuild", () => { + it("reports missing loaded geometry for collider modes that depend on imported mesh data", () => { + const { asset } = createFixtureLoadedModelAssetFromGeometry("asset-model-static-validation", new BoxGeometry(1, 1, 1)); + const modelInstance = createModelInstance({ + id: "model-instance-static-validation", + assetId: asset.id, + collision: { + mode: "static", + visible: false + } + }); + const validation = validateRuntimeSceneBuild( + { + ...createEmptySceneDocument({ name: "Missing Model Geometry Scene" }), + assets: { + [asset.id]: asset + }, + modelInstances: { + [modelInstance.id]: modelInstance + } + }, + { + navigationMode: "orbitVisitor", + loadedModelAssets: {} + } + ); + + expect(validation.errors.map((diagnostic) => diagnostic.code)).toContain("missing-model-collider-geometry"); + }); + + it("fails terrain mode clearly when the source mesh is incompatible with the heightfield path", () => { + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-terrain-validation", new BoxGeometry(1, 1, 1)); + const modelInstance = createModelInstance({ + id: "model-instance-terrain-validation", + assetId: asset.id, + collision: { + mode: "terrain", + visible: true + } + }); + const validation = validateRuntimeSceneBuild( + { + ...createEmptySceneDocument({ name: "Invalid Terrain Scene" }), + assets: { + [asset.id]: asset + }, + modelInstances: { + [modelInstance.id]: modelInstance + } + }, + { + navigationMode: "orbitVisitor", + loadedModelAssets: { + [asset.id]: loadedAsset + } + } + ); + + expect(validation.errors.map((diagnostic) => diagnostic.code)).toContain("unsupported-terrain-model-collider"); + }); + + it("warns that dynamic collision currently participates as fixed queryable world geometry", () => { + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-dynamic-validation", new BoxGeometry(1, 1, 1)); + const modelInstance = createModelInstance({ + id: "model-instance-dynamic-validation", + assetId: asset.id, + collision: { + mode: "dynamic", + visible: false + } + }); + const validation = validateRuntimeSceneBuild( + { + ...createEmptySceneDocument({ name: "Dynamic Collider Scene" }), + assets: { + [asset.id]: asset + }, + modelInstances: { + [modelInstance.id]: modelInstance + } + }, + { + navigationMode: "orbitVisitor", + loadedModelAssets: { + [asset.id]: loadedAsset + } + } + ); + + expect(validation.errors).toEqual([]); + expect(validation.warnings.map((diagnostic) => diagnostic.code)).toContain("dynamic-model-collider-fixed-query-only"); + }); +}); diff --git a/tests/geometry/model-instance-collider-generation.test.ts b/tests/geometry/model-instance-collider-generation.test.ts new file mode 100644 index 00000000..42406bb0 --- /dev/null +++ b/tests/geometry/model-instance-collider-generation.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PlaneGeometry } from "three"; + +import { createModelInstance } from "../../src/assets/model-instances"; +import { buildGeneratedModelCollider } from "../../src/geometry/model-instance-collider-generation"; +import { + createFixtureLoadedModelAsset, + createFixtureLoadedModelAssetFromGeometry, + createFixtureModelAssetRecord +} from "../helpers/model-collider-fixtures"; + +describe("buildGeneratedModelCollider", () => { + it("builds a simple oriented box collider from asset bounds", () => { + const { asset } = createFixtureLoadedModelAssetFromGeometry("asset-model-simple", new BoxGeometry(2, 4, 6)); + const modelInstance = createModelInstance({ + id: "model-instance-simple", + assetId: asset.id, + collision: { + mode: "simple", + visible: true + } + }); + + const collider = buildGeneratedModelCollider(modelInstance, asset); + + expect(collider).not.toBeNull(); + expect(collider).toMatchObject({ + kind: "box", + mode: "simple", + visible: true, + center: { + x: 0, + y: 0, + z: 0 + }, + size: { + x: 2, + y: 4, + z: 6 + } + }); + }); + + it("builds a static triangle-mesh collider from loaded model geometry", () => { + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-static", new BoxGeometry(2, 1, 3)); + const modelInstance = createModelInstance({ + id: "model-instance-static", + assetId: asset.id, + collision: { + mode: "static", + visible: false + } + }); + + const collider = buildGeneratedModelCollider(modelInstance, asset, loadedAsset); + + expect(collider).not.toBeNull(); + expect(collider?.kind).toBe("trimesh"); + expect(collider?.mode).toBe("static"); + expect(Array.from(collider?.vertices ?? [])).toSatisfy((values: number[]) => values.every(Number.isFinite)); + expect(Array.from(collider?.indices ?? [])).toSatisfy((values: number[]) => values.every(Number.isInteger)); + }); + + it("builds a terrain heightfield from a regular-grid mesh", () => { + const geometry = new PlaneGeometry(4, 4, 2, 2); + geometry.rotateX(-Math.PI * 0.5); + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-terrain", geometry); + const modelInstance = createModelInstance({ + id: "model-instance-terrain", + assetId: asset.id, + collision: { + mode: "terrain", + visible: true + } + }); + + const collider = buildGeneratedModelCollider(modelInstance, asset, loadedAsset); + + expect(collider).not.toBeNull(); + expect(collider).toMatchObject({ + kind: "heightfield", + mode: "terrain", + rows: 3, + cols: 3 + }); + expect(Array.from(collider?.heights ?? [])).toSatisfy((values: number[]) => values.every(Number.isFinite)); + }); + + it("fails terrain mode for meshes that are not a clean regular-grid terrain surface", () => { + const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-invalid-terrain", new BoxGeometry(2, 2, 2)); + const modelInstance = createModelInstance({ + id: "model-instance-invalid-terrain", + assetId: asset.id, + collision: { + mode: "terrain", + visible: false + } + }); + + expect(() => buildGeneratedModelCollider(modelInstance, asset, loadedAsset)).toThrow("cannot use terrain collision"); + }); + + it("builds explicit convex compound pieces for dynamic mode", () => { + const template = new Group(); + const material = new MeshBasicMaterial(); + const leftBox = new Mesh(new BoxGeometry(1, 1, 1), material); + const rightBox = new Mesh(new BoxGeometry(1, 2, 1), material); + + leftBox.position.set(-1.25, 0.5, 0); + rightBox.position.set(1.25, 1, 0); + template.add(leftBox); + template.add(rightBox); + template.updateMatrixWorld(true); + + const asset = createFixtureModelAssetRecord("asset-model-dynamic", template); + const loadedAsset = createFixtureLoadedModelAsset(asset, template); + const modelInstance = createModelInstance({ + id: "model-instance-dynamic", + assetId: asset.id, + collision: { + mode: "dynamic", + visible: true + } + }); + + const collider = buildGeneratedModelCollider(modelInstance, asset, loadedAsset); + + expect(collider).not.toBeNull(); + expect(collider).toMatchObject({ + kind: "compound", + mode: "dynamic", + decomposition: "spatial-bisect", + runtimeBehavior: "fixedQueryOnly" + }); + expect(collider?.kind === "compound" ? collider.pieces.length : 0).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/tests/helpers/model-collider-fixtures.ts b/tests/helpers/model-collider-fixtures.ts new file mode 100644 index 00000000..a17fa41c --- /dev/null +++ b/tests/helpers/model-collider-fixtures.ts @@ -0,0 +1,104 @@ +import { Box3, Group, Mesh, MeshBasicMaterial, type BufferGeometry } from "three"; + +import type { LoadedModelAsset } from "../../src/assets/gltf-model-import"; +import { createProjectAssetStorageKey, type ModelAssetRecord } from "../../src/assets/project-assets"; + +function countMeshes(group: Group): number { + let count = 0; + + group.traverse((object) => { + if ((object as Mesh).isMesh === true) { + count += 1; + } + }); + + return count; +} + +function countNodes(group: Group): number { + let count = 0; + + group.traverse(() => { + count += 1; + }); + + return count; +} + +function createBoundingBox(group: Group): ModelAssetRecord["metadata"]["boundingBox"] { + const bounds = new Box3().setFromObject(group); + + if (bounds.isEmpty()) { + return null; + } + + return { + min: { + x: bounds.min.x, + y: bounds.min.y, + z: bounds.min.z + }, + max: { + x: bounds.max.x, + y: bounds.max.y, + z: bounds.max.z + }, + size: { + x: bounds.max.x - bounds.min.x, + y: bounds.max.y - bounds.min.y, + z: bounds.max.z - bounds.min.z + } + }; +} + +export function createFixtureModelAssetRecord(id: string, template: Group, sourceName = `${id}.glb`): ModelAssetRecord { + template.updateMatrixWorld(true); + + return { + id, + kind: "model", + sourceName, + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey(id), + byteLength: 128, + metadata: { + kind: "model", + format: "glb", + sceneName: sourceName, + nodeCount: countNodes(template), + meshCount: countMeshes(template), + materialNames: [], + textureNames: [], + animationNames: [], + boundingBox: createBoundingBox(template), + warnings: [] + } + }; +} + +export function createFixtureLoadedModelAsset(asset: ModelAssetRecord, template: Group): LoadedModelAsset { + template.updateMatrixWorld(true); + + return { + assetId: asset.id, + storageKey: asset.storageKey, + metadata: asset.metadata, + template, + animations: [] + }; +} + +export function createFixtureLoadedModelAssetFromGeometry(assetId: string, geometry: BufferGeometry): { + asset: ModelAssetRecord; + loadedAsset: LoadedModelAsset; +} { + const template = new Group(); + template.add(new Mesh(geometry, new MeshBasicMaterial())); + template.updateMatrixWorld(true); + const asset = createFixtureModelAssetRecord(assetId, template); + + return { + asset, + loadedAsset: createFixtureLoadedModelAsset(asset, template) + }; +}