Add collision mode support and debug visualization in App.tsx and related files
This commit is contained in:
@@ -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>
|
||||
|
||||
165
src/geometry/model-instance-collider-debug-mesh.ts
Normal file
165
src/geometry/model-instance-collider-debug-mesh.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
101
tests/domain/rapier-collision-world.test.ts
Normal file
101
tests/domain/rapier-collision-world.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
101
tests/domain/runtime-scene-validation.test.ts
Normal file
101
tests/domain/runtime-scene-validation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
137
tests/geometry/model-instance-collider-generation.test.ts
Normal file
137
tests/geometry/model-instance-collider-generation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
104
tests/helpers/model-collider-fixtures.ts
Normal file
104
tests/helpers/model-collider-fixtures.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user