diff --git a/AGENTS.md b/AGENTS.md index 24943a26..0eddc3fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,19 @@ These defaults are intentionally fixed for the early slices unless a later slice - placed imported models are **model instances**, not typed entities - keep model instances in a document collection separate from `entities` +### Imported model collision scope + +- collision authoring for imported models belongs on `modelInstances`, not on asset records +- the canonical source of truth is authored collision settings, not cooked collider bytes +- generated collider data may be cached or rebuilt, but it is derived from: + - imported model asset geometry + - model instance transform + - authored collision settings +- for imported-model collider support beyond simple boxes, prefer integrating a real collision/query library such as Rapier over inventing custom broad-phase/narrow-phase code in-house +- let the collision/query layer own broad-phase and narrow-phase pruning instead of re-implementing that manually in app code +- do not turn this slice into a full physics sandbox or general rigidbody architecture rewrite unless the roadmap explicitly asks for that +- near-term slices may adapt or replace the current handcrafted runner collision path where necessary so brush and imported-model colliders can participate in one coherent collision/query system + ### Runtime interaction scope - keep trigger/action/target links explicit and typed @@ -161,6 +174,7 @@ Use these terms consistently: - **Texture**: image resource backing material channels - **Asset**: imported external resource, usually GLB/GLTF or audio and related media - **Model Instance**: placed scene instance of an imported asset +- **Collider**: runtime collision representation derived from brushes or imported models - **Prefab**: reusable asset/entity package placeable in scenes - **Entity**: typed scene object with runtime/editor semantics - **Project Package**: portable editable bundle containing canonical scene JSON plus referenced assets @@ -168,7 +182,7 @@ Use these terms consistently: - **Runner**: browser runtime that loads and plays scenes - **Viewport**: editor rendering surface - **Command**: undoable state transition -- **Tool**: editor interaction mode such as select, move, box-create, face-edit +- **Tool**: editor interaction mode such as select, create, transform, or face-edit - **Build**: deterministic transformation from document -> runtime scene data - **Export**: downstream transformation to deployable or interchange deliverables such as runner packages or optional later GLB @@ -315,6 +329,21 @@ Do not store raw three.js objects inside canonical document state. - entity validation must happen at document/build boundaries - model instances remain separate from entities +### Imported model collision rules + +- collision settings for imported models live on `modelInstances` +- supported collision modes must be explicit and typed +- generated collision geometry is derived data, not the canonical source document +- collision debug visibility is editor/runtime UI state driven by authored settings, not a hidden renderer-only toggle +- avoid implicit “always collide with render mesh” behavior +- if broad-phase/narrow-phase pruning or non-box collider support is needed, prefer Rapier over ad hoc custom collision math +- collision modes mean: + - `none` = no collider + - `terrain` = heightfield collider, static only + - `static` = triangle mesh collider, fixed only + - `dynamic` = convex decomposition into compound collider, dynamic/kinematic capable + - `simple` = one cheap primitive or one convex hull + --- ## Performance rules diff --git a/CHAT_CONTEXT.md b/CHAT_CONTEXT.md index ae8ded13..062bb601 100644 --- a/CHAT_CONTEXT.md +++ b/CHAT_CONTEXT.md @@ -113,6 +113,10 @@ Per face, keep explicit UV transform values such as: - imported assets live in the asset registry - placed imported models live in `modelInstances` - typed scene objects like `PlayerStart`, `TriggerVolume`, or lights live in `entities` +- collision authoring for imported models belongs on `modelInstances`, not asset records +- generated imported-model collider data should be derived from asset geometry + instance transform + authored settings +- for imported-model collider types beyond simple boxes, prefer a Rapier-backed collision/query layer over extending the handcrafted collision code indefinitely +- broad-phase and narrow-phase pruning should come from that collision/query layer, not custom app code ### Interaction scope diff --git a/architecture.md b/architecture.md index f311c9a6..50ad12d2 100644 --- a/architecture.md +++ b/architecture.md @@ -167,6 +167,21 @@ Placed imported models are not typed entities. - placed scene instances of those assets live in `modelInstances` - typed runtime/editor objects such as `PlayerStart` or `TriggerVolume` live in `entities` +### Imported model collision scope + +Imported model collision should extend the current architecture, not replace it. + +- authored collision settings belong on `modelInstances` +- generated collider data is derived from: + - imported model asset geometry + - model instance transform + - authored collision settings +- do not make cooked/generated collider bytes the canonical source document by default +- for non-box imported-model collider support and broad-phase/narrow-phase pruning, prefer integrating Rapier as a collision/query subsystem over inventing custom collision code in-house +- broad-phase and narrow-phase pair pruning should be delegated to that collision/query layer rather than re-implemented manually in app code +- do not turn this into a full general-purpose physics sandbox unless the product actually needs one +- near-term slices may adapt or replace the current handcrafted runner collision path so brushes and imported models can participate in one coherent collision/query system + ### Trigger/action/target scope Keep interaction links explicit and typed. @@ -344,6 +359,8 @@ interface SceneDocument { } ``` +Imported model collision settings should be represented canonically on the relevant `ModelInstance` records or a tightly related typed sub-structure, not as hidden renderer/runtime state. + `WorldSettings` is the correct home for: - background mode and background color/gradient @@ -525,6 +542,14 @@ Responsibilities: - `AnimationSystem` - `RuntimeUIBridge` +### Collision strategy progression + +Near-term runtime collision is intentionally narrower than a full game-engine physics stack. + +- early runner slices use deterministic explicit collision data for navigation and authored interactions +- imported model collision should first plug into that same runtime build/query path +- only introduce a broader physics world when dynamic rigid bodies, richer contact response, or other product requirements make it necessary + ### Navigation modes Initial modes: @@ -697,6 +722,59 @@ There should be a clear distinction between: - model instance - prefab definition +### Imported model collision authoring + +Imported models need an explicit authored collision path that coexists with brush collision. + +Recommended authored settings shape: + +```ts +type ModelInstanceCollisionMode = + | "none" + | "terrain" + | "static" + | "dynamic" + | "simple"; + +interface ModelInstanceCollisionSettings { + mode: ModelInstanceCollisionMode; + visible: boolean; + simpleShape?: "box" | "sphere" | "capsule" | "convexHull"; + terrainResolution?: number; + dynamicQuality?: "low" | "medium" | "high"; +} +``` + +Rules: + +- these settings are canonical authoring data +- generated collider geometry is derived/cacheable data +- collision generation is triggered by authored settings, not hidden import-time guesses +- `modelInstances` remain separate from `entities` +- collision debug rendering should be visually inspectable in editor and runner when enabled +- preferred implementation path is: + - Rapier 3D WASM provides the collision/query layer + - Rapier owns broad-phase and narrow-phase pruning + - the editor document owns authored collider settings + - generated collider geometry/handles are derived runtime/cache data +- collision modes should mean exactly: + - `none` = no collider + - `terrain` = heightfield collider, static only + - `static` = triangle mesh collider, fixed only + - `dynamic` = convex decomposition into compound collider, dynamic/kinematic capable + - `simple` = one cheap primitive or one convex hull +- initial support may be staged, but unsupported modes must fail clearly instead of silently degrading to random approximations + +### Collision strategy progression + +- early slices used handcrafted box-based player collision because only brush boxes existed +- once imported-model colliders require terrain, trimesh, convex hull, or compound collision, the preferred next step is a Rapier-backed collision/query layer +- this does not require a full gameplay physics sandbox in the same slice +- a valid intermediate state is: + - fixed/queryable colliders for brushes and imported models + - existing first-person movement adapted to query against Rapier-backed colliders + - dynamic collider generation represented canonically even if full rigidbody simulation lands later + --- ## `serialization` @@ -905,11 +983,19 @@ SceneDocument -> validate -> resolve assets/materials/entities/model instances -> build brush meshes +-> build model-instance collider data -> build colliders -> build runtime entity graph -> assemble runtime scene package ``` +In the near-term architecture, `build colliders` may include a mix of: + +- brush-derived colliders +- imported-model-instance-derived colliders + +without requiring a full general-purpose physics world. + ### Project package build stages ```txt diff --git a/package.json b/package.json index 17b86211..ccb815c1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:e2e": "playwright test" }, "dependencies": { + "@dimforge/rapier3d-compat": "^0.19.3", "postprocessing": "^6.39.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/prompts-lite.txt b/prompts-lite.txt index b3c4d82c..83429a9b 100644 --- a/prompts-lite.txt +++ b/prompts-lite.txt @@ -341,6 +341,46 @@ Do not: --- +## Slice Body 8B — Imported model collider authoring + +```text +Relevant docs to inspect after the common preamble: +- architecture.md: model instances, assets, runtime build, collision scope +- roadmap.md: Slice 3.1B +- testing.md: serialization, geometry/runtime, collision coverage + +Implement Slice 3.1B. + +Requirements: +- canonical collision settings on model instances +- inspector selector for: + - none + - terrain + - static + - dynamic + - simple +- collision visibility/debug toggle +- generated collider data derived from model asset geometry + model instance transform + authored settings +- Rapier-backed collision/query integration for broad-phase/narrow-phase handling +- runtime integration with the existing runner collision path +- debug visualization for generated collision where enabled +- explicit collider semantics: + - none = no collider + - terrain = heightfield collider, static only + - static = triangle mesh collider, fixed only + - dynamic = convex decomposition into compound collider, dynamic/kinematic capable + - simple = one cheap primitive or one convex hull + +Constraints: +- do not merge model instances into entities +- use Rapier instead of inventing custom broad-phase/narrow-phase code +- do not rewrite the runner into a full gameplay-physics sandbox in this slice +- keep authored settings canonical and generated collider data derived/cacheable +- if terrain/dynamic support is partial, fail clearly and document it +``` + +--- + ## Slice Body 9 — Animation playback ```text diff --git a/prompts.txt b/prompts.txt index 59278ed4..f7a8b0b4 100644 --- a/prompts.txt +++ b/prompts.txt @@ -698,6 +698,100 @@ Please implement fully and report: --- +## Prompt 8B — Imported model collider authoring + +```text +You are implementing Slice 3.1B — Imported model collider authoring. + +Read and follow: +- AGENTS.md +- CHAT_CONTEXT.md +- architecture.md +- roadmap.md +- testing.md + +Before coding: +- Inspect the current repo and extend the existing implementation. +- Build on the existing model asset / model instance path. Do not merge model instances into entities. +- Integrate with the existing runtime scene build, but use Rapier 3D WASM as the collision/query layer for this slice instead of inventing custom broad-phase/narrow-phase logic. +- Do not turn this slice into a full gameplay-physics sandbox or broad entity rigidbody rewrite. +- If you change persisted schema, update versioning, migrations, and at least one compatibility test. + +Current goal: +Make imported model instances participate in collision by adding explicit authored collider settings, generated collider data, Rapier-backed collision/query integration, and runtime/debug support that fits the current codebase. + +Requirements: +- Add canonical collision settings to model instances. +- Add an inspector UI for imported model instances with an explicit collision mode selector: + - none + - terrain + - static + - dynamic + - simple +- Add a collision visibility/debug toggle for model instances. +- Generated collider data must be derived from: + - imported model asset geometry + - model instance transform + - authored collision settings +- Keep the authored settings canonical. Do not make cooked/generated collider bytes the source of truth in the document. +- If you add caching, keep it explicit and derived. +- Use Rapier 3D WASM for broad-phase/narrow-phase pruning and collision/query evaluation in this slice. + +Collision-mode expectations: +- `none`: + - no collider +- `terrain`: + - heightfield collider + - static only + - if the source mesh cannot support the chosen terrain path cleanly, fail clearly with diagnostics instead of silently pretending it worked +- `static`: + - triangle mesh collider + - fixed only +- `dynamic`: + - convex decomposition into a compound collider + - dynamic/kinematic capable representation + - this slice does not require a full general rigidbody gameplay system, but the generated representation must be explicit and correct + - if actual runtime behavior is limited in this slice, document exactly what works now +- `simple`: + - one cheap primitive or one convex hull + - keep the initial option set small and explicit if needed + +Runtime/build requirements: +- Extend runtime scene build so imported model colliders participate in one coherent Rapier-backed collision/query path together with brush-authored world collision. +- Broad-phase and narrow-phase handling should come from Rapier, not app-specific custom collision pruning code. +- Keep first-person traversal/collision working. It is acceptable to adapt the current player collision path to query against Rapier-backed colliders instead of the handwritten box-only collision path. +- Add debug visualization for generated imported-model collision in editor and/or runner when the visibility toggle is enabled. +- Keep the implementation understandable and explicit. + +Important: +- Do not build a giant general-purpose physics framework around Rapier in this slice. +- Do not add arbitrary rigidbody components to every scene object just because Rapier is present. +- Do not hide collider generation behind import-time magic; it should be driven by authored collision settings. +- Do not treat the render mesh as automatically equal to the collision mesh in all modes. +- Keep model-instance collision separate from brush collision and separate from typed entity schemas. + +Testing expectations: +- Round-trip tests for model-instance collision settings +- Migration/compatibility test if schema changes +- Validation/build tests for invalid or unsupported collision-mode assumptions +- Geometry/runtime tests for generated collider outputs where practical +- Runtime/domain tests proving the generated colliders participate in the actual Rapier-backed collision/query path +- Runner/domain tests proving imported-model collision works together with existing brush/world collision +- Browser/e2e or manual verification for collision debug visibility if practical + +Please implement fully and report: +- model-instance collision schema/settings +- supported behavior for each collision mode +- how generated collider data is represented and rebuilt/cached +- how Rapier is integrated into the current runner collision/query architecture +- how broad-phase/narrow-phase responsibilities are handled +- how debug visibility works +- tests added or updated +- what limitations remain, especially for `terrain` and `dynamic` +``` + +--- + ## Prompt 9 — Animation playback ```text diff --git a/roadmap.md b/roadmap.md index 8dbdfdd9..449098e1 100644 --- a/roadmap.md +++ b/roadmap.md @@ -342,6 +342,40 @@ The tool becomes more than brush-only by supporting imported assets, authored li --- +### Slice 3.1B — Imported model collider authoring + +#### Deliverables + +- canonical collision settings on model instances +- explicit collision mode selector for: + - none + - terrain + - static + - dynamic + - simple +- collision visibility/debug toggle +- generated collider data derived from imported model geometry plus authored settings +- Rapier-backed collision/query integration for imported-model colliders and broad-phase/narrow-phase handling +- runtime build support for imported-model colliders in the runner collision path +- editor/runner debug visualization of generated collision where enabled + +#### Acceptance criteria + +- author can choose a collision mode per model instance in the inspector +- imported models can participate in runner collision without hand-authored invisible geometry +- collider modes mean exactly: + - none = no collider + - terrain = heightfield collider, static only + - static = triangle mesh collider, fixed only + - dynamic = convex decomposition into compound collider, dynamic/kinematic capable + - simple = one cheap primitive or one convex hull +- generated collision survives save/load through canonical settings and deterministic rebuild behavior +- broad-phase and narrow-phase pruning are handled by Rapier instead of app-specific custom collision code +- the slice does not require a full gameplay-physics sandbox +- unsupported or partial modes fail clearly instead of silently pretending to work + +--- + ### Slice 3.2 — Local lights and skyboxes #### Deliverables @@ -593,6 +627,7 @@ A slice is complete only when: - per-face UV persistence - picking accuracy - collision generation from brush data +- collision generation from imported model data - imported asset/material compatibility - browser audio unlock behavior - input edge cases across browsers diff --git a/src/assets/model-instances.ts b/src/assets/model-instances.ts index 13030751..84f33354 100644 --- a/src/assets/model-instances.ts +++ b/src/assets/model-instances.ts @@ -2,6 +2,15 @@ import { createOpaqueId } from "../core/ids"; import type { Vec3 } from "../core/vector"; import type { ModelAssetRecord } from "./project-assets"; +export const MODEL_INSTANCE_COLLISION_MODES = ["none", "terrain", "static", "dynamic", "simple"] as const; + +export type ModelInstanceCollisionMode = (typeof MODEL_INSTANCE_COLLISION_MODES)[number]; + +export interface ModelInstanceCollisionSettings { + mode: ModelInstanceCollisionMode; + visible: boolean; +} + export interface ModelInstance { id: string; kind: "modelInstance"; @@ -10,6 +19,7 @@ export interface ModelInstance { position: Vec3; rotationDegrees: Vec3; scale: Vec3; + collision: ModelInstanceCollisionSettings; animationClipName?: string; animationAutoplay?: boolean; } @@ -32,6 +42,11 @@ export const DEFAULT_MODEL_INSTANCE_SCALE: Vec3 = { z: 1 }; +export const DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS: ModelInstanceCollisionSettings = { + mode: "none", + visible: false +}; + function cloneVec3(vector: Vec3): Vec3 { return { x: vector.x, @@ -44,6 +59,39 @@ function areVec3Equal(left: Vec3, right: Vec3): boolean { return left.x === right.x && left.y === right.y && left.z === right.z; } +export function isModelInstanceCollisionMode(value: unknown): value is ModelInstanceCollisionMode { + return MODEL_INSTANCE_COLLISION_MODES.includes(value as ModelInstanceCollisionMode); +} + +export function createModelInstanceCollisionSettings( + overrides: Partial = {} +): ModelInstanceCollisionSettings { + const mode = overrides.mode ?? DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS.mode; + + if (!isModelInstanceCollisionMode(mode)) { + throw new Error("Model instance collision mode must be a supported value."); + } + + const visible = overrides.visible ?? DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS.visible; + + if (typeof visible !== "boolean") { + throw new Error("Model instance collision visibility must be a boolean."); + } + + return { + mode, + visible + }; +} + +export function cloneModelInstanceCollisionSettings(settings: ModelInstanceCollisionSettings): ModelInstanceCollisionSettings { + return createModelInstanceCollisionSettings(settings); +} + +export function areModelInstanceCollisionSettingsEqual(left: ModelInstanceCollisionSettings, right: ModelInstanceCollisionSettings): boolean { + return left.mode === right.mode && left.visible === right.visible; +} + export function normalizeModelInstanceName(name: string | null | undefined): string | undefined { if (name === undefined || name === null) { return undefined; @@ -68,11 +116,15 @@ function assertPositiveFiniteVec3(vector: Vec3, label: string) { } export function createModelInstance( - overrides: Partial> & Pick + overrides: Partial< + Pick + > & + Pick ): ModelInstance { const position = cloneVec3(overrides.position ?? DEFAULT_MODEL_INSTANCE_POSITION); const rotationDegrees = cloneVec3(overrides.rotationDegrees ?? DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES); const scale = cloneVec3(overrides.scale ?? DEFAULT_MODEL_INSTANCE_SCALE); + const collision = cloneModelInstanceCollisionSettings(overrides.collision ?? DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS); if (overrides.assetId.trim().length === 0) { throw new Error("Model instance assetId must be a non-empty string."); @@ -90,6 +142,7 @@ export function createModelInstance( position, rotationDegrees, scale, + collision, animationClipName: overrides.animationClipName, animationAutoplay: overrides.animationAutoplay }; @@ -128,6 +181,7 @@ export function areModelInstancesEqual(left: ModelInstance, right: ModelInstance areVec3Equal(left.position, right.position) && areVec3Equal(left.rotationDegrees, right.rotationDegrees) && areVec3Equal(left.scale, right.scale) && + areModelInstanceCollisionSettingsEqual(left.collision, right.collision) && left.animationClipName === right.animationClipName && left.animationAutoplay === right.animationAutoplay ); diff --git a/src/document/migrate-scene-document.ts b/src/document/migrate-scene-document.ts index 155808eb..5d3a826b 100644 --- a/src/document/migrate-scene-document.ts +++ b/src/document/migrate-scene-document.ts @@ -1,7 +1,10 @@ import { createStarterMaterialRegistry, type MaterialDef, type MaterialPattern } from "../materials/starter-material-library"; import { + createModelInstanceCollisionSettings, createModelInstance, + isModelInstanceCollisionMode, normalizeModelInstanceName, + type ModelInstanceCollisionSettings, type ModelInstance } from "../assets/model-instances"; import { @@ -51,6 +54,7 @@ import { FACE_MATERIALS_SCENE_DOCUMENT_VERSION, FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION, FOUNDATION_SCENE_DOCUMENT_VERSION, + IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION, LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION, MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION, RUNNER_V1_SCENE_DOCUMENT_VERSION, @@ -503,6 +507,23 @@ function readAssets(value: unknown): SceneDocument["assets"] { return assets; } +function readModelInstanceCollisionSettings(value: unknown, label: string): ModelInstanceCollisionSettings { + if (value === undefined) { + return createModelInstanceCollisionSettings(); + } + + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + + const mode = readOptionalAllowedValue(value.mode, `${label}.mode`, "none", isModelInstanceCollisionMode); + + return createModelInstanceCollisionSettings({ + mode, + visible: readOptionalBoolean(value.visible, `${label}.visible`, false) + }); +} + function readModelInstance(value: unknown, label: string, assets: SceneDocument["assets"]): ModelInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); @@ -526,6 +547,7 @@ function readModelInstance(value: unknown, label: string, assets: SceneDocument[ position: readVec3(value.position, `${label}.position`), rotationDegrees: readVec3(value.rotationDegrees, `${label}.rotationDegrees`), scale: readVec3(value.scale, `${label}.scale`), + collision: readModelInstanceCollisionSettings(value.collision, `${label}.collision`), animationClipName: (() => { const raw = expectOptionalString(value.animationClipName, `${label}.animationClipName`); if (raw === undefined) return undefined; @@ -1442,6 +1464,25 @@ export function migrateSceneDocument(source: unknown): SceneDocument { }; } + // v15 -> v16: model instances gained authored collider settings. + if (source.version === ENTITY_NAMES_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + const assets = readAssets(source.assets); + + return { + version: IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets, + brushes: readBrushes(source.brushes, materials, false), + modelInstances: readModelInstances(source.modelInstances, assets), + entities: readEntities(source.entities, { legacySoundEmitter: false }), + interactionLinks: readInteractionLinks(source.interactionLinks) + }; + } + // v14 -> v15: entities gained an optional authored name field. if (source.version === 14) { const materials = readMaterialRegistry(source.materials, "materials"); diff --git a/src/document/scene-document-validation.ts b/src/document/scene-document-validation.ts index 0cd27417..07a52520 100644 --- a/src/document/scene-document-validation.ts +++ b/src/document/scene-document-validation.ts @@ -6,6 +6,7 @@ import { type ProjectAssetRecord } from "../assets/project-assets"; import type { ModelInstance } from "../assets/model-instances"; +import { isModelInstanceCollisionMode } from "../assets/model-instances"; import { type InteractableEntity, type PointLightEntity, @@ -646,6 +647,28 @@ function validateModelInstance(modelInstance: ModelInstance, path: string, docum diagnostics.push(createDiagnostic("error", "invalid-model-instance-scale", "Model instance scales must remain finite and positive on every axis.", `${path}.scale`)); } + if (!isModelInstanceCollisionMode(modelInstance.collision.mode)) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-instance-collision-mode", + "Model instance collision mode must be one of none, terrain, static, dynamic, or simple.", + `${path}.collision.mode` + ) + ); + } + + if (!isBoolean(modelInstance.collision.visible)) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-instance-collision-visibility", + "Model instance collision visibility must be a boolean.", + `${path}.collision.visible` + ) + ); + } + const asset = document.assets[modelInstance.assetId]; if (asset === undefined) { diff --git a/src/document/scene-document.ts b/src/document/scene-document.ts index 6d3860b0..01490180 100644 --- a/src/document/scene-document.ts +++ b/src/document/scene-document.ts @@ -6,7 +6,8 @@ import type { InteractionLink } from "../interactions/interaction-links"; import { cloneMaterialRegistry, createStarterMaterialRegistry, type MaterialDef } from "../materials/starter-material-library"; import { createDefaultWorldSettings, type WorldSettings } from "./world-settings"; -export const SCENE_DOCUMENT_VERSION = 15 as const; +export const SCENE_DOCUMENT_VERSION = 16 as const; +export const IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION = 16 as const; export const ENTITY_NAMES_SCENE_DOCUMENT_VERSION = 15 as const; export const SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION = 13 as const; export const ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION = 12 as const; diff --git a/src/geometry/model-instance-collider-generation.ts b/src/geometry/model-instance-collider-generation.ts new file mode 100644 index 00000000..f6d64c71 --- /dev/null +++ b/src/geometry/model-instance-collider-generation.ts @@ -0,0 +1,677 @@ +import { + Euler, + Group, + MathUtils, + Matrix4, + Mesh, + Quaternion, + Vector3, + type BufferAttribute, + type BufferGeometry +} from "three"; + +import type { LoadedModelAsset } from "../assets/gltf-model-import"; +import type { ModelInstance, ModelInstanceCollisionMode } from "../assets/model-instances"; +import type { ModelAssetRecord } from "../assets/project-assets"; +import type { Vec3 } from "../core/vector"; + +const TERRAIN_GRID_EPSILON = 1e-4; +const DYNAMIC_TRIANGLE_TARGET = 48; +const DYNAMIC_SPLIT_DEPTH_LIMIT = 3; + +interface LocalTriangle { + readonly a: Vector3; + readonly b: Vector3; + readonly c: Vector3; +} + +interface LocalTriangleCluster { + readonly triangles: LocalTriangle[]; +} + +export interface GeneratedColliderBounds { + min: Vec3; + max: Vec3; +} + +export interface GeneratedModelColliderTransform { + position: Vec3; + rotationDegrees: Vec3; + scale: Vec3; +} + +interface GeneratedModelColliderBase { + source: "modelInstance"; + instanceId: string; + assetId: string; + mode: ModelInstanceCollisionMode; + visible: boolean; + transform: GeneratedModelColliderTransform; + localBounds: GeneratedColliderBounds; + worldBounds: GeneratedColliderBounds; +} + +export interface GeneratedModelBoxCollider extends GeneratedModelColliderBase { + kind: "box"; + center: Vec3; + size: Vec3; +} + +export interface GeneratedModelTriMeshCollider extends GeneratedModelColliderBase { + kind: "trimesh"; + vertices: Float32Array; + indices: Uint32Array; + triangleCount: number; +} + +export interface GeneratedModelHeightfieldCollider extends GeneratedModelColliderBase { + kind: "heightfield"; + rows: number; + cols: number; + heights: Float32Array; + minX: number; + maxX: number; + minZ: number; + maxZ: number; +} + +export interface GeneratedModelCompoundColliderPiece { + id: string; + points: Float32Array; + localBounds: GeneratedColliderBounds; +} + +export interface GeneratedModelCompoundCollider extends GeneratedModelColliderBase { + kind: "compound"; + pieces: GeneratedModelCompoundColliderPiece[]; + decomposition: "spatial-bisect"; + runtimeBehavior: "fixedQueryOnly"; +} + +export type GeneratedModelCollider = + | GeneratedModelBoxCollider + | GeneratedModelTriMeshCollider + | GeneratedModelHeightfieldCollider + | GeneratedModelCompoundCollider; + +export class ModelColliderGenerationError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "ModelColliderGenerationError"; + this.code = code; + } +} + +function cloneVec3(vector: Vec3): Vec3 { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} + +function vector3ToVec3(vector: Vector3): Vec3 { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} + +function createBounds(min: Vector3, max: Vector3): GeneratedColliderBounds { + return { + min: vector3ToVec3(min), + max: vector3ToVec3(max) + }; +} + +function createModelTransform(modelInstance: ModelInstance): GeneratedModelColliderTransform { + return { + position: cloneVec3(modelInstance.position), + rotationDegrees: cloneVec3(modelInstance.rotationDegrees), + scale: cloneVec3(modelInstance.scale) + }; +} + +function createModelTransformMatrix(modelInstance: ModelInstance): Matrix4 { + const rotation = new Euler( + MathUtils.degToRad(modelInstance.rotationDegrees.x), + MathUtils.degToRad(modelInstance.rotationDegrees.y), + MathUtils.degToRad(modelInstance.rotationDegrees.z), + "XYZ" + ); + const quaternion = new Quaternion().setFromEuler(rotation); + + return new Matrix4().compose( + new Vector3(modelInstance.position.x, modelInstance.position.y, modelInstance.position.z), + quaternion, + new Vector3(modelInstance.scale.x, modelInstance.scale.y, modelInstance.scale.z) + ); +} + +function computeBoundsFromPoints(points: Iterable): GeneratedColliderBounds { + const min = new Vector3(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY); + const max = new Vector3(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY); + let hasPoint = false; + + for (const point of points) { + hasPoint = true; + min.min(point); + max.max(point); + } + + if (!hasPoint) { + throw new ModelColliderGenerationError("missing-model-collider-geometry", "The selected model does not contain any collision-capable geometry."); + } + + return createBounds(min, max); +} + +function computeBoundsFromFloat32Points(points: Float32Array): GeneratedColliderBounds { + if (points.length < 3) { + throw new ModelColliderGenerationError("missing-model-collider-geometry", "The selected model does not contain any collision-capable geometry."); + } + + const min = new Vector3(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY); + const max = new Vector3(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY); + + for (let index = 0; index < points.length; index += 3) { + min.x = Math.min(min.x, points[index]); + min.y = Math.min(min.y, points[index + 1]); + min.z = Math.min(min.z, points[index + 2]); + max.x = Math.max(max.x, points[index]); + max.y = Math.max(max.y, points[index + 1]); + max.z = Math.max(max.z, points[index + 2]); + } + + return createBounds(min, max); +} + +function computeWorldBoundsFromLocalBox(localBounds: GeneratedColliderBounds, modelMatrix: Matrix4): GeneratedColliderBounds { + const min = localBounds.min; + const max = localBounds.max; + const corners = [ + new Vector3(min.x, min.y, min.z), + new Vector3(min.x, min.y, max.z), + new Vector3(min.x, max.y, min.z), + new Vector3(min.x, max.y, max.z), + new Vector3(max.x, min.y, min.z), + new Vector3(max.x, min.y, max.z), + new Vector3(max.x, max.y, min.z), + new Vector3(max.x, max.y, max.z) + ]; + + return computeBoundsFromPoints(corners.map((corner) => corner.applyMatrix4(modelMatrix))); +} + +function readIndexedVertex(position: BufferAttribute, index: number, matrix: Matrix4): Vector3 { + return new Vector3(position.getX(index), position.getY(index), position.getZ(index)).applyMatrix4(matrix); +} + +function getMeshGeometry(object: Group | Mesh): BufferGeometry | null { + const maybeMesh = object as Mesh & { isMesh?: boolean }; + + if (maybeMesh.isMesh !== true) { + return null; + } + + return maybeMesh.geometry; +} + +function collectMeshTriangleClusters(template: Group): LocalTriangleCluster[] { + template.updateMatrixWorld(true); + const clusters: LocalTriangleCluster[] = []; + + template.traverse((object) => { + const geometry = getMeshGeometry(object as Group | Mesh); + + if (geometry === null) { + return; + } + + const position = geometry.getAttribute("position"); + + if (position === undefined || position.itemSize < 3 || position.count < 3) { + return; + } + + const matrix = object.matrixWorld; + const index = geometry.getIndex(); + const triangles: LocalTriangle[] = []; + + if (index === null) { + for (let vertexIndex = 0; vertexIndex <= position.count - 3; vertexIndex += 3) { + triangles.push({ + a: readIndexedVertex(position, vertexIndex, matrix), + b: readIndexedVertex(position, vertexIndex + 1, matrix), + c: readIndexedVertex(position, vertexIndex + 2, matrix) + }); + } + } else { + for (let triangleIndex = 0; triangleIndex <= index.count - 3; triangleIndex += 3) { + triangles.push({ + a: readIndexedVertex(position, index.getX(triangleIndex), matrix), + b: readIndexedVertex(position, index.getX(triangleIndex + 1), matrix), + c: readIndexedVertex(position, index.getX(triangleIndex + 2), matrix) + }); + } + } + + if (triangles.length > 0) { + clusters.push({ + triangles + }); + } + }); + + return clusters; +} + +function flattenTriangleClusters(clusters: LocalTriangleCluster[]): LocalTriangle[] { + return clusters.flatMap((cluster) => cluster.triangles); +} + +function buildTriMeshBuffers(triangles: LocalTriangle[]): { vertices: Float32Array; indices: Uint32Array } { + const vertices = new Float32Array(triangles.length * 9); + const indices = new Uint32Array(triangles.length * 3); + let vertexOffset = 0; + + for (let triangleIndex = 0; triangleIndex < triangles.length; triangleIndex += 1) { + const triangle = triangles[triangleIndex]; + vertices[vertexOffset] = triangle.a.x; + vertices[vertexOffset + 1] = triangle.a.y; + vertices[vertexOffset + 2] = triangle.a.z; + vertices[vertexOffset + 3] = triangle.b.x; + vertices[vertexOffset + 4] = triangle.b.y; + vertices[vertexOffset + 5] = triangle.b.z; + vertices[vertexOffset + 6] = triangle.c.x; + vertices[vertexOffset + 7] = triangle.c.y; + vertices[vertexOffset + 8] = triangle.c.z; + indices[triangleIndex * 3] = triangleIndex * 3; + indices[triangleIndex * 3 + 1] = triangleIndex * 3 + 1; + indices[triangleIndex * 3 + 2] = triangleIndex * 3 + 2; + vertexOffset += 9; + } + + return { + vertices, + indices + }; +} + +function computeClusterCentroid(triangles: LocalTriangle[]): Vec3 { + const centroid = { + x: 0, + y: 0, + z: 0 + }; + let pointCount = 0; + + for (const triangle of triangles) { + centroid.x += triangle.a.x + triangle.b.x + triangle.c.x; + centroid.y += triangle.a.y + triangle.b.y + triangle.c.y; + centroid.z += triangle.a.z + triangle.b.z + triangle.c.z; + pointCount += 3; + } + + return { + x: centroid.x / pointCount, + y: centroid.y / pointCount, + z: centroid.z / pointCount + }; +} + +function getTriangleBounds(triangles: LocalTriangle[]): GeneratedColliderBounds { + return computeBoundsFromPoints(triangles.flatMap((triangle) => [triangle.a, triangle.b, triangle.c])); +} + +type TriangleClusterSplit = + | { + kind: "leaf"; + triangles: LocalTriangle[]; + } + | { + kind: "split"; + left: LocalTriangle[]; + right: LocalTriangle[]; + }; + +function splitTriangleCluster(triangles: LocalTriangle[], depth: number): TriangleClusterSplit { + if (triangles.length <= DYNAMIC_TRIANGLE_TARGET || depth >= DYNAMIC_SPLIT_DEPTH_LIMIT) { + return { + kind: "leaf", + triangles + }; + } + + const bounds = getTriangleBounds(triangles); + const size = { + x: bounds.max.x - bounds.min.x, + y: bounds.max.y - bounds.min.y, + z: bounds.max.z - bounds.min.z + }; + const splitAxis = size.x >= size.y && size.x >= size.z ? "x" : size.y >= size.z ? "y" : "z"; + const sortedTriangles = [...triangles].sort((left, right) => computeClusterCentroid([left])[splitAxis] - computeClusterCentroid([right])[splitAxis]); + const splitIndex = Math.floor(sortedTriangles.length * 0.5); + + if (splitIndex <= 0 || splitIndex >= sortedTriangles.length) { + return { + kind: "leaf", + triangles + }; + } + + return { + kind: "split", + left: sortedTriangles.slice(0, splitIndex), + right: sortedTriangles.slice(splitIndex) + }; +} + +function collectConvexHullPointClouds(cluster: LocalTriangle[], depth = 0): Float32Array[] { + const split = splitTriangleCluster(cluster, depth); + + if (split.kind === "leaf") { + return [dedupeTriangleClusterPoints(split.triangles)]; + } + + return [...collectConvexHullPointClouds(split.left, depth + 1), ...collectConvexHullPointClouds(split.right, depth + 1)]; +} + +function quantizeCoordinate(value: number): string { + return (Math.round(value / TERRAIN_GRID_EPSILON) * TERRAIN_GRID_EPSILON).toFixed(4); +} + +function dedupeTriangleClusterPoints(triangles: LocalTriangle[]): Float32Array { + const pointLookup = new Map(); + + for (const triangle of triangles) { + for (const point of [triangle.a, triangle.b, triangle.c]) { + const key = `${quantizeCoordinate(point.x)}:${quantizeCoordinate(point.y)}:${quantizeCoordinate(point.z)}`; + + if (!pointLookup.has(key)) { + pointLookup.set(key, { + x: point.x, + y: point.y, + z: point.z + }); + } + } + } + + if (pointLookup.size < 4) { + throw new ModelColliderGenerationError( + "unsupported-dynamic-model-collider", + "Dynamic collision requires volumetric geometry that can form at least one convex hull." + ); + } + + return new Float32Array( + Array.from(pointLookup.values()).flatMap((point) => [point.x, point.y, point.z]) + ); +} + +function buildSimpleBoxCollider(modelInstance: ModelInstance, asset: ModelAssetRecord): GeneratedModelBoxCollider { + const boundingBox = asset.metadata.boundingBox; + + if (boundingBox === null) { + throw new ModelColliderGenerationError( + "missing-model-collider-bounds", + `Model instance ${modelInstance.id} cannot use simple collision because the asset does not have a measurable bounding box.` + ); + } + + const localBounds = createBounds( + new Vector3(boundingBox.min.x, boundingBox.min.y, boundingBox.min.z), + new Vector3(boundingBox.max.x, boundingBox.max.y, boundingBox.max.z) + ); + + return { + source: "modelInstance", + instanceId: modelInstance.id, + assetId: modelInstance.assetId, + mode: "simple", + kind: "box", + visible: modelInstance.collision.visible, + transform: createModelTransform(modelInstance), + center: { + x: (boundingBox.min.x + boundingBox.max.x) * 0.5, + y: (boundingBox.min.y + boundingBox.max.y) * 0.5, + z: (boundingBox.min.z + boundingBox.max.z) * 0.5 + }, + size: cloneVec3(boundingBox.size), + localBounds, + worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance)) + }; +} + +function buildTriMeshCollider(modelInstance: ModelInstance, asset: ModelAssetRecord, loadedAsset: LoadedModelAsset | undefined): GeneratedModelTriMeshCollider { + if (loadedAsset === undefined) { + throw new ModelColliderGenerationError( + "missing-model-collider-geometry", + `Model instance ${modelInstance.id} cannot build ${modelInstance.collision.mode} collision until asset geometry has loaded.` + ); + } + + const triangles = flattenTriangleClusters(collectMeshTriangleClusters(loadedAsset.template)); + + if (triangles.length === 0) { + throw new ModelColliderGenerationError( + "missing-model-collider-geometry", + `Model instance ${modelInstance.id} cannot use ${modelInstance.collision.mode} collision because the asset has no mesh triangles.` + ); + } + + const buffers = buildTriMeshBuffers(triangles); + const localBounds = computeBoundsFromFloat32Points(buffers.vertices); + + return { + source: "modelInstance", + instanceId: modelInstance.id, + assetId: asset.id, + mode: "static", + kind: "trimesh", + visible: modelInstance.collision.visible, + transform: createModelTransform(modelInstance), + vertices: buffers.vertices, + indices: buffers.indices, + triangleCount: triangles.length, + localBounds, + worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance)) + }; +} + +function buildTerrainCollider( + modelInstance: ModelInstance, + asset: ModelAssetRecord, + loadedAsset: LoadedModelAsset | undefined +): GeneratedModelHeightfieldCollider { + if (loadedAsset === undefined) { + throw new ModelColliderGenerationError( + "missing-model-collider-geometry", + `Model instance ${modelInstance.id} cannot build terrain collision until asset geometry has loaded.` + ); + } + + const triangles = flattenTriangleClusters(collectMeshTriangleClusters(loadedAsset.template)); + + if (triangles.length === 0) { + throw new ModelColliderGenerationError( + "missing-model-collider-geometry", + `Model instance ${modelInstance.id} cannot use terrain collision because the asset has no mesh triangles.` + ); + } + + const heightLookup = new Map(); + const xValues = new Map(); + const zValues = new Map(); + + for (const triangle of triangles) { + for (const point of [triangle.a, triangle.b, triangle.c]) { + const xKey = quantizeCoordinate(point.x); + const zKey = quantizeCoordinate(point.z); + const key = `${xKey}:${zKey}`; + const previousPoint = heightLookup.get(key); + + if (previousPoint !== undefined && Math.abs(previousPoint.y - point.y) > TERRAIN_GRID_EPSILON) { + throw new ModelColliderGenerationError( + "unsupported-terrain-model-collider", + `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is not a single-valued heightfield over X/Z.` + ); + } + + heightLookup.set(key, { + x: point.x, + y: point.y, + z: point.z + }); + xValues.set(xKey, point.x); + zValues.set(zKey, point.z); + } + } + + const sortedX = Array.from(xValues.values()).sort((left, right) => left - right); + const sortedZ = Array.from(zValues.values()).sort((left, right) => left - right); + + if (sortedX.length < 2 || sortedZ.length < 2) { + throw new ModelColliderGenerationError( + "unsupported-terrain-model-collider", + `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh does not form a regular X/Z grid.` + ); + } + + const expectedTriangleCount = (sortedX.length - 1) * (sortedZ.length - 1) * 2; + + if (triangles.length !== expectedTriangleCount) { + throw new ModelColliderGenerationError( + "unsupported-terrain-model-collider", + `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is not a clean regular-grid terrain surface.` + ); + } + + const heights = new Float32Array(sortedX.length * sortedZ.length); + + for (let zIndex = 0; zIndex < sortedZ.length; zIndex += 1) { + for (let xIndex = 0; xIndex < sortedX.length; xIndex += 1) { + const key = `${quantizeCoordinate(sortedX[xIndex])}:${quantizeCoordinate(sortedZ[zIndex])}`; + const point = heightLookup.get(key); + + if (point === undefined) { + throw new ModelColliderGenerationError( + "unsupported-terrain-model-collider", + `Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is missing one or more regular-grid height samples.` + ); + } + + heights[xIndex + zIndex * sortedX.length] = point.y; + } + } + + const localBounds = computeBoundsFromPoints( + Array.from(heightLookup.values(), (point) => new Vector3(point.x, point.y, point.z)) + ); + + return { + source: "modelInstance", + instanceId: modelInstance.id, + assetId: asset.id, + mode: "terrain", + kind: "heightfield", + visible: modelInstance.collision.visible, + transform: createModelTransform(modelInstance), + rows: sortedX.length, + cols: sortedZ.length, + heights, + minX: sortedX[0], + maxX: sortedX.at(-1) ?? sortedX[0], + minZ: sortedZ[0], + maxZ: sortedZ.at(-1) ?? sortedZ[0], + localBounds, + worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance)) + }; +} + +function buildDynamicCollider( + modelInstance: ModelInstance, + asset: ModelAssetRecord, + loadedAsset: LoadedModelAsset | undefined +): GeneratedModelCompoundCollider { + if (loadedAsset === undefined) { + throw new ModelColliderGenerationError( + "missing-model-collider-geometry", + `Model instance ${modelInstance.id} cannot build dynamic collision until asset geometry has loaded.` + ); + } + + const triangleClusters = collectMeshTriangleClusters(loadedAsset.template); + + if (triangleClusters.length === 0) { + throw new ModelColliderGenerationError( + "missing-model-collider-geometry", + `Model instance ${modelInstance.id} cannot use dynamic collision because the asset has no mesh triangles.` + ); + } + + const pieces = triangleClusters + .flatMap((cluster) => collectConvexHullPointClouds(cluster.triangles)) + .map((points, index) => ({ + id: `${modelInstance.id}-piece-${index + 1}`, + points, + localBounds: computeBoundsFromFloat32Points(points) + })); + + if (pieces.length === 0) { + throw new ModelColliderGenerationError( + "unsupported-dynamic-model-collider", + `Model instance ${modelInstance.id} could not derive any convex pieces for dynamic collision.` + ); + } + + const localBounds = computeBoundsFromPoints( + pieces.flatMap((piece) => { + const points: Vector3[] = []; + + for (let pointIndex = 0; pointIndex < piece.points.length; pointIndex += 3) { + points.push(new Vector3(piece.points[pointIndex], piece.points[pointIndex + 1], piece.points[pointIndex + 2])); + } + + return points; + }) + ); + + return { + source: "modelInstance", + instanceId: modelInstance.id, + assetId: asset.id, + mode: "dynamic", + kind: "compound", + visible: modelInstance.collision.visible, + transform: createModelTransform(modelInstance), + pieces, + decomposition: "spatial-bisect", + runtimeBehavior: "fixedQueryOnly", + localBounds, + worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance)) + }; +} + +export function buildGeneratedModelCollider( + modelInstance: ModelInstance, + asset: ModelAssetRecord, + loadedAsset?: LoadedModelAsset +): GeneratedModelCollider | null { + switch (modelInstance.collision.mode) { + case "none": + return null; + case "simple": + return buildSimpleBoxCollider(modelInstance, asset); + case "static": + return buildTriMeshCollider(modelInstance, asset, loadedAsset); + case "terrain": + return buildTerrainCollider(modelInstance, asset, loadedAsset); + case "dynamic": + return buildDynamicCollider(modelInstance, asset, loadedAsset); + } +} diff --git a/src/runtime-three/first-person-navigation-controller.ts b/src/runtime-three/first-person-navigation-controller.ts index 3797878c..bc70a76e 100644 --- a/src/runtime-three/first-person-navigation-controller.ts +++ b/src/runtime-three/first-person-navigation-controller.ts @@ -2,7 +2,7 @@ import { Euler, Vector3 } from "three"; import type { Vec3 } from "../core/vector"; -import { FIRST_PERSON_PLAYER_SHAPE, resolveFirstPersonMotion } from "./player-collision"; +import { FIRST_PERSON_PLAYER_SHAPE } from "./player-collision"; import type { NavigationController, RuntimeControllerContext } from "./navigation-controller"; const LOOK_SENSITIVITY = 0.0022; @@ -116,17 +116,22 @@ export class FirstPersonNavigationController implements NavigationController { this.verticalVelocity -= GRAVITY * dt; - const resolvedMotion = resolveFirstPersonMotion( + const resolvedMotion = this.context.resolveFirstPersonMotion( this.feetPosition, { x: horizontalX, y: this.verticalVelocity * dt, z: horizontalZ }, - FIRST_PERSON_PLAYER_SHAPE, - this.context.getRuntimeScene().colliders + FIRST_PERSON_PLAYER_SHAPE ); + if (resolvedMotion === null) { + this.updateCameraTransform(); + this.publishTelemetry(); + return; + } + this.feetPosition = resolvedMotion.feetPosition; this.grounded = resolvedMotion.grounded; diff --git a/src/runtime-three/navigation-controller.ts b/src/runtime-three/navigation-controller.ts index 0524586b..a9e611de 100644 --- a/src/runtime-three/navigation-controller.ts +++ b/src/runtime-three/navigation-controller.ts @@ -2,6 +2,7 @@ import type { PerspectiveCamera } from "three"; import type { Vec3 } from "../core/vector"; +import type { FirstPersonPlayerShape, ResolvedPlayerMotion } from "./player-collision"; import type { RuntimeNavigationMode, RuntimeSceneDefinition, RuntimeSpawnPoint } from "./runtime-scene-build"; export interface FirstPersonTelemetry { @@ -16,6 +17,7 @@ export interface RuntimeControllerContext { camera: PerspectiveCamera; domElement: HTMLCanvasElement; getRuntimeScene(): RuntimeSceneDefinition; + resolveFirstPersonMotion(feetPosition: Vec3, motion: Vec3, shape: FirstPersonPlayerShape): ResolvedPlayerMotion | null; setRuntimeMessage(message: string | null): void; setFirstPersonTelemetry(telemetry: FirstPersonTelemetry | null): void; } diff --git a/src/runtime-three/rapier-collision-world.ts b/src/runtime-three/rapier-collision-world.ts new file mode 100644 index 00000000..a4e4ef74 --- /dev/null +++ b/src/runtime-three/rapier-collision-world.ts @@ -0,0 +1,278 @@ +import RAPIER from "@dimforge/rapier3d-compat"; +import { Euler, MathUtils, Quaternion, Vector3 } from "three"; + +import type { Vec3 } from "../core/vector"; +import type { + GeneratedModelBoxCollider, + GeneratedModelCollider, + GeneratedModelCompoundCollider, + GeneratedModelHeightfieldCollider, + GeneratedModelTriMeshCollider +} from "../geometry/model-instance-collider-generation"; + +import type { FirstPersonPlayerShape, ResolvedPlayerMotion } from "./player-collision"; +import type { RuntimeBoxCollider, RuntimeSceneCollider } from "./runtime-scene-build"; + +const CHARACTER_CONTROLLER_OFFSET = 0.01; +const COLLISION_EPSILON = 1e-5; + +let rapierInitPromise: Promise | null = null; + +function cloneVec3(vector: Vec3): Vec3 { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} + +function componentScale(vector: Vec3, scale: Vec3): Vec3 { + return { + x: vector.x * scale.x, + y: vector.y * scale.y, + z: vector.z * scale.z + }; +} + +function createRapierQuaternion(rotationDegrees: Vec3): RAPIER.Rotation { + const quaternion = new Quaternion().setFromEuler( + new Euler( + MathUtils.degToRad(rotationDegrees.x), + MathUtils.degToRad(rotationDegrees.y), + MathUtils.degToRad(rotationDegrees.z), + "XYZ" + ) + ); + + return { + x: quaternion.x, + y: quaternion.y, + z: quaternion.z, + w: quaternion.w + }; +} + +function scaleVertices(vertices: Float32Array, scale: Vec3): Float32Array { + const scaledVertices = new Float32Array(vertices.length); + + for (let index = 0; index < vertices.length; index += 3) { + scaledVertices[index] = vertices[index] * scale.x; + scaledVertices[index + 1] = vertices[index + 1] * scale.y; + scaledVertices[index + 2] = vertices[index + 2] * scale.z; + } + + return scaledVertices; +} + +function scaleBoundsCenter(bounds: { min: Vec3; max: Vec3 }, scale: Vec3): Vec3 { + return { + x: ((bounds.min.x + bounds.max.x) * 0.5) * scale.x, + y: ((bounds.min.y + bounds.max.y) * 0.5) * scale.y, + z: ((bounds.min.z + bounds.max.z) * 0.5) * scale.z + }; +} + +function createFixedBodyForModelCollider(world: RAPIER.World, collider: GeneratedModelCollider): RAPIER.RigidBody { + return world.createRigidBody( + RAPIER.RigidBodyDesc.fixed() + .setTranslation(collider.transform.position.x, collider.transform.position.y, collider.transform.position.z) + .setRotation(createRapierQuaternion(collider.transform.rotationDegrees)) + ); +} + +function attachBrushCollider(world: RAPIER.World, collider: RuntimeBoxCollider) { + const center = { + x: (collider.min.x + collider.max.x) * 0.5, + y: (collider.min.y + collider.max.y) * 0.5, + z: (collider.min.z + collider.max.z) * 0.5 + }; + const halfExtents = { + x: (collider.max.x - collider.min.x) * 0.5, + y: (collider.max.y - collider.min.y) * 0.5, + z: (collider.max.z - collider.min.z) * 0.5 + }; + + world.createCollider(RAPIER.ColliderDesc.cuboid(halfExtents.x, halfExtents.y, halfExtents.z).setTranslation(center.x, center.y, center.z)); +} + +function attachSimpleModelCollider(world: RAPIER.World, collider: GeneratedModelBoxCollider) { + const body = createFixedBodyForModelCollider(world, collider); + const scaledCenter = componentScale(collider.center, collider.transform.scale); + const scaledHalfExtents = componentScale( + { + x: collider.size.x * 0.5, + y: collider.size.y * 0.5, + z: collider.size.z * 0.5 + }, + collider.transform.scale + ); + + world.createCollider( + RAPIER.ColliderDesc.cuboid(scaledHalfExtents.x, scaledHalfExtents.y, scaledHalfExtents.z).setTranslation( + scaledCenter.x, + scaledCenter.y, + scaledCenter.z + ), + body + ); +} + +function attachStaticModelCollider(world: RAPIER.World, collider: GeneratedModelTriMeshCollider) { + const body = createFixedBodyForModelCollider(world, collider); + world.createCollider(RAPIER.ColliderDesc.trimesh(scaleVertices(collider.vertices, collider.transform.scale), collider.indices), body); +} + +function attachTerrainModelCollider(world: RAPIER.World, collider: GeneratedModelHeightfieldCollider) { + const body = createFixedBodyForModelCollider(world, collider); + const center = scaleBoundsCenter( + { + min: { + x: collider.minX, + y: 0, + z: collider.minZ + }, + max: { + x: collider.maxX, + y: 0, + z: collider.maxZ + } + }, + collider.transform.scale + ); + + world.createCollider( + RAPIER.ColliderDesc.heightfield(collider.rows, collider.cols, collider.heights, { + x: (collider.maxX - collider.minX) * collider.transform.scale.x, + y: collider.transform.scale.y, + z: (collider.maxZ - collider.minZ) * collider.transform.scale.z + }).setTranslation(center.x, center.y, center.z), + body + ); +} + +function attachDynamicModelCollider(world: RAPIER.World, collider: GeneratedModelCompoundCollider) { + const body = createFixedBodyForModelCollider(world, collider); + + for (const piece of collider.pieces) { + const scaledPoints = scaleVertices(piece.points, collider.transform.scale); + const descriptor = RAPIER.ColliderDesc.convexHull(scaledPoints); + + if (descriptor === null) { + throw new Error(`Dynamic collider piece ${piece.id} could not form a valid convex hull.`); + } + + world.createCollider(descriptor, body); + } +} + +function attachModelCollider(world: RAPIER.World, collider: GeneratedModelCollider) { + switch (collider.kind) { + case "box": + attachSimpleModelCollider(world, collider); + break; + case "trimesh": + attachStaticModelCollider(world, collider); + break; + case "heightfield": + attachTerrainModelCollider(world, collider); + break; + case "compound": + attachDynamicModelCollider(world, collider); + break; + } +} + +function feetPositionToColliderCenter(feetPosition: Vec3, shape: FirstPersonPlayerShape): Vec3 { + const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5); + + return { + x: feetPosition.x, + y: feetPosition.y + shape.radius + cylindricalHalfHeight, + z: feetPosition.z + }; +} + +function colliderCenterToFeetPosition(center: Vec3, shape: FirstPersonPlayerShape): Vec3 { + const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5); + + return { + x: center.x, + y: center.y - (shape.radius + cylindricalHalfHeight), + z: center.z + }; +} + +export async function initializeRapierCollisionWorld(): Promise { + rapierInitPromise ??= RAPIER.init().then(() => RAPIER); + return rapierInitPromise; +} + +export class RapierCollisionWorld { + static async create(colliders: RuntimeSceneCollider[], playerShape: FirstPersonPlayerShape): Promise { + const rapier = await initializeRapierCollisionWorld(); + const world = new rapier.World({ + x: 0, + y: 0, + z: 0 + }); + + for (const collider of colliders) { + if (collider.source === "brush") { + attachBrushCollider(world, collider); + continue; + } + + attachModelCollider(world, collider); + } + + const playerCollider = world.createCollider( + rapier.ColliderDesc.capsule(Math.max(0, (playerShape.height - playerShape.radius * 2) * 0.5), playerShape.radius) + ); + const characterController = world.createCharacterController(CHARACTER_CONTROLLER_OFFSET); + + characterController.setUp({ x: 0, y: 1, z: 0 }); + characterController.setSlideEnabled(true); + characterController.enableSnapToGround(0.2); + characterController.enableAutostep(0.35, 0.15, false); + characterController.setMaxSlopeClimbAngle(Math.PI * 0.45); + characterController.setMinSlopeSlideAngle(Math.PI * 0.5); + + return new RapierCollisionWorld(world, characterController, playerCollider); + } + + private constructor( + private readonly world: RAPIER.World, + private readonly characterController: RAPIER.KinematicCharacterController, + private readonly playerCollider: RAPIER.Collider + ) {} + + resolveFirstPersonMotion(feetPosition: Vec3, motion: Vec3, shape: FirstPersonPlayerShape): ResolvedPlayerMotion { + const currentCenter = feetPositionToColliderCenter(feetPosition, shape); + this.playerCollider.setTranslation(currentCenter); + this.characterController.computeColliderMovement(this.playerCollider, motion); + + const correctedMovement = this.characterController.computedMovement(); + const nextCenter = { + x: currentCenter.x + correctedMovement.x, + y: currentCenter.y + correctedMovement.y, + z: currentCenter.z + correctedMovement.z + }; + + this.playerCollider.setTranslation(nextCenter); + + return { + feetPosition: colliderCenterToFeetPosition(nextCenter, shape), + grounded: this.characterController.computedGrounded(), + collidedAxes: { + x: Math.abs(correctedMovement.x - motion.x) > COLLISION_EPSILON, + y: Math.abs(correctedMovement.y - motion.y) > COLLISION_EPSILON, + z: Math.abs(correctedMovement.z - motion.z) > COLLISION_EPSILON + } + }; + } + + dispose() { + this.world.removeCharacterController(this.characterController); + this.world.free(); + } +} diff --git a/src/runtime-three/runtime-host.ts b/src/runtime-three/runtime-host.ts index 3691acae..2dd1ef3b 100644 --- a/src/runtime-three/runtime-host.ts +++ b/src/runtime-three/runtime-host.ts @@ -40,6 +40,8 @@ import { import { FirstPersonNavigationController } from "./first-person-navigation-controller"; import type { FirstPersonTelemetry, NavigationController, RuntimeControllerContext } from "./navigation-controller"; +import { FIRST_PERSON_PLAYER_SHAPE } from "./player-collision"; +import { RapierCollisionWorld } from "./rapier-collision-world"; import { RuntimeInteractionSystem, type RuntimeInteractionDispatcher, type RuntimeInteractionPrompt } from "./runtime-interaction-system"; import { RuntimeAudioSystem } from "./runtime-audio-system"; import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller"; @@ -85,6 +87,8 @@ export class RuntimeHost { private readonly controllerContext: RuntimeControllerContext; private readonly renderer: WebGLRenderer | null; private runtimeScene: RuntimeSceneDefinition | null = null; + private collisionWorld: RapierCollisionWorld | null = null; + private collisionWorldRequestId = 0; private currentWorld: RuntimeSceneDefinition["world"] | null = null; private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null = null; private advancedRenderingComposer: EffectComposer | null = null; @@ -131,6 +135,7 @@ export class RuntimeHost { return this.runtimeScene; }, + resolveFirstPersonMotion: (feetPosition, motion, shape) => this.collisionWorld?.resolveFirstPersonMotion(feetPosition, motion, shape) ?? null, setRuntimeMessage: (message) => { if (message === this.currentRuntimeMessage) { return; @@ -171,6 +176,7 @@ export class RuntimeHost { this.rebuildLocalLights(runtimeScene.localLights); this.rebuildBrushMeshes(runtimeScene.brushes); this.rebuildModelInstances(runtimeScene.modelInstances); + void this.rebuildCollisionWorld(runtimeScene.colliders); this.audioSystem.loadScene(runtimeScene); } @@ -244,6 +250,8 @@ export class RuntimeHost { this.clearLocalLights(); this.clearBrushMeshes(); this.clearModelInstances(); + this.collisionWorldRequestId += 1; + this.clearCollisionWorld(); this.audioSystem.dispose(); this.advancedRenderingComposer?.dispose(); this.advancedRenderingComposer = null; @@ -306,6 +314,36 @@ export class RuntimeHost { this.applyShadowState(); } + private async rebuildCollisionWorld(colliders: RuntimeSceneDefinition["colliders"]) { + const requestId = ++this.collisionWorldRequestId; + + this.clearCollisionWorld(); + + try { + const nextCollisionWorld = await RapierCollisionWorld.create(colliders, FIRST_PERSON_PLAYER_SHAPE); + + if (requestId !== this.collisionWorldRequestId) { + nextCollisionWorld.dispose(); + return; + } + + this.collisionWorld = nextCollisionWorld; + } catch (error) { + if (requestId !== this.collisionWorldRequestId) { + return; + } + + const message = error instanceof Error ? error.message : "Runner collision initialization failed."; + this.currentRuntimeMessage = `Runner collision initialization failed: ${message}`; + this.runtimeMessageHandler?.(this.currentRuntimeMessage); + } + } + + private clearCollisionWorld() { + this.collisionWorld?.dispose(); + this.collisionWorld = null; + } + private syncAdvancedRenderingComposer(settings: AdvancedRenderingSettings) { if (this.renderer === null) { return; diff --git a/src/runtime-three/runtime-scene-build.ts b/src/runtime-three/runtime-scene-build.ts index 8716d42b..fcf57212 100644 --- a/src/runtime-three/runtime-scene-build.ts +++ b/src/runtime-three/runtime-scene-build.ts @@ -1,3 +1,4 @@ +import type { LoadedModelAsset } from "../assets/gltf-model-import"; import type { Vec3 } from "../core/vector"; import { getModelInstances } from "../assets/model-instances"; import type { BoxBrush, BoxFaceId, FaceUvState } from "../document/brushes"; @@ -5,6 +6,7 @@ import type { SceneDocument } from "../document/scene-document"; import { cloneWorldSettings, type WorldSettings } from "../document/world-settings"; import { getEntityInstances, getPrimaryPlayerStartEntity, type EntityInstance } from "../entities/entity-instances"; import { getBoxBrushBounds } from "../geometry/box-brush"; +import { buildGeneratedModelCollider, type GeneratedColliderBounds, type GeneratedModelCollider } from "../geometry/model-instance-collider-generation"; import { cloneInteractionLink, getInteractionLinks, type InteractionLink } from "../interactions/interaction-links"; import { cloneMaterialDef, type MaterialDef } from "../materials/starter-material-library"; import { cloneFaceUvState } from "../document/brushes"; @@ -28,11 +30,14 @@ export interface RuntimeBoxBrushInstance { export interface RuntimeBoxCollider { kind: "box"; + source: "brush"; brushId: string; min: Vec3; max: Vec3; } +export type RuntimeSceneCollider = RuntimeBoxCollider | GeneratedModelCollider; + export interface RuntimeSceneBounds { min: Vec3; max: Vec3; @@ -132,7 +137,7 @@ export interface RuntimeSceneDefinition { world: WorldSettings; localLights: RuntimeLocalLightCollection; brushes: RuntimeBoxBrushInstance[]; - colliders: RuntimeBoxCollider[]; + colliders: RuntimeSceneCollider[]; sceneBounds: RuntimeSceneBounds | null; modelInstances: RuntimeModelInstance[]; entities: RuntimeEntityCollection; @@ -143,6 +148,7 @@ export interface RuntimeSceneDefinition { interface BuildRuntimeSceneOptions { navigationMode?: RuntimeNavigationMode; + loadedModelAssets?: Record; } function cloneVec3(vector: Vec3): Vec3 { @@ -213,6 +219,7 @@ function buildRuntimeCollider(brush: BoxBrush): RuntimeBoxCollider { return { kind: "box", + source: "brush", brushId: brush.id, min: cloneVec3(bounds.min), max: cloneVec3(bounds.max) @@ -232,21 +239,37 @@ function buildRuntimeModelInstance(modelInstance: SceneDocument["modelInstances" }; } -function combineColliderBounds(colliders: RuntimeBoxCollider[]): RuntimeSceneBounds | null { +function getColliderBounds(collider: RuntimeSceneCollider): GeneratedColliderBounds { + if (collider.source === "brush") { + return { + min: cloneVec3(collider.min), + max: cloneVec3(collider.max) + }; + } + + return { + min: cloneVec3(collider.worldBounds.min), + max: cloneVec3(collider.worldBounds.max) + }; +} + +function combineColliderBounds(colliders: RuntimeSceneCollider[]): RuntimeSceneBounds | null { if (colliders.length === 0) { return null; } - const min = cloneVec3(colliders[0].min); - const max = cloneVec3(colliders[0].max); + const firstBounds = getColliderBounds(colliders[0]); + const min = cloneVec3(firstBounds.min); + const max = cloneVec3(firstBounds.max); for (const collider of colliders.slice(1)) { - min.x = Math.min(min.x, collider.min.x); - min.y = Math.min(min.y, collider.min.y); - min.z = Math.min(min.z, collider.min.z); - max.x = Math.max(max.x, collider.max.x); - max.y = Math.max(max.y, collider.max.y); - max.z = Math.max(max.z, collider.max.z); + const bounds = getColliderBounds(collider); + min.x = Math.min(min.x, bounds.min.x); + min.y = Math.min(min.y, bounds.min.y); + min.z = Math.min(min.z, bounds.min.z); + max.x = Math.max(max.x, bounds.max.x); + max.y = Math.max(max.y, bounds.max.y); + max.z = Math.max(max.z, bounds.max.z); } return { @@ -396,15 +419,33 @@ function assertNever(value: never): never { } export function buildRuntimeSceneFromDocument(document: SceneDocument, options: BuildRuntimeSceneOptions = {}): RuntimeSceneDefinition { - assertRuntimeSceneBuildable(document, options.navigationMode ?? "orbitVisitor"); + assertRuntimeSceneBuildable(document, { + navigationMode: options.navigationMode ?? "orbitVisitor", + loadedModelAssets: options.loadedModelAssets + }); const brushes = Object.values(document.brushes).map((brush) => buildRuntimeBrush(brush, document)); - const colliders = Object.values(document.brushes).map((brush) => buildRuntimeCollider(brush)); - const sceneBounds = combineColliderBounds(colliders); + const colliders: RuntimeSceneCollider[] = Object.values(document.brushes).map((brush) => buildRuntimeCollider(brush)); const modelInstances = getModelInstances(document.modelInstances).map(buildRuntimeModelInstance); const collections = buildRuntimeSceneCollections(document); const interactionLinks = getInteractionLinks(document.interactionLinks).map((link) => cloneInteractionLink(link)); const playerStartEntity = getPrimaryPlayerStartEntity(document.entities); + + for (const modelInstance of getModelInstances(document.modelInstances)) { + const asset = document.assets[modelInstance.assetId]; + + if (asset === undefined || asset.kind !== "model") { + continue; + } + + const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]); + + if (generatedCollider !== null) { + colliders.push(generatedCollider); + } + } + + const combinedSceneBounds = combineColliderBounds(colliders); const playerStart = playerStartEntity === null ? null @@ -419,14 +460,14 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options: localLights: collections.localLights, brushes, colliders, - sceneBounds, + sceneBounds: combinedSceneBounds, modelInstances, entities: collections.entities, interactionLinks, playerStart, spawn: playerStart === null - ? buildFallbackSpawn(sceneBounds) + ? buildFallbackSpawn(combinedSceneBounds) : { source: "playerStart", entityId: playerStart.entityId, diff --git a/src/runtime-three/runtime-scene-validation.ts b/src/runtime-three/runtime-scene-validation.ts index c0ecaedb..ebd667a0 100644 --- a/src/runtime-three/runtime-scene-validation.ts +++ b/src/runtime-three/runtime-scene-validation.ts @@ -1,3 +1,5 @@ +import type { LoadedModelAsset } from "../assets/gltf-model-import"; +import { getModelInstances } from "../assets/model-instances"; import type { SceneDocument } from "../document/scene-document"; import { assertSceneDocumentIsValid, @@ -6,6 +8,7 @@ import { type SceneDiagnostic } from "../document/scene-document-validation"; import { getPrimaryPlayerStartEntity } from "../entities/entity-instances"; +import { buildGeneratedModelCollider, ModelColliderGenerationError } from "../geometry/model-instance-collider-generation"; export interface RuntimeSceneBuildValidationResult { diagnostics: SceneDiagnostic[]; @@ -13,13 +16,18 @@ export interface RuntimeSceneBuildValidationResult { warnings: SceneDiagnostic[]; } +interface ValidateRuntimeSceneBuildOptions { + navigationMode: "firstPerson" | "orbitVisitor"; + loadedModelAssets?: Record; +} + export function validateRuntimeSceneBuild( document: SceneDocument, - navigationMode: "firstPerson" | "orbitVisitor" + options: ValidateRuntimeSceneBuildOptions ): RuntimeSceneBuildValidationResult { const diagnostics: SceneDiagnostic[] = []; - if (navigationMode === "firstPerson" && getPrimaryPlayerStartEntity(document.entities) === null) { + if (options.navigationMode === "firstPerson" && getPrimaryPlayerStartEntity(document.entities) === null) { diagnostics.push( createDiagnostic( "error", @@ -31,6 +39,39 @@ export function validateRuntimeSceneBuild( ); } + for (const modelInstance of getModelInstances(document.modelInstances)) { + const path = `modelInstances.${modelInstance.id}.collision.mode`; + const asset = document.assets[modelInstance.assetId]; + + if (modelInstance.collision.mode === "none" || asset === undefined || asset.kind !== "model") { + continue; + } + + try { + const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]); + + if (generatedCollider?.mode === "dynamic") { + diagnostics.push( + createDiagnostic( + "warning", + "dynamic-model-collider-fixed-query-only", + "Dynamic model collision currently generates convex compound pieces for Rapier queries, but the runner still uses them as fixed world collision rather than fully simulated rigid bodies.", + path, + "build" + ) + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Imported model collision generation failed."; + const code = + error instanceof ModelColliderGenerationError + ? error.code + : "invalid-model-instance-collision-mode"; + + diagnostics.push(createDiagnostic("error", code, message, path, "build")); + } + } + return { diagnostics, errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"), @@ -38,10 +79,10 @@ export function validateRuntimeSceneBuild( }; } -export function assertRuntimeSceneBuildable(document: SceneDocument, navigationMode: "firstPerson" | "orbitVisitor") { +export function assertRuntimeSceneBuildable(document: SceneDocument, options: ValidateRuntimeSceneBuildOptions) { assertSceneDocumentIsValid(document); - const validation = validateRuntimeSceneBuild(document, navigationMode); + const validation = validateRuntimeSceneBuild(document, options); if (validation.errors.length > 0) { throw new Error(`Runtime build is blocked: ${formatSceneDiagnosticSummary(validation.errors)}`); diff --git a/src/viewport-three/viewport-host.ts b/src/viewport-three/viewport-host.ts index 860f5ee1..a13cced4 100644 --- a/src/viewport-three/viewport-host.ts +++ b/src/viewport-three/viewport-host.ts @@ -341,6 +341,7 @@ export class ViewportHost { this.renderer.domElement.addEventListener("pointerleave", this.handlePointerLeave); this.renderer.domElement.addEventListener("wheel", this.handleWheel, { passive: false }); this.renderer.domElement.addEventListener("auxclick", this.handleAuxClick); + this.renderer.domElement.addEventListener("contextmenu", this.handleContextMenu); window.addEventListener("pointermove", this.handleWindowPointerMove); this.resize(); @@ -554,6 +555,7 @@ export class ViewportHost { this.renderer.domElement.removeEventListener("pointerleave", this.handlePointerLeave); this.renderer.domElement.removeEventListener("wheel", this.handleWheel); this.renderer.domElement.removeEventListener("auxclick", this.handleAuxClick); + this.renderer.domElement.removeEventListener("contextmenu", this.handleContextMenu); window.removeEventListener("pointermove", this.handleWindowPointerMove); this.clearLocalLights(); this.clearBrushMeshes(); @@ -2289,6 +2291,16 @@ export class ViewportHost { return; } + if (event.button === 2) { + event.preventDefault(); + + if (this.currentTransformSession.kind === "active") { + this.transformCancelHandler?.(); + } + + return; + } + if (event.button !== 0) { return; } @@ -2657,11 +2669,15 @@ export class ViewportHost { }; private handleAuxClick = (event: MouseEvent) => { - if (event.button === 1) { + if (event.button === 1 || event.button === 2) { event.preventDefault(); } }; + private handleContextMenu = (event: MouseEvent) => { + event.preventDefault(); + }; + private findModelInstanceId(object: Object3D): string | null { let current: Object3D | null = object; diff --git a/testing.md b/testing.md index 4717e7e2..cd428178 100644 --- a/testing.md +++ b/testing.md @@ -37,10 +37,11 @@ Highest-priority confidence areas: 4. per-face material/UV persistence 5. runtime build correctness 6. asset import survival -7. project package portability once binary assets exist -8. runner navigation/input reliability -9. spatial audio and interaction basics -10. critical regressions caught in CI +7. imported-model collider generation and runtime collision correctness +8. project package portability once binary assets exist +9. runner navigation/input reliability +10. spatial audio and interaction basics +11. critical regressions caught in CI --- @@ -158,6 +159,8 @@ Scope: - face generation - topology expectations - collision mesh generation +- imported-model collider generation +- Rapier-backed collider/query integration where relevant - UV projection generation - clipping results - derived mesh determinism @@ -170,6 +173,13 @@ Examples: - clipping yields valid child brushes - generated geometry contains no NaNs - rebuild is deterministic for the same input +- imported model collider generation produces finite valid data for the selected mode +- imported-model collider generation honors the authored mode semantics: + - terrain -> heightfield + - static -> triangle mesh + - dynamic -> compound convex pieces + - simple -> primitive or convex hull +- unsupported imported-model collision modes fail clearly instead of producing silent garbage ### Geometry test principles @@ -212,6 +222,12 @@ For every substantial document feature, add at least: - one round-trip save/load test - one migration or backward-compatibility consideration if schema changed +For authored imported-model collision settings, also add at least: + +- one round-trip test for the selected collision mode/settings +- one validation/build-path test for missing asset or incompatible collision-mode assumptions where relevant +- one runtime/query-path test proving the generated collider participates in the actual collision/query layer rather than only existing as dead metadata + --- ## 5. Browser integration tests