feat(viewer): GLB walkthrough in viewer, monochrome, spawn/export polish

Move the first-person walkthrough into @pascal-app/viewer (BVHEcctrl +
GlbWalkthroughController) and round out the GLB-consuming viewer:

- Walkthrough: fallback ground only on level 0 (upper floors rely on baked
  slabs), hidden spawn marker, auto pointer-lock on enter, single-Esc exit via
  pointerlockchange, force perspective on enter / restore on exit.
- GlbScene: monochrome strips baked textures and recolours meshes by surface
  role using the active theme's clay tints; spawn node hidden from render.
- glb-export: camera/label/spawn identity extras for the viewer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-24 15:50:28 -04:00
co-authored by Claude Opus 4.8
parent 6cfc6ae01b
commit 5aedc366a6
6 changed files with 755 additions and 21 deletions
@@ -45,6 +45,7 @@ import {
} from 'three' } from 'three'
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
import '../../three-types' import '../../three-types'
import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
import { import {
closeDoorOpenState, closeDoorOpenState,
DOOR_SWING_OPEN_ANGLE, DOOR_SWING_OPEN_ANGLE,
@@ -64,8 +65,6 @@ import {
type FirstPersonColliderWorld, type FirstPersonColliderWorld,
type FirstPersonSpawn, type FirstPersonSpawn,
} from './first-person/build-collider-world' } 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 CAMERA_EYE_OFFSET = 0.45
const LOOK_SENSITIVITY = 0.002 const LOOK_SENSITIVITY = 0.002
+47 -1
View File
@@ -470,6 +470,39 @@ function bakeWindowClip(
* (e.g. `pascalSwingLeaf`, cached-material flags) leaks into glTF extras — the * (e.g. `pascalSwingLeaf`, cached-material flags) leaks into glTF extras — the
* file describes itself with exactly the fields a consumer needs. * 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( function stampIdentity(
scene: THREE.Object3D, scene: THREE.Object3D,
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>, cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
@@ -487,7 +520,12 @@ function stampIdentity(
target.name = id target.name = id
const extras: Record<string, unknown> = { pascalId: id, kind: node.type } const extras: Record<string, unknown> = { 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") // 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 // 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 // node visible: the bake must capture every floor regardless of the editor's
@@ -516,6 +554,14 @@ function stampIdentity(
extras.color = zone.color extras.color = zone.color
target.visible = true 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 target.userData = extras
} }
} }
@@ -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 { TransformControls, useKeyboardControls } from '@react-three/drei'
import { type ThreeElements, useFrame, useThree } from '@react-three/fiber' import { type ThreeElements, useFrame, useThree } from '@react-three/fiber'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
@@ -1,5 +1,6 @@
'use client' 'use client'
import type { SurfaceRole } from '@pascal-app/core'
import { Html, useAnimations } from '@react-three/drei' import { Html, useAnimations } from '@react-three/drei'
import { type ThreeEvent, useFrame, useThree } from '@react-three/fiber' import { type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react' 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 { MeshBasicNodeMaterial } from 'three/webgpu'
import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2'
import { ZONE_LAYER } from '../../lib/layers' import { ZONE_LAYER } from '../../lib/layers'
import { createSurfaceRoleMaterial } from '../../lib/materials'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
/** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */ /** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
const EXPLODED_GAP = 5 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<string, SurfaceRole> = {
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. */ /** A building floor discovered in the baked GLB, ordered bottom-to-top. */
export type GlbLevel = { id: `level_${string}`; label: string } export type GlbLevel = { id: `level_${string}`; label: string }
@@ -23,6 +39,14 @@ export type GlbIdentity = Record<string, { kind: string; label: string }>
/** What the cursor would act on at the current drill depth (for a hover label). */ /** What the cursor would act on at the current drill depth (for a hover label). */
export type GlbHover = { kind: string; label: string } | null 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 GlbLevelEntry = { id: GlbLevel['id']; node: THREE.Object3D; baseY: number }
type GlbZoneEntry = { type GlbZoneEntry = {
id: string id: string
@@ -43,6 +67,24 @@ type PascalExtras = {
clips?: string[] clips?: string[]
polygon?: [number, number][] polygon?: [number, number][]
color?: string 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. */ /** 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 _bounds = new THREE.Box3()
const _boundsCenter = new THREE.Vector3() const _boundsCenter = new THREE.Vector3()
const _sample = 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 ZONE_FOOTPRINT_EPSILON = 0.05
const NO_RAYCAST: THREE.Mesh['raycast'] = () => {} const NO_RAYCAST: THREE.Mesh['raycast'] = () => {}
@@ -231,11 +282,13 @@ export function GlbScene({
onLevelsChange, onLevelsChange,
onIdentityChange, onIdentityChange,
onHoverChange, onHoverChange,
onWalkthroughChange,
}: { }: {
url: string url: string
onLevelsChange?: (levels: GlbLevel[]) => void onLevelsChange?: (levels: GlbLevel[]) => void
onIdentityChange?: (identity: GlbIdentity) => void onIdentityChange?: (identity: GlbIdentity) => void
onHoverChange?: (hover: GlbHover) => void onHoverChange?: (hover: GlbHover) => void
onWalkthroughChange?: (state: GlbWalkthrough) => void
}) { }) {
const gltf = useGLTFKTX2(url) as unknown as { const gltf = useGLTFKTX2(url) as unknown as {
scene: THREE.Group scene: THREE.Group
@@ -245,21 +298,57 @@ export function GlbScene({
const { actions } = useAnimations(gltf.animations, rootRef) const { actions } = useAnimations(gltf.animations, rootRef)
const camera = useThree((state) => state.camera) const camera = useThree((state) => state.camera)
const raycaster = useThree((state) => state.raycaster) 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, // One pass over the artifact: identity objects (id → Object3D), ordered floors,
// and zone polygons. Levels stay out of `sceneRegistry` so the parametric // and zone polygons. Levels stay out of `sceneRegistry` so the parametric
// LevelSystem never re-stacks them. // LevelSystem never re-stacks them.
const { levels, identity, zoneEntries, occluders } = useMemo(() => { const { levels, identity, zoneEntries, occluders, rootNode, levelsWithZones } = useMemo(() => {
const objects = new Map<string, THREE.Object3D>() const objects = new Map<string, THREE.Object3D>()
const floors: GlbLevelEntry[] = [] const floors: GlbLevelEntry[] = []
const zoneList: GlbZoneEntry[] = [] const zoneList: GlbZoneEntry[] = []
// Ceilings + roof are hidden when a floor is focused (dollhouse view) so the // Ceilings + roof are hidden when a floor is focused (dollhouse view) so the
// camera sees the rooms and the pointer ray reaches their contents. // camera sees the rooms and the pointer ray reaches their contents.
const occluderNodes: THREE.Object3D[] = [] 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) => { gltf.scene.traverse((object) => {
const extras = object.userData as PascalExtras 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 if (!extras.pascalId) return
objects.set(extras.pascalId, object) 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 === 'ceiling' || extras.kind === 'roof') occluderNodes.push(object)
if (extras.kind === 'level') { if (extras.kind === 'level') {
floors.push({ floors.push({
@@ -286,10 +375,106 @@ export function GlbScene({
} }
}) })
floors.sort((a, b) => a.baseY - b.baseY) 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]) }, [gltf.scene])
const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries]) 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<PascalExtras['camera']>) => {
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(() => { useEffect(() => {
const cameraMask = camera.layers.mask const cameraMask = camera.layers.mask
const raycasterMask = raycaster.layers.mask const raycasterMask = raycaster.layers.mask
@@ -317,15 +502,22 @@ export function GlbScene({
} }
}, [levels, identity, onLevelsChange, onIdentityChange]) }, [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) => { useFrame((_, delta) => {
if (levels.length === 0) return if (levels.length === 0) return
const { levelMode, selection } = useViewer.getState() const { levelMode, selection, walkthroughMode } = useViewer.getState()
const selectedLevel = selection.levelId const selectedLevel = selection.levelId
levels.forEach(({ id, node, baseY }, index) => { levels.forEach(({ id, node, baseY }, index) => {
const targetY = baseY + (levelMode === 'exploded' ? index * EXPLODED_GAP : 0) const exploded = !walkthroughMode && levelMode === 'exploded'
node.position.y = lerp(node.position.y, targetY, delta * 12) const targetY = baseY + (exploded ? index * EXPLODED_GAP : 0)
node.visible = levelMode !== 'solo' || !selectedLevel || id === selectedLevel // 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) }, 5)
@@ -520,17 +712,25 @@ export function GlbScene({
// selection + local hover. // selection + local hover.
const hoveredTarget = useRef<Target | null>(null) const hoveredTarget = useRef<Target | null>(null)
useFrame((_, delta) => { useFrame((_, delta) => {
const { selection, outliner } = useViewer.getState() const state = useViewer.getState()
const { selection, outliner } = state
const t = Math.min(1, delta * 8) const t = Math.min(1, delta * 8)
const hoveredZoneId = hoveredTarget.current?.kind === 'zone' ? hoveredTarget.current.id : null const hoveredZoneId = hoveredTarget.current?.kind === 'zone' ? hoveredTarget.current.id : null
// Dollhouse: once a floor is focused, hide ceilings + roof so the rooms (and // Walkthrough is a first-person tour: no zone tints, no dollhouse cutaway,
// their zone tint) are visible from above and the ray reaches their contents. // no selection outline — you're standing inside the real building.
const focused = selection.levelId != null const walk = state.walkthroughMode
for (const occluder of occluders) occluder.visible = !focused
// 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) { 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 const target = !show ? 0 : id === hoveredZoneId ? 1 : 0.65
let visible = false let visible = false
for (const u of uniforms) { 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] const selectedObject = selection.selectedIds[0]
? (identity.get(selection.selectedIds[0]) ?? null) ? (identity.get(selection.selectedIds[0]) ?? null)
: null : null
outliner.selectedObjects.length = 0
if (selectedObject) outliner.selectedObjects.push(selectedObject) if (selectedObject) outliner.selectedObjects.push(selectedObject)
// Rooms show hover via the fill brightness; everything else uses the outline. // Rooms show hover via the fill brightness; everything else uses the outline.
outliner.hoveredObjects.length = 0
const hover = hoveredTarget.current const hover = hoveredTarget.current
if (hover && hover.kind !== 'zone' && hover.object !== selectedObject) { if (hover && hover.kind !== 'zone' && hover.object !== selectedObject) {
outliner.hoveredObjects.push(hover.object) outliner.hoveredObjects.push(hover.object)
} }
}) })
// ── Walkthrough: first-person HUD + door/window interaction ────────────────
const walkDoorRef = useRef<THREE.Object3D | null>(null)
const lastWalkKey = useRef<string | null>(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<string | null>(null) const lastHover = useRef<string | null>(null)
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(event: ThreeEvent<PointerEvent>) => { (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation() event.stopPropagation()
if (walkthroughMode) return
const target = resolveTarget( const target = resolveTarget(
event.intersections.map((hit) => ({ object: hit.object, point: hit.point })), event.intersections.map((hit) => ({ object: hit.object, point: hit.point })),
event.ray, event.ray,
@@ -583,7 +856,7 @@ export function GlbScene({
onHoverChange?.(target ? { kind: target.kind, label: target.label } : null) onHoverChange?.(target ? { kind: target.kind, label: target.label } : null)
} }
}, },
[resolveTarget, onHoverChange], [resolveTarget, onHoverChange, walkthroughMode],
) )
const handlePointerOut = useCallback(() => { const handlePointerOut = useCallback(() => {
@@ -601,6 +874,9 @@ export function GlbScene({
const handleClick = useCallback( const handleClick = useCallback(
(event: ThreeEvent<MouseEvent>) => { (event: ThreeEvent<MouseEvent>) => {
event.stopPropagation() 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( const target = resolveTarget(
event.intersections.map((hit) => ({ object: hit.object, point: hit.point })), event.intersections.map((hit) => ({ object: hit.object, point: hit.point })),
event.ray, event.ray,
@@ -637,12 +913,13 @@ export function GlbScene({
setSelection({ zoneId: null }) setSelection({ zoneId: null })
} }
}, },
[resolveTarget, toggleOpenable], [resolveTarget, toggleOpenable, walkthroughMode],
) )
// A click that hits nothing (empty space) steps one level back up the drill // A click that hits nothing (empty space) steps one level back up the drill
// hierarchy, like the legacy viewer. // hierarchy, like the legacy viewer.
const handlePointerMissed = useCallback(() => { const handlePointerMissed = useCallback(() => {
if (useViewer.getState().walkthroughMode) return
const { selection, setSelection, setLevelMode } = useViewer.getState() const { selection, setSelection, setLevelMode } = useViewer.getState()
if (selection.selectedIds.length > 0) { if (selection.selectedIds.length > 0) {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
@@ -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<keyof MovementInput, 'joystick'>; 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<GlbColliderWorld | null>(null)
const controllerRef = useRef<BVHEcctrlApi | null>(null)
const yawRef = useRef(0)
const pitchRef = useRef(0)
const [start, setStart] = useState<{ position: [number, number, number] } | null>(null)
const [world, setWorld] = useState<GlbColliderWorld | null>(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<void> | 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 (
<KeyboardControls map={keyboardMap}>
<BVHEcctrl
acceleration={26}
airDragFactor={0.3}
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderMeshes={[world.mesh]}
collisionCheckIteration={3}
collisionPushBackDamping={0.1}
collisionPushBackThreshold={0.001}
deceleration={30}
delay={0}
fallGravityFactor={4}
floatCheckType="BOTH"
floatDampingC={36}
floatHeight={0.5}
floatPullBackHeight={0.35}
floatSensorRadius={0.15}
floatSpringK={1200}
gravity={9.81}
jumpVel={6}
maxRunSpeed={5.5}
maxSlope={1.2}
maxWalkSpeed={4}
position={start.position}
ref={setControllerApi}
/>
</KeyboardControls>
)
}
+7
View File
@@ -13,12 +13,19 @@ export { ErrorBoundary } from './components/error-boundary'
// — no per-kind re-exports needed. // — no per-kind re-exports needed.
export { NodeRenderer } from './components/renderers/node-renderer' export { NodeRenderer } from './components/renderers/node-renderer'
export { default as Viewer, type ViewerHandle } from './components/viewer' export { default as Viewer, type ViewerHandle } from './components/viewer'
export {
type BVHEcctrlApi,
default as BVHEcctrl,
type MovementInput,
} from './components/viewer/bvh-ecctrl'
export { export {
type GlbHover, type GlbHover,
type GlbIdentity, type GlbIdentity,
type GlbLevel, type GlbLevel,
GlbScene, GlbScene,
type GlbWalkthrough,
} from './components/viewer/glb-scene' } from './components/viewer/glb-scene'
export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-controller'
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
export { export {
DEFAULT_HOVER_STYLES, DEFAULT_HOVER_STYLES,