fix: restore walkthrough collisions and spawn controls

This commit is contained in:
Aymeric Rabot
2026-06-07 23:58:49 -04:00
parent be0f491bbd
commit 6b8dc33b62
9 changed files with 552 additions and 141 deletions
@@ -10,7 +10,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer' import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber' import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Box3, Vector3 } from 'three' import { Box3, Vector3 } from 'three'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
@@ -25,13 +25,119 @@ const tempSize = new Vector3()
const tempTarget = new Vector3() const tempTarget = new Vector3()
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
type CameraMode = ReturnType<typeof useViewer.getState>['cameraMode']
type CameraPoseSnapshot = {
mode: CameraMode
position: [number, number, number]
target: [number, number, number]
}
function writeVectorTuple(tuple: [number, number, number], vector: Vector3) {
tuple[0] = vector.x
tuple[1] = vector.y
tuple[2] = vector.z
}
function saveCameraPose(
control: CameraControlsImpl,
mode: CameraMode,
pose: CameraPoseSnapshot,
position: Vector3,
target: Vector3,
) {
control.getPosition(position)
control.getTarget(target)
pose.mode = mode
writeVectorTuple(pose.position, position)
writeVectorTuple(pose.target, target)
}
function restoreCameraPose(control: CameraControlsImpl, pose: CameraPoseSnapshot) {
control.setLookAt(
pose.position[0],
pose.position[1],
pose.position[2],
pose.target[0],
pose.target[1],
pose.target[2],
false,
)
}
function useFirstPersonCameraPoseRestore(
controls: { current: CameraControlsImpl | null },
isFirstPersonMode: boolean,
cameraMode: CameraMode,
) {
const restorePose = useRef<CameraPoseSnapshot>({
mode: cameraMode,
position: [0, 0, 0],
target: [0, 0, 0],
})
const hasRestorePose = useRef(false)
const isRestoring = useRef(false)
const wasFirstPersonMode = useRef(isFirstPersonMode)
const snapshotPosition = useRef(new Vector3())
const snapshotTarget = useRef(new Vector3())
useFrame(() => {
if (isFirstPersonMode || isRestoring.current) return
const control = controls.current
if (!control) return
saveCameraPose(
control,
cameraMode,
restorePose.current,
snapshotPosition.current,
snapshotTarget.current,
)
hasRestorePose.current = true
})
useEffect(() => {
const wasFirstPerson = wasFirstPersonMode.current
wasFirstPersonMode.current = isFirstPersonMode
if (isFirstPersonMode) {
return
}
if (!wasFirstPerson || !hasRestorePose.current) return
const pose = restorePose.current
isRestoring.current = true
useViewer.getState().setCameraMode(pose.mode)
const restoreFrame = requestAnimationFrame(() => {
const currentControls = controls.current
if (currentControls) {
restoreCameraPose(currentControls, pose)
}
isRestoring.current = false
})
return () => {
cancelAnimationFrame(restoreFrame)
isRestoring.current = false
}
}, [controls, isFirstPersonMode])
return useCallback(() => isRestoring.current, [])
}
export const CustomCameraControls = () => { export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl>(null!) const controls = useRef<CameraControlsImpl | null>(null)
const isPreviewMode = useEditor((s) => s.isPreviewMode) const isPreviewMode = useEditor((s) => s.isPreviewMode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
const selection = useViewer((s) => s.selection) const selection = useViewer((s) => s.selection)
const cameraMode = useViewer((state) => state.cameraMode)
const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
controls,
isFirstPersonMode,
cameraMode,
)
const currentLevelId = selection.levelId const currentLevelId = selection.levelId
const firstLoad = useRef(true) const firstLoad = useRef(true)
const maxPolarAngle = const maxPolarAngle =
@@ -47,7 +153,7 @@ export const CustomCameraControls = () => {
}, [camera, raycaster]) }, [camera, raycaster])
useEffect(() => { useEffect(() => {
if (isPreviewMode) return // Preview mode uses auto-navigate instead if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
let targetY = 0 let targetY = 0
if (currentLevelId) { if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId) const levelMesh = sceneRegistry.nodes.get(currentLevelId)
@@ -62,10 +168,10 @@ export const CustomCameraControls = () => {
} }
controls.current.getTarget(currentTarget) controls.current.getTarget(currentTarget)
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true) controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
}, [currentLevelId, isPreviewMode]) }, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose])
useEffect(() => { useEffect(() => {
if (!controls.current) return if (isFirstPersonMode || !controls.current) return
controls.current.maxPolarAngle = maxPolarAngle controls.current.maxPolarAngle = maxPolarAngle
controls.current.minPolarAngle = 0 controls.current.minPolarAngle = 0
@@ -73,11 +179,11 @@ export const CustomCameraControls = () => {
if (controls.current.polarAngle > maxPolarAngle) { if (controls.current.polarAngle > maxPolarAngle) {
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true) controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
} }
}, [maxPolarAngle]) }, [isFirstPersonMode, maxPolarAngle])
const focusNode = useCallback( const focusNode = useCallback(
(nodeId: string) => { (nodeId: string) => {
if (isPreviewMode || !controls.current) return if (isPreviewMode || isFirstPersonMode || !controls.current) return
const object3D = sceneRegistry.nodes.get(nodeId) const object3D = sceneRegistry.nodes.get(nodeId)
if (!object3D) return if (!object3D) return
@@ -100,11 +206,10 @@ export const CustomCameraControls = () => {
true, true,
) )
}, },
[isPreviewMode], [isPreviewMode, isFirstPersonMode],
) )
// Configure mouse buttons based on control mode and camera mode // Configure mouse buttons based on control mode and camera mode
const cameraMode = useViewer((state) => state.cameraMode)
const mouseButtons = useMemo(() => { const mouseButtons = useMemo(() => {
// Use ZOOM for orthographic camera, DOLLY for perspective camera // Use ZOOM for orthographic camera, DOLLY for perspective camera
const wheelAction = const wheelAction =
@@ -170,6 +275,8 @@ export const CustomCameraControls = () => {
}, [cameraMode, isPreviewMode, isInteracting]) }, [cameraMode, isPreviewMode, isInteracting])
useEffect(() => { useEffect(() => {
if (isFirstPersonMode) return
const keyState = { const keyState = {
shiftRight: false, shiftRight: false,
shiftLeft: false, shiftLeft: false,
@@ -249,8 +356,9 @@ export const CustomCameraControls = () => {
return () => { return () => {
document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp) document.removeEventListener('keyup', onKeyUp)
document.body.style.cursor = ''
} }
}, [cameraMode, isPreviewMode]) }, [cameraMode, isPreviewMode, isFirstPersonMode])
// Preview mode: auto-navigate camera to selected node (viewer behavior) // Preview mode: auto-navigate camera to selected node (viewer behavior)
const previewTargetNodeId = isPreviewMode const previewTargetNodeId = isPreviewMode
@@ -258,7 +366,7 @@ export const CustomCameraControls = () => {
: null : null
useEffect(() => { useEffect(() => {
if (!(isPreviewMode && controls.current)) return if (!(isPreviewMode && controls.current) || isFirstPersonMode) return
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
@@ -318,7 +426,7 @@ export const CustomCameraControls = () => {
tempCenter.z, tempCenter.z,
true, true,
) )
}, [isPreviewMode, previewTargetNodeId]) }, [isPreviewMode, isFirstPersonMode, previewTargetNodeId])
// Preset capture auto-framing — when `setCaptureMode({ mode: 'preset', // Preset capture auto-framing — when `setCaptureMode({ mode: 'preset',
// isolated })` fires, fly the camera to a pose that fits the union // isolated })` fires, fly the camera to a pose that fits the union
@@ -329,6 +437,7 @@ export const CustomCameraControls = () => {
// modal opened. // modal opened.
const captureMode = useEditor((s) => s.captureMode) const captureMode = useEditor((s) => s.captureMode)
useEffect(() => { useEffect(() => {
if (isFirstPersonMode) return
if (!controls.current) return if (!controls.current) return
if (captureMode.mode !== 'preset') return if (captureMode.mode !== 'preset') return
const ids = captureMode.isolated const ids = captureMode.isolated
@@ -417,11 +526,11 @@ export const CustomCameraControls = () => {
true, true,
) )
} }
}, [captureMode]) }, [captureMode, isFirstPersonMode])
useEffect(() => { useEffect(() => {
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
if (!controls.current) return if (isFirstPersonMode || !controls.current) return
const position = new Vector3() const position = new Vector3()
const target = new Vector3() const target = new Vector3()
@@ -439,7 +548,7 @@ export const CustomCameraControls = () => {
}) })
} }
const handleNodeView = ({ nodeId }: CameraControlEvent) => { const handleNodeView = ({ nodeId }: CameraControlEvent) => {
if (!controls.current) return if (isFirstPersonMode || !controls.current) return
const node = useScene.getState().nodes[nodeId] const node = useScene.getState().nodes[nodeId]
if (!node?.camera) return if (!node?.camera) return
@@ -457,7 +566,7 @@ export const CustomCameraControls = () => {
} }
const handleTopView = () => { const handleTopView = () => {
if (!controls.current) return if (isFirstPersonMode || !controls.current) return
const currentPolarAngle = controls.current.polarAngle const currentPolarAngle = controls.current.polarAngle
@@ -469,7 +578,7 @@ export const CustomCameraControls = () => {
} }
const handleOrbitCW = () => { const handleOrbitCW = () => {
if (!controls.current) return if (isFirstPersonMode || !controls.current) return
const currentAzimuth = controls.current.azimuthAngle const currentAzimuth = controls.current.azimuthAngle
const currentPolar = controls.current.polarAngle const currentPolar = controls.current.polarAngle
@@ -481,7 +590,7 @@ export const CustomCameraControls = () => {
} }
const handleOrbitCCW = () => { const handleOrbitCCW = () => {
if (!controls.current) return if (isFirstPersonMode || !controls.current) return
const currentAzimuth = controls.current.azimuthAngle const currentAzimuth = controls.current.azimuthAngle
const currentPolar = controls.current.polarAngle const currentPolar = controls.current.polarAngle
@@ -497,7 +606,7 @@ export const CustomCameraControls = () => {
} }
const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => { const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
if (!controls.current || isPreviewMode) return if (isFirstPersonMode || !controls.current || isPreviewMode) return
if (!bounds) { if (!bounds) {
// Restore default framing pose when no bounds were computed. // Restore default framing pose when no bounds were computed.
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
@@ -530,7 +639,7 @@ export const CustomCameraControls = () => {
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
emitter.off('camera-controls:fit-scene', handleFitScene) emitter.off('camera-controls:fit-scene', handleFitScene)
} }
}, [focusNode, isPreviewMode]) }, [focusNode, isPreviewMode, isFirstPersonMode])
const onTransitionStart = useCallback(() => { const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true) useViewer.getState().setCameraDragging(true)
@@ -540,10 +649,6 @@ export const CustomCameraControls = () => {
useViewer.getState().setCameraDragging(false) useViewer.getState().setCameraDragging(false)
}, []) }, [])
if (isFirstPersonMode) {
return null
}
// Preset capture mode frames a single subtree (often a 0.32m preset), // Preset capture mode frames a single subtree (often a 0.32m preset),
// so the default 6m minDistance prevents the user from getting close // so the default 6m minDistance prevents the user from getting close
// enough to compose a good thumbnail. Relax the clamp to 0.5m while // enough to compose a good thumbnail. Relax the clamp to 0.5m while
@@ -552,6 +657,10 @@ export const CustomCameraControls = () => {
const isPresetCapture = captureMode.mode === 'preset' const isPresetCapture = captureMode.mode === 'preset'
const minDistance = isPresetCapture ? 0.5 : 6 const minDistance = isPresetCapture ? 0.5 : 6
if (isFirstPersonMode) {
return null
}
return ( return (
<CameraControls <CameraControls
makeDefault makeDefault
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeDefinition,
ColumnNode,
ElevatorNode,
LevelNode,
nodeRegistry,
registerNode,
ShelfNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three'
import { buildFirstPersonColliderWorldFromRegistry } from './build-collider-world'
function registerColliderDefinition(
kind: AnyNode['type'],
schema: AnyNodeDefinition['schema'],
category: AnyNodeDefinition['category'],
) {
registerNode({
kind,
schema,
schemaVersion: 1,
category,
capabilities: {},
} as AnyNodeDefinition)
}
function mountNode(
node: AnyNode,
box: [number, number, number],
position: [number, number, number],
) {
const group = new Group()
const mesh = new Mesh(new BoxGeometry(box[0], box[1], box[2]), new MeshBasicMaterial())
mesh.position.set(position[0], position[1], position[2])
group.add(mesh)
group.updateMatrixWorld(true)
sceneRegistry.nodes.set(node.id, group)
sceneRegistry.byType[node.type]!.add(node.id)
}
function mountRegistryGroup(node: AnyNode) {
const group = new Group()
group.updateMatrixWorld(true)
sceneRegistry.nodes.set(node.id, group)
sceneRegistry.byType[node.type]!.add(node.id)
}
function setSceneNodes(nodes: AnyNode[]) {
useScene.setState({
nodes: Object.fromEntries(nodes.map((node) => [node.id, node])),
rootNodeIds: nodes.map((node) => node.id),
} as never)
}
describe('buildFirstPersonColliderWorldFromRegistry', () => {
afterEach(() => {
sceneRegistry.clear()
nodeRegistry._reset()
useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
})
test('includes structure and furnish nodes discovered through the node registry', () => {
registerColliderDefinition('column', ColumnNode, 'structure')
registerColliderDefinition('shelf', ShelfNode, 'furnish')
const column = ColumnNode.parse({ id: 'column_test' })
const shelf = ShelfNode.parse({ id: 'shelf_test', position: [3, 0, 0] })
setSceneNodes([column, shelf])
mountNode(column, [1, 2, 1], [0, 1, 0])
mountNode(shelf, [2, 1, 1], [3, 0.5, 0])
const world = buildFirstPersonColliderWorldFromRegistry()
expect(world).not.toBeNull()
expect(world?.bounds?.min.x).toBeCloseTo(-0.5)
expect(world?.bounds?.max.x).toBeCloseTo(4)
world?.dispose()
})
test('leaves elevators to their dedicated dynamic collider meshes', () => {
registerColliderDefinition('elevator', ElevatorNode, 'structure')
const elevator = ElevatorNode.parse({ id: 'elevator_test' })
setSceneNodes([elevator])
mountNode(elevator, [2, 3, 2], [0, 1.5, 0])
const world = buildFirstPersonColliderWorldFromRegistry()
expect(world).toBeNull()
})
test('adds a fallback floor for a visible level with no slab', () => {
const level = LevelNode.parse({ id: 'level_test', level: 0 })
setSceneNodes([level])
mountRegistryGroup(level)
const world = buildFirstPersonColliderWorldFromRegistry()
expect(world).not.toBeNull()
expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
expect(world?.bounds?.max.y).toBeCloseTo(0)
world?.dispose()
})
})
@@ -1,8 +1,10 @@
import { import {
type AnyNode,
type AnyNodeId, type AnyNodeId,
type DoorNode, type DoorNode,
getGarageVisibleOpeningRatio, getGarageVisibleOpeningRatio,
isOperationDoorType, isOperationDoorType,
nodeRegistry,
sceneRegistry, sceneRegistry,
useInteractive, useInteractive,
useScene, useScene,
@@ -10,21 +12,11 @@ import {
import * as THREE from 'three' import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
import { computeSceneBoundsXZ } from '../../../lib/scene-bounds'
const COLLIDER_NODE_TYPES = [
'wall',
'fence',
'slab',
'stair',
'stair-segment',
'roof',
'roof-segment',
'door',
'window',
'item',
] as const
const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh']) const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh'])
const COLLIDER_NODE_CATEGORIES = new Set(['structure', 'furnish'])
const DEDICATED_COLLIDER_NODE_TYPES = new Set<AnyNode['type']>(['elevator'])
const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial() const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial()
const DOWN = new THREE.Vector3(0, -1, 0) const DOWN = new THREE.Vector3(0, -1, 0)
const UP = new THREE.Vector3(0, 1, 0) const UP = new THREE.Vector3(0, 1, 0)
@@ -32,6 +24,9 @@ const SPAWN_EYE_HEIGHT = 1.65
const RAYCAST_CLEARANCE = 25 const RAYCAST_CLEARANCE = 25
const DOOR_LEAF_COLLIDER_DEPTH = 0.06 const DOOR_LEAF_COLLIDER_DEPTH = 0.06
const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85 const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85
const LEVEL_FALLBACK_FLOOR_THICKNESS = 0.08
const LEVEL_FALLBACK_FLOOR_PADDING = 2
const LEVEL_FALLBACK_FLOOR_MIN_SIZE = 30
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
@@ -46,7 +41,8 @@ export type FirstPersonSpawn = {
yaw: number yaw: number
} }
type ColliderNodeType = (typeof COLLIDER_NODE_TYPES)[number] type LevelNode = Extract<AnyNode, { type: 'level' }>
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 {
return 'isMesh' in object && (object as THREE.Mesh).isMesh return 'isMesh' in object && (object as THREE.Mesh).isMesh
@@ -56,6 +52,72 @@ function isColliderMaterialVisible(material: THREE.Material | THREE.Material[])
return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
} }
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 ?? '')
}
function createBoxColliderGeometry(width: number, height: number, depth: number) {
const sourceGeometry = new THREE.BoxGeometry(width, height, depth).toNonIndexed()
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
sourceGeometry.dispose()
return geometry
}
function getVisibleLevelChildren(level: LevelNode, nodes: SceneNodes) {
return level.children
.map((childId) => nodes[childId as AnyNodeId])
.filter((child): child is AnyNode => Boolean(child && child.visible !== false))
}
function createLevelFallbackFloorGeometry(level: LevelNode, nodes: SceneNodes) {
if (level.visible === false) return null
const children = getVisibleLevelChildren(level, nodes)
if (children.some((child) => child.type === 'slab')) return null
const levelObject = sceneRegistry.nodes.get(level.id)
if (!levelObject?.visible) return null
const bounds = computeSceneBoundsXZ(children)
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)
levelObject.updateWorldMatrix(true, false)
geometry.applyMatrix4(
new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ),
)
geometry.applyMatrix4(levelObject.matrixWorld)
return geometry
}
function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
const geometries: THREE.BufferGeometry[] = []
for (const levelId of sceneRegistry.byType.level!) {
const node = nodes[levelId as AnyNodeId]
if (node?.type !== 'level') continue
const geometry = createLevelFallbackFloorGeometry(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
@@ -107,16 +169,12 @@ function cloneWorldGeometry(mesh: THREE.Mesh) {
return cleanGeometry return cleanGeometry
} }
function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) { function shouldSkipColliderNode(node: AnyNode) {
if (type === 'window') { if (node.type === 'window') {
const node = useScene.getState().nodes[nodeId as AnyNodeId] return node.openingKind === 'opening'
return node?.type === 'window' && node.openingKind === 'opening'
} }
if (type !== 'door') return false if (node.type !== 'door') return false
const node = useScene.getState().nodes[nodeId as AnyNodeId]
if (!node || node.type !== 'door') return false
if (!node.segments.length) return true if (!node.segments.length) return true
@@ -145,15 +203,7 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
const visibleHeight = leafH * (1 - openAmount) const visibleHeight = leafH * (1 - openAmount)
if (visibleHeight <= 0.12) return null if (visibleHeight <= 0.12) return null
const sourceGeometry = new THREE.BoxGeometry( const geometry = createBoxColliderGeometry(leafW, visibleHeight, DOOR_LEAF_COLLIDER_DEPTH)
leafW,
visibleHeight,
DOOR_LEAF_COLLIDER_DEPTH,
).toNonIndexed()
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
sourceGeometry.dispose()
const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2 const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2
geometry.applyMatrix4( geometry.applyMatrix4(
root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)), root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)),
@@ -174,15 +224,7 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle ?? 0)) const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle ?? 0))
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
const sourceGeometry = new THREE.BoxGeometry( const geometry = createBoxColliderGeometry(leafW, leafH, DOOR_LEAF_COLLIDER_DEPTH)
leafW,
leafH,
DOOR_LEAF_COLLIDER_DEPTH,
).toNonIndexed()
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
sourceGeometry.dispose()
const matrix = root.matrixWorld const matrix = root.matrixWorld
.clone() .clone()
.multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0)) .multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0))
@@ -193,16 +235,17 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
return geometry return geometry
} }
function buildRegisteredNodeTypeLookup() { function buildRegisteredColliderNodeIds(nodes: SceneNodes) {
const nodeTypes = new Map<string, ColliderNodeType>() const nodeIds = new Set<string>()
for (const type of COLLIDER_NODE_TYPES) { for (const nodeId of sceneRegistry.nodes.keys()) {
for (const nodeId of sceneRegistry.byType[type]!) { const node = nodes[nodeId as AnyNodeId]
nodeTypes.set(nodeId, type) if (!node || !isGenericColliderNode(node)) continue
} if (shouldSkipColliderNode(node)) continue
nodeIds.add(nodeId)
} }
return nodeTypes return nodeIds
} }
function collectColliderGeometriesFromNode( function collectColliderGeometriesFromNode(
@@ -210,7 +253,7 @@ function collectColliderGeometriesFromNode(
rootNodeId: string, rootNodeId: string,
visitedMeshes: WeakSet<THREE.Object3D>, visitedMeshes: WeakSet<THREE.Object3D>,
registeredObjectIds: Map<THREE.Object3D, string>, registeredObjectIds: Map<THREE.Object3D, string>,
registeredNodeTypes: Map<string, ColliderNodeType>, registeredColliderNodeIds: Set<string>,
): THREE.BufferGeometry[] { ): THREE.BufferGeometry[] {
const geometries: THREE.BufferGeometry[] = [] const geometries: THREE.BufferGeometry[] = []
@@ -232,12 +275,9 @@ function collectColliderGeometriesFromNode(
for (const child of object.children) { for (const child of object.children) {
const childNodeId = registeredObjectIds.get(child) const childNodeId = registeredObjectIds.get(child)
if (childNodeId && childNodeId !== rootNodeId) { if (childNodeId && childNodeId !== rootNodeId && registeredColliderNodeIds.has(childNodeId)) {
const childType = registeredNodeTypes.get(childNodeId)
if (childType && COLLIDER_NODE_TYPES.includes(childType)) {
continue continue
} }
}
visit(child) visit(child)
} }
@@ -249,26 +289,24 @@ function collectColliderGeometriesFromNode(
} }
export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null { export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null {
const nodes = useScene.getState().nodes
const geometries: THREE.BufferGeometry[] = [] const geometries: THREE.BufferGeometry[] = []
const visitedMeshes = new WeakSet<THREE.Object3D>() const visitedMeshes = new WeakSet<THREE.Object3D>()
const registeredNodeTypes = buildRegisteredNodeTypeLookup() const registeredColliderNodeIds = buildRegisteredColliderNodeIds(nodes)
const registeredObjectIds = new Map<THREE.Object3D, string>() const registeredObjectIds = new Map<THREE.Object3D, string>()
for (const [nodeId, object] of sceneRegistry.nodes) { for (const [nodeId, object] of sceneRegistry.nodes) {
registeredObjectIds.set(object, nodeId) registeredObjectIds.set(object, nodeId)
} }
for (const type of COLLIDER_NODE_TYPES) { for (const nodeId of registeredColliderNodeIds) {
for (const nodeId of sceneRegistry.byType[type]!) { const node = nodes[nodeId as AnyNodeId]
if (shouldSkipColliderNode(nodeId, type)) continue if (!node) continue
const root = sceneRegistry.nodes.get(nodeId) const root = sceneRegistry.nodes.get(nodeId)
if (!root) continue if (!root) continue
if (type === 'door') { if (node.type === 'door') {
const node = useScene.getState().nodes[nodeId as AnyNodeId]
if (node?.type !== 'door') continue
const doorGeometry = createDoorLeafColliderGeometry(root, node) const doorGeometry = createDoorLeafColliderGeometry(root, node)
if (doorGeometry) { if (doorGeometry) {
geometries.push(doorGeometry) geometries.push(doorGeometry)
@@ -283,11 +321,12 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
nodeId, nodeId,
visitedMeshes, visitedMeshes,
registeredObjectIds, registeredObjectIds,
registeredNodeTypes, registeredColliderNodeIds,
), ),
) )
} }
}
geometries.push(...collectLevelFallbackFloorGeometries(nodes))
if (geometries.length === 0) { if (geometries.length === 0) {
return null return null
@@ -311,7 +350,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
;(bvhGeometry as any).computeBoundsTree = computeBoundsTree ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
bvhGeometry.computeBoundsTree?.({ bvhGeometry.computeBoundsTree?.({
maxLeafTris: 12, maxLeafSize: 12,
strategy: 0, strategy: 0,
} as never) } as never)
bvhGeometry.computeBoundingBox() bvhGeometry.computeBoundingBox()
@@ -1,6 +1,11 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { SpawnNode as SpawnSchemaFromCore } from '@pascal-app/core' import {
type FloorplanGeometry,
type GeometryContext,
SpawnNode as SpawnSchemaFromCore,
} from '@pascal-app/core'
import { spawnDefinition } from '../definition' import { spawnDefinition } from '../definition'
import { buildSpawnFloorplan } from '../floorplan'
import { SpawnNode } from '../schema' import { SpawnNode } from '../schema'
/** /**
@@ -8,7 +13,7 @@ import { SpawnNode } from '../schema'
* *
* The new renderer is a near-line-by-line port of the legacy * The new renderer is a near-line-by-line port of the legacy
* `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` — * `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` —
* same mesh count, same primitives, same colors. The "parity" assertion * same mesh count and primitives. The "parity" assertion
* for the spike is structural (definition is well-formed, both lazy * for the spike is structural (definition is well-formed, both lazy
* modules resolve to React components) plus a manual visual eyeball check * modules resolve to React components) plus a manual visual eyeball check
* documented in the plan. Pixel-level Playwright parity lands in Phase 4 * documented in the plan. Pixel-level Playwright parity lands in Phase 4
@@ -52,6 +57,56 @@ describe('spawn definition', () => {
expect(angles).toContain(0) expect(angles).toContain(0)
}) })
test('handles expose rotation and move controls', () => {
expect(Array.isArray(spawnDefinition.handles)).toBe(true)
if (!Array.isArray(spawnDefinition.handles)) return
expect(spawnDefinition.handles.map((handle) => handle.kind)).toEqual([
'arc-resize',
'translate',
])
})
test('floorplan uses indigo marker color and selected rotation affordance', () => {
const spawn = SpawnNode.parse({
id: 'spawn_test1234567890ab',
position: [1, 0, 2],
rotation: Math.PI / 4,
})
const geometry = buildSpawnFloorplan(spawn, {
resolve: () => undefined,
children: [],
siblings: [],
parent: null,
viewState: {
selected: true,
highlighted: false,
hovered: false,
moving: false,
palette: {
selectedStroke: '#60a5fa',
selectedFill: '#dbeafe',
selectedHatch: '#60a5fa',
wallHoverStroke: '#60a5fa',
endpointHandleFill: '#fed7aa',
endpointHandleStroke: '#f97316',
endpointHandleHoverStroke: '#fb923c',
endpointHandleActiveFill: '#fdba74',
endpointHandleActiveStroke: '#ea580c',
curveHandleFill: '#99f6e4',
curveHandleStroke: '#14b8a6',
curveHandleHoverStroke: '#2dd4bf',
measurementStroke: '#6366f1',
measurementLabelBackground: '#ffffff',
measurementLabelText: '#111827',
},
},
} satisfies GeometryContext)
const flat = flattenFloorplan(geometry)
expect(flat.some((entry) => entry.kind === 'polygon' && entry.fill === '#818cf8')).toBe(true)
expect(flat.some((entry) => entry.kind === 'rotate-arrow')).toBe(true)
})
test('renderer is a parametric lazy module reference', () => { test('renderer is a parametric lazy module reference', () => {
expect(spawnDefinition.renderer.kind).toBe('parametric') expect(spawnDefinition.renderer.kind).toBe('parametric')
if (spawnDefinition.renderer.kind !== 'parametric') return if (spawnDefinition.renderer.kind !== 'parametric') return
@@ -67,3 +122,8 @@ describe('spawn definition', () => {
expect(spawnDefinition.mcp?.description?.length).toBeGreaterThan(0) expect(spawnDefinition.mcp?.description?.length).toBeGreaterThan(0)
}) })
}) })
function flattenFloorplan(geometry: FloorplanGeometry): FloorplanGeometry[] {
if (geometry.kind !== 'group') return [geometry]
return geometry.children.flatMap((child) => flattenFloorplan(child))
}
+30 -1
View File
@@ -1,10 +1,36 @@
import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core' import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core'
import { buildSpawnFloorplan } from './floorplan' import { buildSpawnFloorplan } from './floorplan'
import { spawnRotateAffordance } from './floorplan-affordances'
import { spawnParametrics } from './parametrics' import { spawnParametrics } from './parametrics'
import { SpawnNode } from './schema' import { SpawnNode } from './schema'
const SPAWN_FOOTPRINT = 0.6 const SPAWN_FOOTPRINT = 0.6
const SPAWN_HANDLE_HEIGHT = 0.46
const MOVE_FRONT_OFFSET = 0.35 const MOVE_FRONT_OFFSET = 0.35
const ROTATE_CORNER_OFFSET = 0.32
const ROTATE_RING_OFFSET = 0.04
function spawnRotateHandle(): HandleDescriptor<SpawnNodeType> {
return {
kind: 'arc-resize',
axis: 'angular',
shape: 'rotate',
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
placement: {
position: () => [
SPAWN_FOOTPRINT / 2,
SPAWN_HANDLE_HEIGHT,
SPAWN_FOOTPRINT / 2 + ROTATE_CORNER_OFFSET,
],
rotationY: () => -Math.PI / 4,
},
decoration: {
kind: 'ring',
radius: () => Math.hypot(SPAWN_FOOTPRINT / 2, SPAWN_FOOTPRINT / 2) + ROTATE_RING_OFFSET,
y: () => SPAWN_HANDLE_HEIGHT,
},
}
}
function spawnMoveHandle(): HandleDescriptor<SpawnNodeType> { function spawnMoveHandle(): HandleDescriptor<SpawnNodeType> {
return { return {
@@ -52,7 +78,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
}, },
parametrics: spawnParametrics, parametrics: spawnParametrics,
handles: [spawnMoveHandle()], handles: [spawnRotateHandle(), spawnMoveHandle()],
renderer: { renderer: {
kind: 'parametric', kind: 'parametric',
@@ -66,6 +92,9 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
// delete. Legacy spawn click handlers in FloorplanNodeLayer become // delete. Legacy spawn click handlers in FloorplanNodeLayer become
// dead code once Phase 6 cleanup removes the [] entries path. // dead code once Phase 6 cleanup removes the [] entries path.
floorplan: buildSpawnFloorplan, floorplan: buildSpawnFloorplan,
floorplanAffordances: {
'spawn-rotate': spawnRotateAffordance,
},
tool: () => import('./tool'), tool: () => import('./tool'),
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Place spawn point' }, { key: 'Left click', label: 'Place spawn point' },
@@ -0,0 +1,35 @@
import {
type AnyNodeId,
type FloorplanAffordance,
type SpawnNode,
useScene,
} from '@pascal-app/core'
export const spawnRotateAffordance: FloorplanAffordance<SpawnNode> = {
start({ node, initialPlanPoint }) {
const spawnId = node.id as AnyNodeId
const initialRotation = node.rotation ?? 0
const cx = node.position[0]
const cz = node.position[2]
const initialAngle = Math.atan2(initialPlanPoint[1] - cz, initialPlanPoint[0] - cx)
let lastRotation = initialRotation
return {
affectedIds: [spawnId],
apply({ planPoint }) {
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
let delta = currentAngle - initialAngle
while (delta > Math.PI) delta -= 2 * Math.PI
while (delta < -Math.PI) delta += 2 * Math.PI
lastRotation = initialRotation - delta
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
},
canCommit() {
return true
},
commit() {
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
},
}
},
}
+38 -7
View File
@@ -1,21 +1,26 @@
import type { FloorplanGeometry } from '@pascal-app/core' import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
import type { SpawnNode } from './schema' import type { SpawnNode } from './schema'
const SPAWN_COLOR = '#818cf8'
const ROTATE_ARROW_CORNER_OFFSET = 0.22
/** /**
* 2D floor-plan marker for a spawn point. A small filled circle at the * 2D floor-plan marker for a spawn point. A small filled circle at the
* spawn's position, with a triangular arrow indicating the facing * spawn's position, with a triangular arrow indicating the facing
* direction (rotation around Y, looking down at the X-Z plane). * direction (rotation around Y, looking down at the X-Z plane).
* *
* Color matches the 3D renderer's `SPAWN_COLOR = '#22c55e'` so the user * Color matches the 3D renderer's indigo spawn material so the user
* sees the same visual identity in both views. * sees the same visual identity in both views.
* *
* Coordinates are level-local meters; rotation is radians. * Coordinates are level-local meters; rotation is radians.
*/ */
export function buildSpawnFloorplan(node: SpawnNode): FloorplanGeometry { export function buildSpawnFloorplan(node: SpawnNode, ctx: GeometryContext): FloorplanGeometry {
const [px, , pz] = node.position const [px, , pz] = node.position
const ry = node.rotation const ry = node.rotation
const isSelected = ctx.viewState?.selected ?? false
return { const children: FloorplanGeometry[] = [
{
kind: 'group', kind: 'group',
transform: { translate: [px, pz], rotate: ry }, transform: { translate: [px, pz], rotate: ry },
children: [ children: [
@@ -28,7 +33,7 @@ export function buildSpawnFloorplan(node: SpawnNode): FloorplanGeometry {
[-0.18, 0.12], [-0.18, 0.12],
[0.18, 0.12], [0.18, 0.12],
], ],
fill: '#22c55e', fill: SPAWN_COLOR,
opacity: 0.85, opacity: 0.85,
}, },
// Spawn body marker — circle outline so the spawn is legible at // Spawn body marker — circle outline so the spawn is legible at
@@ -38,11 +43,37 @@ export function buildSpawnFloorplan(node: SpawnNode): FloorplanGeometry {
cx: 0, cx: 0,
cy: 0, cy: 0,
r: 0.34, r: 0.34,
stroke: '#22c55e', stroke: SPAWN_COLOR,
strokeWidth: 0.025, strokeWidth: 0.025,
fill: '#22c55e', fill: SPAWN_COLOR,
opacity: 0.18, opacity: 0.18,
}, },
], ],
},
]
if (isSelected) {
const cornerLocalX = 0.34 + ROTATE_ARROW_CORNER_OFFSET
const cornerLocalZ = 0.34 + ROTATE_ARROW_CORNER_OFFSET
const [cornerX, cornerZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, ry)
const [radialX, radialZ] = rotatePlanVector(1, 1, ry)
children.push({
kind: 'rotate-arrow',
point: [px + cornerX, pz + cornerZ],
angle: Math.atan2(radialZ, radialX),
affordance: 'spawn-rotate',
pivot: [px, pz],
})
}
return {
kind: 'group',
children,
} }
} }
function rotatePlanVector(x: number, y: number, rotation: number): FloorplanPoint {
const c = Math.cos(rotation)
const s = Math.sin(rotation)
return [x * c - y * s, x * s + y * c]
}
+3 -3
View File
@@ -11,12 +11,12 @@ import { createDefaultMaterial, useNodeEvents, useViewer } from '@pascal-app/vie
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { Color, type Group, Shape } from 'three' import { Color, type Group, Shape } from 'three'
const SPAWN_COLOR = new Color('#22c55e') const SPAWN_COLOR = new Color('#818cf8')
/** /**
* Registry-driven spawn renderer. Behaviorally identical to the legacy * Registry-driven spawn renderer. Behaviorally identical to the legacy
* `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` — same * `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` — same
* geometry, same colors, same event surface. When the spawn definition lands * geometry and event surface. When the spawn definition lands
* in `builtinPlugin.nodes`, the Phase 0 dispatch shims switch the renderer * in `builtinPlugin.nodes`, the Phase 0 dispatch shims switch the renderer
* here and the legacy one is short-circuited. * here and the legacy one is short-circuited.
* *
@@ -38,7 +38,7 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
useRegistry(node.id, 'spawn', ref) useRegistry(node.id, 'spawn', ref)
const material = useMemo(() => { const material = useMemo(() => {
const next = createDefaultMaterial('#22c55e', 0.42, shading) as ReturnType< const next = createDefaultMaterial('#818cf8', 0.42, shading) as ReturnType<
typeof createDefaultMaterial typeof createDefaultMaterial
> & { > & {
emissive?: Color emissive?: Color
+1 -1
View File
@@ -120,7 +120,7 @@ const SpawnTool = () => {
if (!activeLevelId) return null if (!activeLevelId) return null
return <CursorSphere color="#60a5fa" height={2.2} ref={cursorRef} /> return <CursorSphere color="#818cf8" height={2.2} ref={cursorRef} />
} }
export default SpawnTool export default SpawnTool