fix: restore walkthrough collisions and spawn controls
This commit is contained in:
@@ -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.3–2m preset),
|
// Preset capture mode frames a single subtree (often a 0.3–2m 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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user