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:
co-authored by
Claude Opus 4.8
parent
6cfc6ae01b
commit
5aedc366a6
@@ -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
|
||||
|
||||
@@ -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<THREE.Object3D, THREE.Object3D>,
|
||||
@@ -487,7 +520,12 @@ function stampIdentity(
|
||||
|
||||
target.name = id
|
||||
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")
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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'
|
||||
@@ -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<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. */
|
||||
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). */
|
||||
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<string, THREE.Object3D>()
|
||||
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<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(() => {
|
||||
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<Target | null>(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<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 handlePointerMove = useCallback(
|
||||
(event: ThreeEvent<PointerEvent>) => {
|
||||
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<MouseEvent>) => {
|
||||
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: [] })
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user