diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts index fd5214e6..c76d50f1 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts @@ -2,12 +2,14 @@ import { afterEach, describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeDefinition, + CeilingNode, ColumnNode, ElevatorNode, LevelNode, nodeRegistry, registerNode, ShelfNode, + SiteNode, sceneRegistry, useScene, } from '@pascal-app/core' @@ -18,12 +20,14 @@ function registerColliderDefinition( kind: AnyNode['type'], schema: AnyNodeDefinition['schema'], category: AnyNodeDefinition['category'], + surfaceRole?: AnyNodeDefinition['surfaceRole'], ) { registerNode({ kind, schema, schemaVersion: 1, category, + surfaceRole, capabilities: {}, } as AnyNodeDefinition) } @@ -81,6 +85,26 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => { world?.dispose() }) + test('excludes ceiling surfaces so the walkthrough player passes through them', () => { + registerColliderDefinition('column', ColumnNode, 'structure') + registerColliderDefinition('ceiling', CeilingNode, 'structure', 'ceiling') + + const column = ColumnNode.parse({ id: 'column_test' }) + const ceiling = CeilingNode.parse({ id: 'ceiling_test', polygon: [] }) + setSceneNodes([column, ceiling]) + mountNode(column, [1, 2, 1], [0, 1, 0]) + // A wide ceiling at head height — if it were collected, bounds would span ±5. + mountNode(ceiling, [10, 0.1, 10], [0, 2.5, 0]) + + const world = buildFirstPersonColliderWorldFromRegistry() + + expect(world).not.toBeNull() + // Bounds reflect only the 1×1 column; the ceiling contributed no geometry. + expect(world?.bounds?.min.x).toBeCloseTo(-0.5) + expect(world?.bounds?.max.x).toBeCloseTo(0.5) + world?.dispose() + }) + test('leaves elevators to their dedicated dynamic collider meshes', () => { registerColliderDefinition('elevator', ElevatorNode, 'structure') @@ -105,4 +129,21 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => { expect(world?.bounds?.max.y).toBeCloseTo(0) world?.dispose() }) + + test('adds a site ground collider so a spawn on bare ground has a floor', () => { + const site = SiteNode.parse({ id: 'site_test' }) + setSceneNodes([site]) + mountRegistryGroup(site) + + const world = buildFirstPersonColliderWorldFromRegistry() + + expect(world).not.toBeNull() + // Ground slab sits just below the site ground plane (y = 0). + expect(world?.bounds?.min.y).toBeCloseTo(-0.08) + expect(world?.bounds?.max.y).toBeCloseTo(0) + // Default site footprint falls back to the 30 m minimum size. + expect(world?.bounds?.min.x).toBeCloseTo(-15) + expect(world?.bounds?.max.x).toBeCloseTo(15) + world?.dispose() + }) }) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index 478336c0..666e3de9 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -42,6 +42,7 @@ export type FirstPersonSpawn = { } type LevelNode = Extract +type SiteNode = Extract type SceneNodes = ReturnType['nodes'] function isMesh(object: THREE.Object3D): object is THREE.Mesh { @@ -55,7 +56,12 @@ function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) function isGenericColliderNode(node: AnyNode) { if (node.visible === false) return false if (DEDICATED_COLLIDER_NODE_TYPES.has(node.type)) return false - return COLLIDER_NODE_CATEGORIES.has(nodeRegistry.get(node.type)?.category ?? '') + const def = nodeRegistry.get(node.type) + // Ceilings are a transparent mount surface for fixtures (lights, fans), not a + // walkable or blocking structure — the walkthrough player must pass through + // them rather than be held up as if standing on a floor slab. + if (def?.surfaceRole === 'ceiling') return false + return COLLIDER_NODE_CATEGORIES.has(def?.category ?? '') } function createBoxColliderGeometry(width: number, height: number, depth: number) { @@ -118,6 +124,55 @@ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) { return geometries } +// The visible ground is the site node's ground mesh, but `site` is a `site` +// category node and therefore excluded from the generic collider sweep. Without +// a dedicated collider, a spawn on the bare ground (no slab, or not parented to +// a level that triggers the per-level fallback) has no floor to stand on and the +// walkthrough player falls through. Derive a thin ground slab from node data (not +// the rendered mesh) so it exists regardless of geometry-mount timing, sized to +// cover the whole scene footprint at the site's ground plane. +function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) { + if (site.visible === false) return null + + const siteObject = sceneRegistry.nodes.get(site.id) + if (!siteObject?.visible) return null + + const bounds = computeSceneBoundsXZ(nodes) + const [centerX, centerZ] = bounds?.center ?? [0, 0] + const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0] + const width = Math.max( + boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2, + LEVEL_FALLBACK_FLOOR_MIN_SIZE, + ) + const depth = Math.max( + boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2, + LEVEL_FALLBACK_FLOOR_MIN_SIZE, + ) + + const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth) + + siteObject.updateWorldMatrix(true, false) + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ), + ) + geometry.applyMatrix4(siteObject.matrixWorld) + return geometry +} + +function collectSiteGroundColliderGeometries(nodes: SceneNodes) { + const geometries: THREE.BufferGeometry[] = [] + + for (const siteId of sceneRegistry.byType.site ?? []) { + const node = nodes[siteId as AnyNodeId] + if (node?.type !== 'site') continue + + const geometry = createSiteGroundColliderGeometry(node, nodes) + if (geometry) geometries.push(geometry) + } + + return geometries +} + // Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a // plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every // merged geometry to share the same typed-array constructor for matching attributes, so @@ -327,6 +382,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider } geometries.push(...collectLevelFallbackFloorGeometries(nodes)) + geometries.push(...collectSiteGroundColliderGeometries(nodes)) if (geometries.length === 0) { return null