diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 980ae420..38e6f33b 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -45,6 +45,7 @@ import { } from 'three' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import '../../three-types' +import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer' import { closeDoorOpenState, DOOR_SWING_OPEN_ANGLE, @@ -64,8 +65,6 @@ import { type FirstPersonColliderWorld, type FirstPersonSpawn, } from './first-person/build-collider-world' -import type { BVHEcctrlApi, MovementInput } from './first-person/bvh-ecctrl' -import BVHEcctrl from './first-person/bvh-ecctrl' const CAMERA_EYE_OFFSET = 0.45 const LOOK_SENSITIVITY = 0.002 diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index e0a731a3..29ef52ce 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -470,6 +470,39 @@ function bakeWindowClip( * (e.g. `pascalSwingLeaf`, cached-material flags) leaks into glTF extras — the * file describes itself with exactly the fields a consumer needs. */ +/** + * Human-readable label for a baked node, mirroring the viewer's `getNodeName`: + * an explicit name wins, items fall back to their catalog asset name, other + * kinds to a capitalized type. Levels override this with their display name. + */ +function nodeDisplayLabel(node: AnyNode): string { + if (node.name) return node.name + switch (node.type) { + case 'item': + return (node as { asset?: { name?: string } }).asset?.name || 'Item' + case 'wall': + return 'Wall' + case 'door': + return 'Door' + case 'window': + return 'Window' + case 'slab': + return 'Slab' + case 'ceiling': + return 'Ceiling' + case 'roof': + return 'Roof' + case 'fence': + return 'Fence' + case 'column': + return 'Column' + case 'stair': + return 'Stairs' + default: + return node.type + } +} + function stampIdentity( scene: THREE.Object3D, cloneByOriginal: Map, @@ -487,7 +520,12 @@ function stampIdentity( target.name = id const extras: Record = { pascalId: id, kind: node.type } - if (node.name) extras.label = node.name + // Stamp a human label for every node (catalog name for items, a type label + // otherwise) so the viewer breadcrumb/hover read names, not raw pascalIds. + extras.label = nodeDisplayLabel(node) + // Camera bookmarks ride on the identity node (any kind can carry one) so the + // baked viewer flies to a saved pose on selection without a side file. + if (node.camera) extras.camera = node.camera // Levels carry no stored name; stamp the editor's display name ("Level 1") // so the baked viewer's level/breadcrumb UI reads the same labels. Force the // node visible: the bake must capture every floor regardless of the editor's @@ -516,6 +554,14 @@ function stampIdentity( extras.color = zone.color target.visible = true } + if (node.type === 'spawn') { + // The spawn marker's visible mesh lives on a non-scene overlay layer (and + // is pruned), so this identity node is an empty transform. Keep it + force + // visible so the baked walkthrough can read its world position/yaw and + // start the player there (`extras.rotation` mirrors the node's yaw). + extras.rotation = (node as { rotation?: number }).rotation ?? 0 + target.visible = true + } target.userData = extras } } diff --git a/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx b/packages/viewer/src/components/viewer/bvh-ecctrl.tsx similarity index 99% rename from packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx rename to packages/viewer/src/components/viewer/bvh-ecctrl.tsx index 3c459659..ef6a3c1f 100644 --- a/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx +++ b/packages/viewer/src/components/viewer/bvh-ecctrl.tsx @@ -1,4 +1,5 @@ -import '../../../three-types' +// R3F JSX type augmentations (mesh, group, box3Helper, …) for the debug overlay. +import '@react-three/fiber' import { TransformControls, useKeyboardControls } from '@react-three/drei' import { type ThreeElements, useFrame, useThree } from '@react-three/fiber' import type { ReactNode } from 'react' diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 5dd8d28b..6718d235 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -1,5 +1,6 @@ 'use client' +import type { SurfaceRole } from '@pascal-app/core' import { Html, useAnimations } from '@react-three/drei' import { type ThreeEvent, useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -9,11 +10,26 @@ import { color, float, uniform, uv } from 'three/tsl' import { MeshBasicNodeMaterial } from 'three/webgpu' import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' import { ZONE_LAYER } from '../../lib/layers' +import { createSurfaceRoleMaterial } from '../../lib/materials' import useViewer from '../../store/use-viewer' /** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */ const EXPLODED_GAP = 5 +/** Baked `kind` → surface role, so monochrome can recolor by role like the + * parametric viewer (textures-off collapses each face to its themed clay). */ +const ROLE_BY_KIND: Record = { + wall: 'wall', + slab: 'floor', + floor: 'floor', + ceiling: 'ceiling', + roof: 'roof', + 'roof-segment': 'roof', + window: 'glazing', + door: 'joinery', + item: 'furnishing', +} + /** A building floor discovered in the baked GLB, ordered bottom-to-top. */ export type GlbLevel = { id: `level_${string}`; label: string } @@ -23,6 +39,14 @@ export type GlbIdentity = Record /** What the cursor would act on at the current drill depth (for a hover label). */ export type GlbHover = { kind: string; label: string } | null +/** Walkthrough HUD state, reported each frame: the floor/room the camera is in + * and the openable door/window directly in view (for the reticle prompt). */ +export type GlbWalkthrough = { + zoneLabel: string | null + floorLabel: string | null + door: { label: string; isOpen: boolean } | null +} | null + type GlbLevelEntry = { id: GlbLevel['id']; node: THREE.Object3D; baseY: number } type GlbZoneEntry = { id: string @@ -43,6 +67,24 @@ type PascalExtras = { clips?: string[] polygon?: [number, number][] color?: string + camera?: { position: [number, number, number]; target: [number, number, number] } +} + +/** The subset of the camera-controls instance the scene drives (drei makeDefault). */ +type LookAtControls = { + setLookAt: ( + px: number, + py: number, + pz: number, + tx: number, + ty: number, + tz: number, + enableTransition?: boolean, + ) => unknown + /** Wraps the wound-up azimuth so a transition rotates the short way, not 360°. */ + normalizeRotations?: () => unknown + /** Pans camera + target together (keeps angle + distance) to re-center a point. */ + moveTo?: (x: number, y: number, z: number, enableTransition?: boolean) => unknown } /** The resolved drill target for a raycast hit, given the current selection. */ @@ -76,6 +118,15 @@ const _up = new THREE.Vector3(0, 1, 0) const _bounds = new THREE.Box3() const _boundsCenter = new THREE.Vector3() const _sample = new THREE.Vector3() +const _camBox = new THREE.Box3() +const _camCenter = new THREE.Vector3() +const _camSize = new THREE.Vector3() +const _camPoint = new THREE.Vector3() +const _walkPos = new THREE.Vector3() +const _reticleNdc = new THREE.Vector2(0, 0) +const _reticleRaycaster = new THREE.Raycaster() +/** How far ahead (metres) a door/window counts as "in view" for activation. */ +const WALK_REACH = 3 const ZONE_FOOTPRINT_EPSILON = 0.05 const NO_RAYCAST: THREE.Mesh['raycast'] = () => {} @@ -231,11 +282,13 @@ export function GlbScene({ onLevelsChange, onIdentityChange, onHoverChange, + onWalkthroughChange, }: { url: string onLevelsChange?: (levels: GlbLevel[]) => void onIdentityChange?: (identity: GlbIdentity) => void onHoverChange?: (hover: GlbHover) => void + onWalkthroughChange?: (state: GlbWalkthrough) => void }) { const gltf = useGLTFKTX2(url) as unknown as { scene: THREE.Group @@ -245,21 +298,57 @@ export function GlbScene({ const { actions } = useAnimations(gltf.animations, rootRef) const camera = useThree((state) => state.camera) const raycaster = useThree((state) => state.raycaster) + const controls = useThree((state) => state.controls) as LookAtControls | null + const walkthroughMode = useViewer((s) => s.walkthroughMode) + const textures = useViewer((s) => s.textures) + const sceneTheme = useViewer((s) => s.sceneTheme) + + // Monochrome: strip the baked textures and recolor every building mesh with a + // flat themed-clay material by surface role — mirrors the parametric viewer's + // textures-off path. The original baked material is stashed on the mesh + // (`userData.__bakedMaterial`) so it survives the cached GLTF across remounts. + useEffect(() => { + gltf.scene.traverse((object) => { + const role = ROLE_BY_KIND[(object.userData as PascalExtras).kind ?? ''] + if (!role) return + object.traverse((child) => { + const mesh = child as THREE.Mesh + if (!mesh.isMesh || mesh.layers.isEnabled(ZONE_LAYER)) return + const ud = mesh.userData as { __bakedMaterial?: THREE.Material | THREE.Material[] } + if (!ud.__bakedMaterial) ud.__bakedMaterial = mesh.material + mesh.material = textures + ? ud.__bakedMaterial + : createSurfaceRoleMaterial(role, 'clay', THREE.DoubleSide, sceneTheme) + }) + }) + }, [gltf.scene, textures, sceneTheme]) // One pass over the artifact: identity objects (id → Object3D), ordered floors, // and zone polygons. Levels stay out of `sceneRegistry` so the parametric // LevelSystem never re-stacks them. - const { levels, identity, zoneEntries, occluders } = useMemo(() => { + const { levels, identity, zoneEntries, occluders, rootNode, levelsWithZones } = useMemo(() => { const objects = new Map() const floors: GlbLevelEntry[] = [] const zoneList: GlbZoneEntry[] = [] // Ceilings + roof are hidden when a floor is focused (dollhouse view) so the // camera sees the rooms and the pointer ray reaches their contents. const occluderNodes: THREE.Object3D[] = [] + // The building (or site) node anchors the building-view camera bookmark/fit. + let buildingNode: THREE.Object3D | null = null + let siteNode: THREE.Object3D | null = null gltf.scene.traverse((object) => { const extras = object.userData as PascalExtras + // The spawn marker is an authoring-only node (walkthrough start pose); it + // should never render in the viewer. Its transform still feeds the + // walkthrough controller — visibility doesn't affect that. + if (extras.kind === 'spawn') { + object.visible = false + return + } if (!extras.pascalId) return objects.set(extras.pascalId, object) + if (extras.kind === 'building') buildingNode = object + else if (extras.kind === 'site') siteNode = object if (extras.kind === 'ceiling' || extras.kind === 'roof') occluderNodes.push(object) if (extras.kind === 'level') { floors.push({ @@ -286,10 +375,106 @@ export function GlbScene({ } }) floors.sort((a, b) => a.baseY - b.baseY) - return { levels: floors, identity: objects, zoneEntries: zoneList, occluders: occluderNodes } + return { + levels: floors, + identity: objects, + zoneEntries: zoneList, + occluders: occluderNodes, + rootNode: (buildingNode ?? siteNode) as THREE.Object3D | null, + // Levels that have rooms — only these trigger the dollhouse occluder strip. + levelsWithZones: new Set(zoneList.map((zone) => zone.levelId)), + } }, [gltf.scene]) const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries]) + // Move the camera to match the drill depth: a saved bookmark (extras.camera) + // wins; otherwise fit to the target's bounds (the object, the room's polygon + // footprint for empty zone nodes, the level, or the whole building). Mirrors + // the parametric viewer's selection framing so the GLB path feels identical. + const focusLevelId = useViewer((s) => s.selection.levelId) + const focusZoneId = useViewer((s) => s.selection.zoneId) + const focusSelectedId = useViewer((s) => s.selection.selectedIds[0] ?? null) + useEffect(() => { + if (!controls) return + const flyToBookmark = (bookmark: NonNullable) => { + const { position: p, target: t } = bookmark + controls.setLookAt(p[0], p[1], p[2], t[0], t[1], t[2], true) + controls.normalizeRotations?.() + } + + // Item selection happens inside a room, where we're already at a good angle: + // fly to the item's own bookmark if it has one, otherwise just pan to it + // (keep the current orbit angle + distance) rather than reframing the camera. + if (focusSelectedId) { + const object = identity.get(focusSelectedId) + if (!object) return + const itemBookmark = (object.userData as PascalExtras).camera + if (itemBookmark) { + flyToBookmark(itemBookmark) + return + } + _camBox.makeEmpty() + _camBox.setFromObject(object) + if (_camBox.isEmpty()) return + _camBox.getCenter(_camCenter) + controls.moveTo?.(_camCenter.x, _camCenter.y, _camCenter.z, true) + return + } + + let bookmarkNode: THREE.Object3D | null = null + _camBox.makeEmpty() + if (focusZoneId) { + const zone = zoneById.get(focusZoneId) + if (!zone) return + bookmarkNode = zone.node + // Zone identity nodes carry no mesh — bound the room from its polygon. + zone.node.updateWorldMatrix(true, false) + for (const [x, z] of zone.polygon) { + _camBox.expandByPoint(_camPoint.set(x, 0, z).applyMatrix4(zone.node.matrixWorld)) + _camBox.expandByPoint( + _camPoint.set(x, ZONE_WALL_HEIGHT, z).applyMatrix4(zone.node.matrixWorld), + ) + } + } else if (focusLevelId) { + const object = identity.get(focusLevelId) + if (!object) return + bookmarkNode = object + _camBox.setFromObject(object) + } else { + bookmarkNode = rootNode + _camBox.setFromObject(gltf.scene) + } + + const bookmark = (bookmarkNode?.userData as PascalExtras | undefined)?.camera + if (bookmark) { + flyToBookmark(bookmark) + return + } + if (_camBox.isEmpty()) return + _camBox.getCenter(_camCenter) + _camBox.getSize(_camSize) + const distance = Math.max(Math.max(_camSize.x, _camSize.y, _camSize.z) * 2, 15) + controls.setLookAt( + _camCenter.x + distance * 0.7, + _camCenter.y + distance * 0.5, + _camCenter.z + distance * 0.7, + _camCenter.x, + _camCenter.y, + _camCenter.z, + true, + ) + controls.normalizeRotations?.() + }, [ + controls, + focusSelectedId, + focusZoneId, + focusLevelId, + identity, + zoneById, + rootNode, + gltf.scene, + ]) + useEffect(() => { const cameraMask = camera.layers.mask const raycasterMask = raycaster.layers.mask @@ -317,15 +502,22 @@ export function GlbScene({ } }, [levels, identity, onLevelsChange, onIdentityChange]) - // Apply the editor's level modes to the baked floors each frame. + // Apply the editor's level modes to the baked floors each frame. Walkthrough + // always shows the full stacked building (you're standing inside it) — and the + // first-person collider is built from the visible meshes, so a hidden solo + // floor would otherwise drop the player through the world. useFrame((_, delta) => { if (levels.length === 0) return - const { levelMode, selection } = useViewer.getState() + const { levelMode, selection, walkthroughMode } = useViewer.getState() const selectedLevel = selection.levelId levels.forEach(({ id, node, baseY }, index) => { - const targetY = baseY + (levelMode === 'exploded' ? index * EXPLODED_GAP : 0) - node.position.y = lerp(node.position.y, targetY, delta * 12) - node.visible = levelMode !== 'solo' || !selectedLevel || id === selectedLevel + const exploded = !walkthroughMode && levelMode === 'exploded' + const targetY = baseY + (exploded ? index * EXPLODED_GAP : 0) + // Snap (not lerp) in walkthrough so the first-person collider, built from + // these world positions, matches the stacked building immediately. + node.position.y = walkthroughMode ? targetY : lerp(node.position.y, targetY, delta * 12) + node.visible = + walkthroughMode || levelMode !== 'solo' || !selectedLevel || id === selectedLevel }) }, 5) @@ -520,17 +712,25 @@ export function GlbScene({ // selection + local hover. const hoveredTarget = useRef(null) useFrame((_, delta) => { - const { selection, outliner } = useViewer.getState() + const state = useViewer.getState() + const { selection, outliner } = state const t = Math.min(1, delta * 8) const hoveredZoneId = hoveredTarget.current?.kind === 'zone' ? hoveredTarget.current.id : null - // Dollhouse: once a floor is focused, hide ceilings + roof so the rooms (and - // their zone tint) are visible from above and the ray reaches their contents. - const focused = selection.levelId != null - for (const occluder of occluders) occluder.visible = !focused + // Walkthrough is a first-person tour: no zone tints, no dollhouse cutaway, + // no selection outline — you're standing inside the real building. + const walk = state.walkthroughMode + + // Dollhouse: hide ceilings + roof so the rooms (and their zone tint) are + // visible from above and the ray reaches their contents — but only when the + // focused level actually has rooms. Focusing a zone-less floor keeps the + // building intact (otherwise its roof would just vanish with nothing to show). + const revealing = !walk && selection.levelId != null && levelsWithZones.has(selection.levelId) + for (const occluder of occluders) occluder.visible = !revealing for (const { id, levelId, meshes, uniforms } of zoneFills.current) { - const show = selection.levelId != null && levelId === selection.levelId && !selection.zoneId + const show = + !walk && selection.levelId != null && levelId === selection.levelId && !selection.zoneId const target = !show ? 0 : id === hoveredZoneId ? 1 : 0.65 let visible = false for (const u of uniforms) { @@ -554,23 +754,96 @@ export function GlbScene({ } } + outliner.selectedObjects.length = 0 + outliner.hoveredObjects.length = 0 + if (walk) return + const selectedObject = selection.selectedIds[0] ? (identity.get(selection.selectedIds[0]) ?? null) : null - outliner.selectedObjects.length = 0 if (selectedObject) outliner.selectedObjects.push(selectedObject) // Rooms show hover via the fill brightness; everything else uses the outline. - outliner.hoveredObjects.length = 0 const hover = hoveredTarget.current if (hover && hover.kind !== 'zone' && hover.object !== selectedObject) { outliner.hoveredObjects.push(hover.object) } }) + // ── Walkthrough: first-person HUD + door/window interaction ──────────────── + const walkDoorRef = useRef(null) + const lastWalkKey = useRef(null) + + // Each frame in walkthrough, report the floor + room the camera stands in and + // the openable directly ahead (a forward ray from screen centre) so the host + // can draw the reticle prompt. Fires the callback only when the state changes. + useFrame(() => { + if (!walkthroughMode) return + camera.getWorldPosition(_walkPos) + + let floor: GlbLevelEntry | null = levels[0] ?? null + for (const level of levels) { + if (_walkPos.y >= level.baseY - 0.5) floor = level + else break + } + const floorLabel = floor ? ((floor.node.userData as PascalExtras).label ?? floor.id) : null + const zone = floor ? zoneAtPoint(_walkPos, floor.id) : null + + _reticleRaycaster.far = WALK_REACH + _reticleRaycaster.setFromCamera(_reticleNdc, camera) + const hit = _reticleRaycaster.intersectObject(gltf.scene, true)[0] + let doorNode: THREE.Object3D | null = null + let doorId = '' + let door: { label: string; isOpen: boolean } | null = null + if (hit) { + const node = findIdentityAncestor(hit.object) + const extras = node?.userData as PascalExtras | undefined + if (node && extras?.openable && extras.clips?.length) { + doorNode = node + doorId = extras.pascalId as string + door = { label: extras.label ?? 'Door', isOpen: openIds.current.has(doorId) } + } + } + walkDoorRef.current = doorNode + + const key = `${floor?.id ?? ''}|${zone?.id ?? ''}|${door ? `${doorId}:${door.isOpen}` : ''}` + if (key !== lastWalkKey.current) { + lastWalkKey.current = key + onWalkthroughChange?.({ zoneLabel: zone?.label ?? null, floorLabel, door }) + } + }) + + // E or click activates the openable in view. The click also re-locks the + // pointer via WalkthroughControls — harmless overlap; no selection happens. + const activateWalkDoor = useCallback(() => { + if (walkDoorRef.current) toggleOpenable(walkDoorRef.current) + }, [toggleOpenable]) + useEffect(() => { + if (!walkthroughMode) return + const onKey = (event: KeyboardEvent) => { + if (event.key.toLowerCase() === 'e') activateWalkDoor() + } + const canvas = document.querySelector('canvas') + window.addEventListener('keydown', onKey) + canvas?.addEventListener('click', activateWalkDoor) + return () => { + window.removeEventListener('keydown', onKey) + canvas?.removeEventListener('click', activateWalkDoor) + } + }, [walkthroughMode, activateWalkDoor]) + + // Clear the HUD (and stale targeting) whenever walkthrough turns off. + useEffect(() => { + if (walkthroughMode) return + walkDoorRef.current = null + lastWalkKey.current = null + onWalkthroughChange?.(null) + }, [walkthroughMode, onWalkthroughChange]) + const lastHover = useRef(null) const handlePointerMove = useCallback( (event: ThreeEvent) => { event.stopPropagation() + if (walkthroughMode) return const target = resolveTarget( event.intersections.map((hit) => ({ object: hit.object, point: hit.point })), event.ray, @@ -583,7 +856,7 @@ export function GlbScene({ onHoverChange?.(target ? { kind: target.kind, label: target.label } : null) } }, - [resolveTarget, onHoverChange], + [resolveTarget, onHoverChange, walkthroughMode], ) const handlePointerOut = useCallback(() => { @@ -601,6 +874,9 @@ export function GlbScene({ const handleClick = useCallback( (event: ThreeEvent) => { event.stopPropagation() + // Walkthrough handles its own door activation (E / canvas click) and never + // selects — leave the drill hierarchy untouched. + if (walkthroughMode) return const target = resolveTarget( event.intersections.map((hit) => ({ object: hit.object, point: hit.point })), event.ray, @@ -637,12 +913,13 @@ export function GlbScene({ setSelection({ zoneId: null }) } }, - [resolveTarget, toggleOpenable], + [resolveTarget, toggleOpenable, walkthroughMode], ) // A click that hits nothing (empty space) steps one level back up the drill // hierarchy, like the legacy viewer. const handlePointerMissed = useCallback(() => { + if (useViewer.getState().walkthroughMode) return const { selection, setSelection, setLevelMode } = useViewer.getState() if (selection.selectedIds.length > 0) { setSelection({ selectedIds: [] }) diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx new file mode 100644 index 00000000..f235f773 --- /dev/null +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -0,0 +1,404 @@ +'use client' + +import { KeyboardControls } from '@react-three/drei' +import { useFrame, useThree } from '@react-three/fiber' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + Box3, + BoxGeometry, + type BufferAttribute, + BufferGeometry, + Euler, + Float32BufferAttribute, + type InterleavedBufferAttribute, + Matrix4, + Mesh, + MeshBasicMaterial, + type Object3D, + Quaternion, + Vector3, +} from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' +import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' +import { SCENE_LAYER } from '../../lib/layers' +import useViewer from '../../store/use-viewer' +import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl' + +// Eye/capsule geometry mirrors the editor's first-person controller so the +// baked walkthrough feels identical. The capsule centre sits below the eye; the +// camera rides the capsule with a small offset and the controller floats it to +// the ground. +const CAMERA_EYE_OFFSET = 0.45 +const CONTROLLER_CENTER_FROM_EYE = 0.85 +const SPAWN_EYE_HEIGHT = 1.65 +const LOOK_SENSITIVITY = 0.002 +const VOID_FALL_RESPAWN_DEPTH = 12 + +// Kinds that must not block the player: room helpers, the spawn marker, the +// ceiling/roof shell (you walk under them), and door/window leaves — excluding +// the latter lets you pass any doorway whether the leaf is open or shut (the +// wall already has the opening cut into its baked geometry). +const COLLIDER_EXCLUDED_KINDS = new Set(['zone', 'spawn', 'ceiling', 'roof', 'door', 'window']) + +const colliderMaterial = new MeshBasicMaterial({ visible: false }) + +const keyboardMap: Array<{ name: Exclude; keys: string[] }> = [ + { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, + { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, + { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] }, + { name: 'rightward', keys: ['ArrowRight', 'KeyD'] }, + { name: 'jump', keys: ['Space'] }, + { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] }, +] + +const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) +const cameraEuler = new Euler(0, 0, 0, 'YXZ') +const spawnQuat = new Quaternion() +const spawnEuler = new Euler(0, 0, 0, 'YXZ') +const spawnPos = new Vector3() + +type GlbColliderWorld = { mesh: Mesh; minY: number; dispose: () => void } + +/** Effective visibility — an invisible ancestor hides the whole subtree. */ +function isEffectivelyVisible(object: Object3D) { + let current: Object3D | null = object + while (current) { + if (!current.visible) return false + current = current.parent + } + return true +} + +function kindOf(object: Object3D): string | undefined { + let current: Object3D | null = object + while (current) { + const kind = (current.userData as { kind?: string }).kind + if (kind) return kind + current = current.parent + } + return undefined +} + +// Coerce any position attribute (quantized/interleaved) to a plain Float32 one +// so mergeGeometries can combine geometries that don't share an array type. +function toFloat32Position(source: BufferAttribute | InterleavedBufferAttribute) { + const array = new Float32Array(source.count * 3) + for (let i = 0; i < source.count; i++) { + array[i * 3] = source.getX(i) + array[i * 3 + 1] = source.getY(i) + array[i * 3 + 2] = source.getZ(i) + } + return new Float32BufferAttribute(array, 3) +} + +const FALLBACK_THICKNESS = 0.08 +const GROUND_MIN = 2000 + +/** A thin position-only floor box whose top face sits at `topY`. */ +function boxFloorGeometry( + cx: number, + topY: number, + cz: number, + width: number, + depth: number, +): BufferGeometry { + const box = new BoxGeometry(width, FALLBACK_THICKNESS, depth).toNonIndexed() + const geometry = new BufferGeometry() + geometry.setAttribute('position', (box.getAttribute('position') as BufferAttribute).clone()) + box.dispose() + geometry.applyMatrix4(new Matrix4().makeTranslation(cx, topY - FALLBACK_THICKNESS / 2, cz)) + return geometry +} + +// Fallback ground so the player never falls into the void: a single large +// ground plane at the lowest level (level 0). Upper levels rely on their own +// baked slabs — a slab-less upper floor lets you fall down to the ground, which +// the ground plane catches. (Mirrors the editor walkthrough's site ground.) +function addFallbackFloors(scene: Object3D, geometries: BufferGeometry[]) { + const sceneBounds = new Box3() + for (const geometry of geometries) { + geometry.computeBoundingBox() + if (geometry.boundingBox) sceneBounds.union(geometry.boundingBox) + } + + const center = new Vector3() + const levelPos = new Vector3() + let lowestLevelY = Number.POSITIVE_INFINITY + + scene.traverse((object) => { + if ((object.userData as { kind?: string }).kind !== 'level') return + object.updateWorldMatrix(true, false) + object.getWorldPosition(levelPos) + lowestLevelY = Math.min(lowestLevelY, levelPos.y) + }) + + if (sceneBounds.isEmpty()) return + sceneBounds.getCenter(center) + const groundY = Number.isFinite(lowestLevelY) ? lowestLevelY : sceneBounds.min.y + geometries.push(boxFloorGeometry(center.x, groundY, center.z, GROUND_MIN, GROUND_MIN)) +} + +/** Merge the baked GLB's walkable/blocking meshes into one BVH collider. */ +function buildGlbColliderWorld(scene: Object3D): GlbColliderWorld | null { + scene.updateWorldMatrix(true, true) + const geometries: BufferGeometry[] = [] + + scene.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + // Zone fills live on a separate layer (and never collide). + if (!mesh.layers.isEnabled(SCENE_LAYER)) return + if (!isEffectivelyVisible(mesh)) return + const kind = kindOf(mesh) + if (kind && COLLIDER_EXCLUDED_KINDS.has(kind)) return + const position = mesh.geometry?.getAttribute('position') + if (!position || position.count < 3) return + + const geometry = new BufferGeometry() + const source = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry + geometry.setAttribute('position', toFloat32Position(source.getAttribute('position'))) + if (mesh.geometry.index) source.dispose() + geometry.applyMatrix4(mesh.matrixWorld) + geometries.push(geometry) + }) + + if (geometries.length === 0) return null + + addFallbackFloors(scene, geometries) + + const merged = mergeGeometries(geometries, false) + for (const geometry of geometries) geometry.dispose() + if (!merged || merged.getAttribute('position') == null) { + merged?.dispose() + return null + } + // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype + ;(merged as any).computeBoundsTree = computeBoundsTree + // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype + ;(merged as any).disposeBoundsTree = disposeBoundsTree + // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension + ;(merged as any).computeBoundsTree({ maxLeafSize: 12, strategy: 0 }) + merged.computeBoundingBox() + + const mesh = new Mesh(merged, colliderMaterial) + mesh.raycast = acceleratedRaycast + mesh.visible = true + mesh.userData = { + type: 'STATIC', + friction: 0.8, + restitution: 0.05, + excludeFloatHit: false, + excludeCollisionCheck: false, + } + mesh.updateMatrixWorld(true) + + return { + mesh, + minY: merged.boundingBox?.min.y ?? 0, + dispose: () => { + // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension + ;(merged as any).disposeBoundsTree?.() + merged.dispose() + }, + } +} + +/** The baked spawn marker's eye position + yaw, if the artifact carries one. */ +function resolveGlbSpawn( + scene: Object3D, +): { position: [number, number, number]; yaw: number } | null { + let spawn: Object3D | null = null + scene.traverse((object) => { + if ((object.userData as { kind?: string }).kind === 'spawn') spawn = object + }) + if (!spawn) return null + const node = spawn as Object3D + node.updateWorldMatrix(true, false) + node.getWorldPosition(spawnPos) + node.getWorldQuaternion(spawnQuat) + spawnEuler.setFromQuaternion(spawnQuat, 'YXZ') + return { + position: [spawnPos.x, spawnPos.y + SPAWN_EYE_HEIGHT, spawnPos.z], + yaw: spawnEuler.y, + } +} + +/** + * First-person walkthrough controller for the baked GLB. Reuses the editor's + * `BVHEcctrl` capsule character controller (gravity, jump, sprint, ground-float, + * mesh collision) fed a collider built from the artifact's own geometry — so the + * baked viewer walks the building with the same physics as the editor, without + * the parametric scene. Pointer-lock drives look; WASD moves; Space jumps; Shift + * sprints. Door/window interaction stays in `GlbScene` (its centre-ray HUD). + */ +export function GlbWalkthroughController({ url }: { url: string }) { + const { camera, gl } = useThree() + const gltf = useGLTFKTX2(url) as unknown as { scene: Object3D } + + const worldRef = useRef(null) + const controllerRef = useRef(null) + const yawRef = useRef(0) + const pitchRef = useRef(0) + const [start, setStart] = useState<{ position: [number, number, number] } | null>(null) + const [world, setWorld] = useState(null) + + // Build the collider on the first frame (priority after GlbScene's level loop, + // which snaps the floors to their stacked world positions in walkthrough) so it + // matches the rendered building rather than a mid-lerp / exploded layout. + const builtRef = useRef(false) + useFrame(() => { + if (builtRef.current) return + builtRef.current = true + setWorld(buildGlbColliderWorld(gltf.scene)) + }, 6) + + // First-person needs a perspective camera — an orthographic projection has no + // foreshortening and makes the walkthrough unusable. Force perspective while + // walking and restore the prior projection on exit. + useEffect(() => { + const prevMode = useViewer.getState().cameraMode + if (prevMode === 'orthographic') useViewer.getState().setCameraMode('perspective') + return () => { + if (prevMode === 'orthographic') useViewer.getState().setCameraMode('orthographic') + } + }, []) + + useEffect(() => { + worldRef.current = world + if (world) { + const triangles = world.mesh.geometry.getAttribute('position').count / 3 + console.warn('[glb-walkthrough] collider built', { + triangles, + minY: world.minY, + hasBoundsTree: !!(world.mesh.geometry as { boundsTree?: unknown }).boundsTree, + spawn: resolveGlbSpawn(gltf.scene), + }) + } else { + console.warn('[glb-walkthrough] NO collider world (no eligible meshes)') + } + return () => { + world?.dispose() + worldRef.current = null + } + }, [world, gltf.scene]) + + // Resolve the spawn once the collider exists; the capsule centre sits below + // the eye, then the controller floats it onto the ground. + useEffect(() => { + if (!world || start) return + const spawn = resolveGlbSpawn(gltf.scene) + const eye = spawn?.position ?? [0, SPAWN_EYE_HEIGHT, 0] + yawRef.current = spawn?.yaw ?? 0 + pitchRef.current = 0 + setStart({ position: [eye[0], eye[1] - CONTROLLER_CENTER_FROM_EYE, eye[2]] }) + }, [world, start, gltf.scene]) + + // Pointer-lock look + click-to-lock fallback + Esc/unlock to exit. Once the + // pointer has been locked, releasing it (Esc — the browser swallows that + // keydown, so we can't rely on it; or any other unlock) leaves the walkthrough + // in a single press rather than just freeing the cursor. + useEffect(() => { + const canvas = gl.domElement + let wasLocked = false + const onMouseMove = (event: MouseEvent) => { + if (document.pointerLockElement !== canvas) return + yawRef.current -= event.movementX * LOOK_SENSITIVITY + pitchRef.current = Math.max( + -(Math.PI / 2 - 0.05), + Math.min(Math.PI / 2 - 0.05, pitchRef.current - event.movementY * LOOK_SENSITIVITY), + ) + } + const onClick = () => { + if (document.pointerLockElement !== canvas) canvas.requestPointerLock?.() + } + const onKeyDown = (event: KeyboardEvent) => { + // When locked, the browser intercepts Esc to release the pointer and the + // pointerlockchange handler below exits; this only covers Esc while the + // pointer is already free (e.g. lock never engaged). + if (event.code === 'Escape' && document.pointerLockElement !== canvas) { + useViewer.getState().setWalkthroughMode(false) + } + } + const onPointerLockChange = () => { + if (document.pointerLockElement === canvas) wasLocked = true + else if (wasLocked) useViewer.getState().setWalkthroughMode(false) + } + document.addEventListener('mousemove', onMouseMove) + canvas.addEventListener('click', onClick) + document.addEventListener('keydown', onKeyDown) + document.addEventListener('pointerlockchange', onPointerLockChange) + return () => { + document.removeEventListener('mousemove', onMouseMove) + canvas.removeEventListener('click', onClick) + document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('pointerlockchange', onPointerLockChange) + if (document.pointerLockElement === canvas) document.exitPointerLock() + } + }, [gl]) + + // Lock the pointer the moment the walkthrough is ready, so the user doesn't + // have to click the canvas first. The walkthrough toggle is itself a user + // gesture; if the browser still rejects the request (no transient activation + // left), the click-to-lock fallback above covers it. + useEffect(() => { + if (!(world && start)) return + const canvas = gl.domElement + if (document.pointerLockElement === canvas) return + const result = canvas.requestPointerLock?.() as Promise | undefined + if (result && typeof result.catch === 'function') result.catch(() => {}) + }, [gl, world, start]) + + const setControllerApi = useCallback((api: BVHEcctrlApi | null) => { + controllerRef.current = api + }, []) + + // Drive the camera from the capsule each frame + respawn if it falls into void. + useFrame(() => { + const group = controllerRef.current?.group + if (!group) return + + if (start && world && group.position.y < world.minY - VOID_FALL_RESPAWN_DEPTH) { + group.position.set(start.position[0], start.position[1], start.position[2]) + controllerRef.current?.resetLinVel() + } + + group.rotation.y = 0 + camera.position.copy(group.position).add(cameraOffset) + cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') + camera.quaternion.setFromEuler(cameraEuler) + camera.updateMatrixWorld(true) + }, 2.5) + + if (!(world && start)) return null + + return ( + + + + ) +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index f9b1eebf..4af1f2eb 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -13,12 +13,19 @@ export { ErrorBoundary } from './components/error-boundary' // — no per-kind re-exports needed. export { NodeRenderer } from './components/renderers/node-renderer' export { default as Viewer, type ViewerHandle } from './components/viewer' +export { + type BVHEcctrlApi, + default as BVHEcctrl, + type MovementInput, +} from './components/viewer/bvh-ecctrl' export { type GlbHover, type GlbIdentity, type GlbLevel, GlbScene, + type GlbWalkthrough, } from './components/viewer/glb-scene' +export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-controller' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { DEFAULT_HOVER_STYLES,