Files
webeditor3d/tests/geometry/model-instance-collider-generation.test.ts

185 lines
6.1 KiB
TypeScript

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");
if (collider === null || collider.kind !== "trimesh") {
throw new Error("Expected a trimesh collider.");
}
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 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);
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?.kind).toBe("heightfield");
if (collider === null || collider.kind !== "heightfield") {
throw new Error("Expected a heightfield collider.");
}
expect(collider).toMatchObject({
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);
});
});