fix(editor): keep walkthrough on ground, pass through ceilings

Two gaps in the first-person walkthrough collider world:

- The visible default ground is the site node's mesh, but `site` is a
  `site`-category node and excluded from the generic collider sweep. The
  per-level fallback floor only fires for a level without a slab, so a
  spawn on the bare ground (no slab) had no floor and fell through. Add a
  dedicated site-ground collider derived from node data (not the rendered
  mesh, so it's immune to geometry-mount timing) covering the scene
  footprint at the site's ground plane.

- Ceilings are `structure`-category and were swept in as colliders, so the
  player was held up as if standing on a floor slab. Exclude nodes whose
  registry `surfaceRole` is 'ceiling' so the player passes through them
  (they're a transparent mount surface for lights/fans), while walls,
  slabs and stairs keep colliding.

Adds tests for both behaviors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-08 01:05:30 -04:00
co-authored by Claude Opus 4.8
parent 6b8dc33b62
commit ab271df9b6
2 changed files with 98 additions and 1 deletions
@@ -2,12 +2,14 @@ import { afterEach, describe, expect, test } from 'bun:test'
import { import {
type AnyNode, type AnyNode,
type AnyNodeDefinition, type AnyNodeDefinition,
CeilingNode,
ColumnNode, ColumnNode,
ElevatorNode, ElevatorNode,
LevelNode, LevelNode,
nodeRegistry, nodeRegistry,
registerNode, registerNode,
ShelfNode, ShelfNode,
SiteNode,
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -18,12 +20,14 @@ function registerColliderDefinition(
kind: AnyNode['type'], kind: AnyNode['type'],
schema: AnyNodeDefinition['schema'], schema: AnyNodeDefinition['schema'],
category: AnyNodeDefinition['category'], category: AnyNodeDefinition['category'],
surfaceRole?: AnyNodeDefinition['surfaceRole'],
) { ) {
registerNode({ registerNode({
kind, kind,
schema, schema,
schemaVersion: 1, schemaVersion: 1,
category, category,
surfaceRole,
capabilities: {}, capabilities: {},
} as AnyNodeDefinition) } as AnyNodeDefinition)
} }
@@ -81,6 +85,26 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
world?.dispose() 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', () => { test('leaves elevators to their dedicated dynamic collider meshes', () => {
registerColliderDefinition('elevator', ElevatorNode, 'structure') registerColliderDefinition('elevator', ElevatorNode, 'structure')
@@ -105,4 +129,21 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
expect(world?.bounds?.max.y).toBeCloseTo(0) expect(world?.bounds?.max.y).toBeCloseTo(0)
world?.dispose() 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()
})
}) })
@@ -42,6 +42,7 @@ export type FirstPersonSpawn = {
} }
type LevelNode = Extract<AnyNode, { type: 'level' }> type LevelNode = Extract<AnyNode, { type: 'level' }>
type SiteNode = Extract<AnyNode, { type: 'site' }>
type SceneNodes = ReturnType<typeof useScene.getState>['nodes'] type SceneNodes = ReturnType<typeof useScene.getState>['nodes']
function isMesh(object: THREE.Object3D): object is THREE.Mesh { function isMesh(object: THREE.Object3D): object is THREE.Mesh {
@@ -55,7 +56,12 @@ function isColliderMaterialVisible(material: THREE.Material | THREE.Material[])
function isGenericColliderNode(node: AnyNode) { function isGenericColliderNode(node: AnyNode) {
if (node.visible === false) return false if (node.visible === false) return false
if (DEDICATED_COLLIDER_NODE_TYPES.has(node.type)) 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) { function createBoxColliderGeometry(width: number, height: number, depth: number) {
@@ -118,6 +124,55 @@ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
return geometries 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 // Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a
// plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every // plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every
// merged geometry to share the same typed-array constructor for matching attributes, so // 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(...collectLevelFallbackFloorGeometries(nodes))
geometries.push(...collectSiteGroundColliderGeometries(nodes))
if (geometries.length === 0) { if (geometries.length === 0) {
return null return null