Add collision mode support and debug visualization in App.tsx and related files

This commit is contained in:
2026-04-04 07:55:42 +02:00
parent 3d1dd3fe63
commit ba8f8235bf
8 changed files with 732 additions and 7 deletions

View File

@@ -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<BoxFaceId, string> = {
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<ViewportQuadResizeMode | null>(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) {
</div>
</div>
<div className="form-section">
<div className="label">Collision</div>
<label className="form-field">
<span className="label">Mode</span>
<select
data-testid="model-instance-collision-mode"
className="select-input"
value={selectedModelInstance.collision.mode}
onChange={(event) => {
store.executeCommand(
createUpsertModelInstanceCommand({
modelInstance: {
...selectedModelInstance,
collision: {
...selectedModelInstance.collision,
mode: event.target.value as ModelInstanceCollisionMode
}
},
label: "Set model collision mode"
})
);
}}
>
{MODEL_INSTANCE_COLLISION_MODES.map((mode) => (
<option key={mode} value={mode}>
{mode}
</option>
))}
</select>
</label>
<label className="form-field">
<input
data-testid="model-instance-collision-visible"
type="checkbox"
checked={selectedModelInstance.collision.visible}
onChange={(event) => {
store.executeCommand(
createUpsertModelInstanceCommand({
modelInstance: {
...selectedModelInstance,
collision: {
...selectedModelInstance.collision,
visible: event.target.checked
}
},
label: event.target.checked ? "Show model collision debug" : "Hide model collision debug"
})
);
}}
/>
<span className="label">Show generated collision debug</span>
</label>
<div className="material-summary">{getModelInstanceCollisionModeDescription(selectedModelInstance.collision.mode)}</div>
</div>
{selectedModelAssetRecord !== null && selectedModelAssetRecord.metadata.animationNames.length > 0 && (
<div className="form-section">
<div className="label">Animation</div>

View File

@@ -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);
});
}

View File

@@ -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<RuntimeSceneDefinition["colliders"][number], { source: "modelInstance" }> | 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);

View File

@@ -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);
}

View File

@@ -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();
}
});
});

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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)
};
}