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";
|
} from "../core/transform-session";
|
||||||
import type { Vec2, Vec3 } from "../core/vector";
|
import type { Vec2, Vec3 } from "../core/vector";
|
||||||
import {
|
import {
|
||||||
|
MODEL_INSTANCE_COLLISION_MODES,
|
||||||
areModelInstancesEqual,
|
areModelInstancesEqual,
|
||||||
createModelInstance,
|
createModelInstance,
|
||||||
createModelInstancePlacementPosition,
|
createModelInstancePlacementPosition,
|
||||||
@@ -61,7 +62,8 @@ import {
|
|||||||
DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES,
|
DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES,
|
||||||
DEFAULT_MODEL_INSTANCE_SCALE,
|
DEFAULT_MODEL_INSTANCE_SCALE,
|
||||||
normalizeModelInstanceName,
|
normalizeModelInstanceName,
|
||||||
type ModelInstance
|
type ModelInstance,
|
||||||
|
type ModelInstanceCollisionMode
|
||||||
} from "../assets/model-instances";
|
} from "../assets/model-instances";
|
||||||
import {
|
import {
|
||||||
getModelInstanceDisplayLabelById,
|
getModelInstanceDisplayLabelById,
|
||||||
@@ -169,6 +171,7 @@ import {
|
|||||||
import { STARTER_MATERIAL_LIBRARY, type MaterialDef } from "../materials/starter-material-library";
|
import { STARTER_MATERIAL_LIBRARY, type MaterialDef } from "../materials/starter-material-library";
|
||||||
import { RunnerCanvas } from "../runner-web/RunnerCanvas";
|
import { RunnerCanvas } from "../runner-web/RunnerCanvas";
|
||||||
import type { FirstPersonTelemetry } from "../runtime-three/navigation-controller";
|
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 type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system";
|
||||||
import { buildRuntimeSceneFromDocument, type RuntimeNavigationMode, type RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build";
|
import { buildRuntimeSceneFromDocument, type RuntimeNavigationMode, type RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build";
|
||||||
import { validateRuntimeSceneBuild } from "../runtime-three/runtime-scene-validation";
|
import { validateRuntimeSceneBuild } from "../runtime-three/runtime-scene-validation";
|
||||||
@@ -219,6 +222,21 @@ const FACE_LABELS: Record<BoxFaceId, string> = {
|
|||||||
negZ: "-Z Back"
|
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 STARTER_MATERIAL_ORDER = new Map(STARTER_MATERIAL_LIBRARY.map((material, index) => [material.id, index]));
|
||||||
const MIN_VIEWPORT_QUAD_SPLIT = 0.2;
|
const MIN_VIEWPORT_QUAD_SPLIT = 0.2;
|
||||||
const MAX_VIEWPORT_QUAD_SPLIT = 0.8;
|
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 [viewportQuadResizeMode, setViewportQuadResizeMode] = useState<ViewportQuadResizeMode | null>(null);
|
||||||
const documentValidation = validateSceneDocument(editorState.document);
|
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 diagnostics = [...documentValidation.errors, ...documentValidation.warnings, ...runValidation.errors, ...runValidation.warnings];
|
||||||
const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
||||||
const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
|
const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
|
||||||
@@ -1141,6 +1162,10 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
|||||||
loadedModelAssetsRef.current = loadedModelAssets;
|
loadedModelAssetsRef.current = loadedModelAssets;
|
||||||
}, [loadedModelAssets]);
|
}, [loadedModelAssets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void initializeRapierCollisionWorld();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadedAudioAssetsRef.current = loadedAudioAssets;
|
loadedAudioAssetsRef.current = loadedAudioAssets;
|
||||||
}, [loadedAudioAssets]);
|
}, [loadedAudioAssets]);
|
||||||
@@ -2161,9 +2186,12 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
|||||||
id: selectedModelInstance.id,
|
id: selectedModelInstance.id,
|
||||||
assetId: selectedModelInstance.assetId,
|
assetId: selectedModelInstance.assetId,
|
||||||
name: selectedModelInstance.name,
|
name: selectedModelInstance.name,
|
||||||
|
collision: selectedModelInstance.collision,
|
||||||
position: readVec3Draft(modelPositionDraft, "Model instance position"),
|
position: readVec3Draft(modelPositionDraft, "Model instance position"),
|
||||||
rotationDegrees: readVec3Draft(modelRotationDraft, "Model instance rotation"),
|
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.");
|
commitModelInstanceChange(selectedModelInstance, nextModelInstance, "Updated model instance.");
|
||||||
@@ -4165,7 +4193,8 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const nextRuntimeScene = buildRuntimeSceneFromDocument(editorState.document, {
|
const nextRuntimeScene = buildRuntimeSceneFromDocument(editorState.document, {
|
||||||
navigationMode: preferredNavigationMode
|
navigationMode: preferredNavigationMode,
|
||||||
|
loadedModelAssets
|
||||||
});
|
});
|
||||||
const nextNavigationMode = preferredNavigationMode;
|
const nextNavigationMode = preferredNavigationMode;
|
||||||
|
|
||||||
@@ -5664,6 +5693,61 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{selectedModelAssetRecord !== null && selectedModelAssetRecord.metadata.animationNames.length > 0 && (
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<div className="label">Animation</div>
|
<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 { LoadedAudioAsset } from "../assets/audio-assets";
|
||||||
import type { ProjectAssetRecord } from "../assets/project-assets";
|
import type { ProjectAssetRecord } from "../assets/project-assets";
|
||||||
import { applyBoxBrushFaceUvsToGeometry } from "../geometry/box-face-uvs";
|
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 { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
|
||||||
import {
|
import {
|
||||||
applyAdvancedRenderingLightShadowFlags,
|
applyAdvancedRenderingLightShadowFlags,
|
||||||
@@ -64,6 +65,19 @@ interface LocalLightRenderObjects {
|
|||||||
|
|
||||||
const FALLBACK_FACE_COLOR = 0x747d89;
|
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 {
|
export class RuntimeHost {
|
||||||
private readonly scene = new Scene();
|
private readonly scene = new Scene();
|
||||||
private readonly camera = new PerspectiveCamera(70, 1, 0.05, 1000);
|
private readonly camera = new PerspectiveCamera(70, 1, 0.05, 1000);
|
||||||
@@ -175,7 +189,7 @@ export class RuntimeHost {
|
|||||||
this.applyWorld();
|
this.applyWorld();
|
||||||
this.rebuildLocalLights(runtimeScene.localLights);
|
this.rebuildLocalLights(runtimeScene.localLights);
|
||||||
this.rebuildBrushMeshes(runtimeScene.brushes);
|
this.rebuildBrushMeshes(runtimeScene.brushes);
|
||||||
this.rebuildModelInstances(runtimeScene.modelInstances);
|
this.rebuildModelInstances(runtimeScene.modelInstances, runtimeScene.colliders);
|
||||||
void this.rebuildCollisionWorld(runtimeScene.colliders);
|
void this.rebuildCollisionWorld(runtimeScene.colliders);
|
||||||
this.audioSystem.loadScene(runtimeScene);
|
this.audioSystem.loadScene(runtimeScene);
|
||||||
}
|
}
|
||||||
@@ -195,7 +209,7 @@ export class RuntimeHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.runtimeScene !== null) {
|
if (this.runtimeScene !== null) {
|
||||||
this.rebuildModelInstances(this.runtimeScene.modelInstances);
|
this.rebuildModelInstances(this.runtimeScene.modelInstances, this.runtimeScene.colliders);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.audioSystem.updateAssets(projectAssets, loadedAudioAssets);
|
this.audioSystem.updateAssets(projectAssets, loadedAudioAssets);
|
||||||
@@ -482,7 +496,7 @@ export class RuntimeHost {
|
|||||||
this.applyShadowState();
|
this.applyShadowState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private rebuildModelInstances(modelInstances: RuntimeSceneDefinition["modelInstances"]) {
|
private rebuildModelInstances(modelInstances: RuntimeSceneDefinition["modelInstances"], colliders: RuntimeSceneDefinition["colliders"]) {
|
||||||
this.clearModelInstances();
|
this.clearModelInstances();
|
||||||
|
|
||||||
for (const modelInstance of modelInstances) {
|
for (const modelInstance of modelInstances) {
|
||||||
@@ -502,6 +516,11 @@ export class RuntimeHost {
|
|||||||
loadedAsset,
|
loadedAsset,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
const visibleCollider = findVisibleModelCollider(colliders, modelInstance.instanceId);
|
||||||
|
|
||||||
|
if (visibleCollider !== null) {
|
||||||
|
renderGroup.add(createModelColliderDebugGroup(visibleCollider));
|
||||||
|
}
|
||||||
|
|
||||||
this.modelGroup.add(renderGroup);
|
this.modelGroup.add(renderGroup);
|
||||||
this.modelRenderObjects.set(modelInstance.instanceId, renderGroup);
|
this.modelRenderObjects.set(modelInstance.instanceId, renderGroup);
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ import {
|
|||||||
} from "../entities/entity-instances";
|
} from "../entities/entity-instances";
|
||||||
import { BOX_FACE_IDS, DEFAULT_BOX_BRUSH_SIZE, type BoxBrush, type BoxFaceId } from "../document/brushes";
|
import { BOX_FACE_IDS, DEFAULT_BOX_BRUSH_SIZE, type BoxBrush, type BoxFaceId } from "../document/brushes";
|
||||||
import { applyBoxBrushFaceUvsToGeometry } from "../geometry/box-face-uvs";
|
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 { DEFAULT_GRID_SIZE, snapValueToGrid, snapVec3ToGrid } from "../geometry/grid-snapping";
|
||||||
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
|
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
|
||||||
import type { MaterialDef } from "../materials/starter-material-library";
|
import type { MaterialDef } from "../materials/starter-material-library";
|
||||||
@@ -1680,6 +1682,18 @@ export class ViewportHost {
|
|||||||
const loadedAsset = this.loadedModelAssets[modelInstance.assetId];
|
const loadedAsset = this.loadedModelAssets[modelInstance.assetId];
|
||||||
const renderGroup = createModelInstanceRenderGroup(modelInstance, asset, loadedAsset, selected);
|
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.modelGroup.add(renderGroup);
|
||||||
this.modelRenderObjects.set(modelInstance.id, 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