diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 38091ba7..1fe75c4c 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -10,7 +10,7 @@ import { } from '@pascal-app/core' import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer' 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 { Box3, Vector3 } from 'three' import { EDITOR_LAYER } from '../../lib/constants' @@ -25,13 +25,119 @@ const tempSize = new Vector3() const tempTarget = new Vector3() const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 +type CameraMode = ReturnType['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({ + 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 = () => { - const controls = useRef(null!) + const controls = useRef(null) const isPreviewMode = useEditor((s) => s.isPreviewMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) const selection = useViewer((s) => s.selection) + const cameraMode = useViewer((state) => state.cameraMode) + const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore( + controls, + isFirstPersonMode, + cameraMode, + ) const currentLevelId = selection.levelId const firstLoad = useRef(true) const maxPolarAngle = @@ -47,7 +153,7 @@ export const CustomCameraControls = () => { }, [camera, raycaster]) useEffect(() => { - if (isPreviewMode) return // Preview mode uses auto-navigate instead + if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return let targetY = 0 if (currentLevelId) { const levelMesh = sceneRegistry.nodes.get(currentLevelId) @@ -62,10 +168,10 @@ export const CustomCameraControls = () => { } controls.current.getTarget(currentTarget) controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true) - }, [currentLevelId, isPreviewMode]) + }, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose]) useEffect(() => { - if (!controls.current) return + if (isFirstPersonMode || !controls.current) return controls.current.maxPolarAngle = maxPolarAngle controls.current.minPolarAngle = 0 @@ -73,11 +179,11 @@ export const CustomCameraControls = () => { if (controls.current.polarAngle > maxPolarAngle) { controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true) } - }, [maxPolarAngle]) + }, [isFirstPersonMode, maxPolarAngle]) const focusNode = useCallback( (nodeId: string) => { - if (isPreviewMode || !controls.current) return + if (isPreviewMode || isFirstPersonMode || !controls.current) return const object3D = sceneRegistry.nodes.get(nodeId) if (!object3D) return @@ -100,11 +206,10 @@ export const CustomCameraControls = () => { true, ) }, - [isPreviewMode], + [isPreviewMode, isFirstPersonMode], ) // Configure mouse buttons based on control mode and camera mode - const cameraMode = useViewer((state) => state.cameraMode) const mouseButtons = useMemo(() => { // Use ZOOM for orthographic camera, DOLLY for perspective camera const wheelAction = @@ -170,6 +275,8 @@ export const CustomCameraControls = () => { }, [cameraMode, isPreviewMode, isInteracting]) useEffect(() => { + if (isFirstPersonMode) return + const keyState = { shiftRight: false, shiftLeft: false, @@ -249,8 +356,9 @@ export const CustomCameraControls = () => { return () => { document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keyup', onKeyUp) + document.body.style.cursor = '' } - }, [cameraMode, isPreviewMode]) + }, [cameraMode, isPreviewMode, isFirstPersonMode]) // Preview mode: auto-navigate camera to selected node (viewer behavior) const previewTargetNodeId = isPreviewMode @@ -258,7 +366,7 @@ export const CustomCameraControls = () => { : null useEffect(() => { - if (!(isPreviewMode && controls.current)) return + if (!(isPreviewMode && controls.current) || isFirstPersonMode) return const nodes = useScene.getState().nodes let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null @@ -318,7 +426,7 @@ export const CustomCameraControls = () => { tempCenter.z, true, ) - }, [isPreviewMode, previewTargetNodeId]) + }, [isPreviewMode, isFirstPersonMode, previewTargetNodeId]) // Preset capture auto-framing — when `setCaptureMode({ mode: 'preset', // isolated })` fires, fly the camera to a pose that fits the union @@ -329,6 +437,7 @@ export const CustomCameraControls = () => { // modal opened. const captureMode = useEditor((s) => s.captureMode) useEffect(() => { + if (isFirstPersonMode) return if (!controls.current) return if (captureMode.mode !== 'preset') return const ids = captureMode.isolated @@ -417,11 +526,11 @@ export const CustomCameraControls = () => { true, ) } - }, [captureMode]) + }, [captureMode, isFirstPersonMode]) useEffect(() => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { - if (!controls.current) return + if (isFirstPersonMode || !controls.current) return const position = new Vector3() const target = new Vector3() @@ -439,7 +548,7 @@ export const CustomCameraControls = () => { }) } const handleNodeView = ({ nodeId }: CameraControlEvent) => { - if (!controls.current) return + if (isFirstPersonMode || !controls.current) return const node = useScene.getState().nodes[nodeId] if (!node?.camera) return @@ -457,7 +566,7 @@ export const CustomCameraControls = () => { } const handleTopView = () => { - if (!controls.current) return + if (isFirstPersonMode || !controls.current) return const currentPolarAngle = controls.current.polarAngle @@ -469,7 +578,7 @@ export const CustomCameraControls = () => { } const handleOrbitCW = () => { - if (!controls.current) return + if (isFirstPersonMode || !controls.current) return const currentAzimuth = controls.current.azimuthAngle const currentPolar = controls.current.polarAngle @@ -481,7 +590,7 @@ export const CustomCameraControls = () => { } const handleOrbitCCW = () => { - if (!controls.current) return + if (isFirstPersonMode || !controls.current) return const currentAzimuth = controls.current.azimuthAngle const currentPolar = controls.current.polarAngle @@ -497,7 +606,7 @@ export const CustomCameraControls = () => { } const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => { - if (!controls.current || isPreviewMode) return + if (isFirstPersonMode || !controls.current || isPreviewMode) return if (!bounds) { // Restore default framing pose when no bounds were computed. 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:fit-scene', handleFitScene) } - }, [focusNode, isPreviewMode]) + }, [focusNode, isPreviewMode, isFirstPersonMode]) const onTransitionStart = useCallback(() => { useViewer.getState().setCameraDragging(true) @@ -540,10 +649,6 @@ export const CustomCameraControls = () => { useViewer.getState().setCameraDragging(false) }, []) - if (isFirstPersonMode) { - return null - } - // Preset capture mode frames a single subtree (often a 0.3–2m preset), // so the default 6m minDistance prevents the user from getting close // 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 minDistance = isPresetCapture ? 0.5 : 6 + if (isFirstPersonMode) { + return null + } + return ( [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() + }) +}) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index ff039020..478336c0 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -1,8 +1,10 @@ import { + type AnyNode, type AnyNodeId, type DoorNode, getGarageVisibleOpeningRatio, isOperationDoorType, + nodeRegistry, sceneRegistry, useInteractive, useScene, @@ -10,21 +12,11 @@ import { import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' - -const COLLIDER_NODE_TYPES = [ - 'wall', - 'fence', - 'slab', - 'stair', - 'stair-segment', - 'roof', - 'roof-segment', - 'door', - 'window', - 'item', -] as const +import { computeSceneBoundsXZ } from '../../../lib/scene-bounds' const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh']) +const COLLIDER_NODE_CATEGORIES = new Set(['structure', 'furnish']) +const DEDICATED_COLLIDER_NODE_TYPES = new Set(['elevator']) const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial() const DOWN = 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 DOOR_LEAF_COLLIDER_DEPTH = 0.06 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 @@ -46,7 +41,8 @@ export type FirstPersonSpawn = { yaw: number } -type ColliderNodeType = (typeof COLLIDER_NODE_TYPES)[number] +type LevelNode = Extract +type SceneNodes = ReturnType['nodes'] function isMesh(object: THREE.Object3D): object is THREE.Mesh { 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 } +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 // plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every // merged geometry to share the same typed-array constructor for matching attributes, so @@ -107,16 +169,12 @@ function cloneWorldGeometry(mesh: THREE.Mesh) { return cleanGeometry } -function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) { - if (type === 'window') { - const node = useScene.getState().nodes[nodeId as AnyNodeId] - return node?.type === 'window' && node.openingKind === 'opening' +function shouldSkipColliderNode(node: AnyNode) { + if (node.type === 'window') { + return node.openingKind === 'opening' } - if (type !== 'door') return false - - const node = useScene.getState().nodes[nodeId as AnyNodeId] - if (!node || node.type !== 'door') return false + if (node.type !== 'door') return false if (!node.segments.length) return true @@ -145,15 +203,7 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { const visibleHeight = leafH * (1 - openAmount) if (visibleHeight <= 0.12) return null - const sourceGeometry = new THREE.BoxGeometry( - 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 geometry = createBoxColliderGeometry(leafW, visibleHeight, DOOR_LEAF_COLLIDER_DEPTH) const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2 geometry.applyMatrix4( 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 leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign - const sourceGeometry = new THREE.BoxGeometry( - 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 geometry = createBoxColliderGeometry(leafW, leafH, DOOR_LEAF_COLLIDER_DEPTH) const matrix = root.matrixWorld .clone() .multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0)) @@ -193,16 +235,17 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { return geometry } -function buildRegisteredNodeTypeLookup() { - const nodeTypes = new Map() +function buildRegisteredColliderNodeIds(nodes: SceneNodes) { + const nodeIds = new Set() - for (const type of COLLIDER_NODE_TYPES) { - for (const nodeId of sceneRegistry.byType[type]!) { - nodeTypes.set(nodeId, type) - } + for (const nodeId of sceneRegistry.nodes.keys()) { + const node = nodes[nodeId as AnyNodeId] + if (!node || !isGenericColliderNode(node)) continue + if (shouldSkipColliderNode(node)) continue + nodeIds.add(nodeId) } - return nodeTypes + return nodeIds } function collectColliderGeometriesFromNode( @@ -210,7 +253,7 @@ function collectColliderGeometriesFromNode( rootNodeId: string, visitedMeshes: WeakSet, registeredObjectIds: Map, - registeredNodeTypes: Map, + registeredColliderNodeIds: Set, ): THREE.BufferGeometry[] { const geometries: THREE.BufferGeometry[] = [] @@ -232,11 +275,8 @@ function collectColliderGeometriesFromNode( for (const child of object.children) { const childNodeId = registeredObjectIds.get(child) - if (childNodeId && childNodeId !== rootNodeId) { - const childType = registeredNodeTypes.get(childNodeId) - if (childType && COLLIDER_NODE_TYPES.includes(childType)) { - continue - } + if (childNodeId && childNodeId !== rootNodeId && registeredColliderNodeIds.has(childNodeId)) { + continue } visit(child) @@ -249,46 +289,45 @@ function collectColliderGeometriesFromNode( } export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null { + const nodes = useScene.getState().nodes const geometries: THREE.BufferGeometry[] = [] const visitedMeshes = new WeakSet() - const registeredNodeTypes = buildRegisteredNodeTypeLookup() + const registeredColliderNodeIds = buildRegisteredColliderNodeIds(nodes) const registeredObjectIds = new Map() for (const [nodeId, object] of sceneRegistry.nodes) { registeredObjectIds.set(object, nodeId) } - for (const type of COLLIDER_NODE_TYPES) { - for (const nodeId of sceneRegistry.byType[type]!) { - if (shouldSkipColliderNode(nodeId, type)) continue + for (const nodeId of registeredColliderNodeIds) { + const node = nodes[nodeId as AnyNodeId] + if (!node) continue - const root = sceneRegistry.nodes.get(nodeId) - if (!root) continue + const root = sceneRegistry.nodes.get(nodeId) + if (!root) continue - if (type === 'door') { - const node = useScene.getState().nodes[nodeId as AnyNodeId] - if (node?.type !== 'door') continue - - const doorGeometry = createDoorLeafColliderGeometry(root, node) - if (doorGeometry) { - geometries.push(doorGeometry) - } - continue + if (node.type === 'door') { + const doorGeometry = createDoorLeafColliderGeometry(root, node) + if (doorGeometry) { + geometries.push(doorGeometry) } - - root.updateMatrixWorld(true) - geometries.push( - ...collectColliderGeometriesFromNode( - root, - nodeId, - visitedMeshes, - registeredObjectIds, - registeredNodeTypes, - ), - ) + continue } + + root.updateMatrixWorld(true) + geometries.push( + ...collectColliderGeometriesFromNode( + root, + nodeId, + visitedMeshes, + registeredObjectIds, + registeredColliderNodeIds, + ), + ) } + geometries.push(...collectLevelFallbackFloorGeometries(nodes)) + if (geometries.length === 0) { return null } @@ -311,7 +350,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree bvhGeometry.computeBoundsTree?.({ - maxLeafTris: 12, + maxLeafSize: 12, strategy: 0, } as never) bvhGeometry.computeBoundingBox() diff --git a/packages/nodes/src/spawn/__tests__/parity.test.ts b/packages/nodes/src/spawn/__tests__/parity.test.ts index 67f85c3e..3c1d8d01 100644 --- a/packages/nodes/src/spawn/__tests__/parity.test.ts +++ b/packages/nodes/src/spawn/__tests__/parity.test.ts @@ -1,6 +1,11 @@ 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 { buildSpawnFloorplan } from '../floorplan' 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 * `@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 * modules resolve to React components) plus a manual visual eyeball check * documented in the plan. Pixel-level Playwright parity lands in Phase 4 @@ -52,6 +57,56 @@ describe('spawn definition', () => { 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', () => { expect(spawnDefinition.renderer.kind).toBe('parametric') if (spawnDefinition.renderer.kind !== 'parametric') return @@ -67,3 +122,8 @@ describe('spawn definition', () => { 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)) +} diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index 0f7109df..f942b893 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -1,10 +1,36 @@ import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core' import { buildSpawnFloorplan } from './floorplan' +import { spawnRotateAffordance } from './floorplan-affordances' import { spawnParametrics } from './parametrics' import { SpawnNode } from './schema' const SPAWN_FOOTPRINT = 0.6 +const SPAWN_HANDLE_HEIGHT = 0.46 const MOVE_FRONT_OFFSET = 0.35 +const ROTATE_CORNER_OFFSET = 0.32 +const ROTATE_RING_OFFSET = 0.04 + +function spawnRotateHandle(): HandleDescriptor { + 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 { return { @@ -52,7 +78,7 @@ export const spawnDefinition: NodeDefinition = { }, parametrics: spawnParametrics, - handles: [spawnMoveHandle()], + handles: [spawnRotateHandle(), spawnMoveHandle()], renderer: { kind: 'parametric', @@ -66,6 +92,9 @@ export const spawnDefinition: NodeDefinition = { // delete. Legacy spawn click handlers in FloorplanNodeLayer become // dead code once Phase 6 cleanup removes the [] entries path. floorplan: buildSpawnFloorplan, + floorplanAffordances: { + 'spawn-rotate': spawnRotateAffordance, + }, tool: () => import('./tool'), toolHints: [ { key: 'Left click', label: 'Place spawn point' }, diff --git a/packages/nodes/src/spawn/floorplan-affordances.ts b/packages/nodes/src/spawn/floorplan-affordances.ts new file mode 100644 index 00000000..b8482fb2 --- /dev/null +++ b/packages/nodes/src/spawn/floorplan-affordances.ts @@ -0,0 +1,35 @@ +import { + type AnyNodeId, + type FloorplanAffordance, + type SpawnNode, + useScene, +} from '@pascal-app/core' + +export const spawnRotateAffordance: FloorplanAffordance = { + 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 }) + }, + } + }, +} diff --git a/packages/nodes/src/spawn/floorplan.ts b/packages/nodes/src/spawn/floorplan.ts index 4e219462..2922369a 100644 --- a/packages/nodes/src/spawn/floorplan.ts +++ b/packages/nodes/src/spawn/floorplan.ts @@ -1,48 +1,79 @@ -import type { FloorplanGeometry } from '@pascal-app/core' +import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core' 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 * spawn's position, with a triangular arrow indicating the facing * 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. * * 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 ry = node.rotation + const isSelected = ctx.viewState?.selected ?? false + + const children: FloorplanGeometry[] = [ + { + kind: 'group', + transform: { translate: [px, pz], rotate: ry }, + children: [ + // Direction-pointing triangle, base centered at origin, tip in -Z + // (forward). Matches the 3D arrow's orientation. + { + kind: 'polygon', + points: [ + [0, -0.28], + [-0.18, 0.12], + [0.18, 0.12], + ], + fill: SPAWN_COLOR, + opacity: 0.85, + }, + // Spawn body marker — circle outline so the spawn is legible at + // small zoom levels where the triangle would shrink past visibility. + { + kind: 'circle', + cx: 0, + cy: 0, + r: 0.34, + stroke: SPAWN_COLOR, + strokeWidth: 0.025, + fill: SPAWN_COLOR, + 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', - transform: { translate: [px, pz], rotate: ry }, - children: [ - // Direction-pointing triangle, base centered at origin, tip in -Z - // (forward). Matches the 3D arrow's orientation. - { - kind: 'polygon', - points: [ - [0, -0.28], - [-0.18, 0.12], - [0.18, 0.12], - ], - fill: '#22c55e', - opacity: 0.85, - }, - // Spawn body marker — circle outline so the spawn is legible at - // small zoom levels where the triangle would shrink past visibility. - { - kind: 'circle', - cx: 0, - cy: 0, - r: 0.34, - stroke: '#22c55e', - strokeWidth: 0.025, - fill: '#22c55e', - opacity: 0.18, - }, - ], + 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] +} diff --git a/packages/nodes/src/spawn/renderer.tsx b/packages/nodes/src/spawn/renderer.tsx index 9088ac87..4203655d 100644 --- a/packages/nodes/src/spawn/renderer.tsx +++ b/packages/nodes/src/spawn/renderer.tsx @@ -11,12 +11,12 @@ import { createDefaultMaterial, useNodeEvents, useViewer } from '@pascal-app/vie import { useMemo, useRef } from 'react' 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 * `@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 * here and the legacy one is short-circuited. * @@ -38,7 +38,7 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => { useRegistry(node.id, 'spawn', ref) const material = useMemo(() => { - const next = createDefaultMaterial('#22c55e', 0.42, shading) as ReturnType< + const next = createDefaultMaterial('#818cf8', 0.42, shading) as ReturnType< typeof createDefaultMaterial > & { emissive?: Color diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index 6bb9dc00..fc7b7c8e 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -120,7 +120,7 @@ const SpawnTool = () => { if (!activeLevelId) return null - return + return } export default SpawnTool