feat(viewer): distance-based LOD switching in GLB scene

GlbScene accepts an optional lodUrls list and swaps rendered detail by camera
distance to the building's world-space center: lod0 (interactive — identity,
selection, zones, clips all bind here) for normal + building-view distances,
simpler visual-only levels only past ~2.5R / ~8R. Single-URL callers are
unchanged. useGLTFKTX2 now accepts a URL array.

Manual visibility toggling rather than THREE.LOD: THREE.LOD measures distance
to its own origin (0,0,0), which mis-selected levels (blanking the scene) when
the baked geometry is offset from the origin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-23 13:10:01 -04:00
co-authored by Claude Opus 4.8
parent 064be98d7f
commit 0469b25639
2 changed files with 58 additions and 8 deletions
@@ -13,6 +13,7 @@ import useViewer from '../../store/use-viewer'
/** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
const EXPLODED_GAP = 5
const LOD_FALLBACK_RADIUS = 10
/** A building floor discovered in the baked GLB, ordered bottom-to-top. */
export type GlbLevel = { id: `level_${string}`; label: string }
@@ -48,6 +49,10 @@ type PascalExtras = {
/** The resolved drill target for a raycast hit, given the current selection. */
type Target = { object: THREE.Object3D; id: string; kind: string; label: string }
type HitCandidate = { object: THREE.Object3D; point?: THREE.Vector3 }
type GlbLoadResult = {
scene: THREE.Group
animations: THREE.AnimationClip[]
}
function findIdentityAncestor(object: THREE.Object3D): THREE.Object3D | null {
let current: THREE.Object3D | null = object
@@ -228,19 +233,49 @@ function createZoneWallGeometry(polygon: [number, number][]): THREE.BufferGeomet
*/
export function GlbScene({
url,
lodUrls,
onLevelsChange,
onIdentityChange,
onHoverChange,
}: {
url: string
lodUrls?: string[]
onLevelsChange?: (levels: GlbLevel[]) => void
onIdentityChange?: (identity: GlbIdentity) => void
onHoverChange?: (hover: GlbHover) => void
}) {
const gltf = useGLTFKTX2(url) as unknown as {
scene: THREE.Group
animations: THREE.AnimationClip[]
}
const urls = useMemo(() => (lodUrls && lodUrls.length > 1 ? lodUrls : [url]), [lodUrls, url])
const loadedGltfs = useGLTFKTX2(urls) as unknown as GlbLoadResult | GlbLoadResult[]
const gltfs = Array.isArray(loadedGltfs) ? loadedGltfs : [loadedGltfs]
const gltf = gltfs[0]!
const lodScenes = useMemo(() => gltfs.map((g) => g.scene), [gltfs])
// Distance-based LOD. We toggle scene visibility by camera distance to the
// building's world-space CENTER — `THREE.LOD` measures distance to its own
// origin (0,0,0), which mis-selects levels when the baked scene is offset
// from the origin. Interaction stays bound to lod0 only; the simpler levels
// are visual-only and appear when the camera pulls well past the building
// (so normal + building-view distances keep the interactive lod0).
const lodInfo = useMemo(() => {
if (lodScenes.length <= 1) return null
const box = new THREE.Box3().setFromObject(lodScenes[0]!)
const center = box.getCenter(new THREE.Vector3())
const radius = box.getBoundingSphere(new THREE.Sphere()).radius
const r = Number.isFinite(radius) && radius > 0 ? radius : LOD_FALLBACK_RADIUS
return { center, dist1: r * 2.5, dist2: r * 8, maxLevel: lodScenes.length - 1 }
}, [lodScenes])
useEffect(() => {
lodScenes.forEach((scene, i) => {
scene.visible = i === 0
})
}, [lodScenes])
useFrame(({ camera }) => {
if (!lodInfo) return
const d = camera.position.distanceTo(lodInfo.center)
const level = Math.min(d >= lodInfo.dist2 ? 2 : d >= lodInfo.dist1 ? 1 : 0, lodInfo.maxLevel)
for (let i = 0; i < lodScenes.length; i++) {
lodScenes[i]!.visible = i === level
}
})
const rootRef = useRef<THREE.Group>(null!)
const { actions } = useAnimations(gltf.animations, rootRef)
const camera = useThree((state) => state.camera)
@@ -345,7 +380,10 @@ export function GlbScene({
const built: ZoneFill[] = []
for (const entry of zoneEntries) {
const shape = new THREE.Shape()
entry.polygon.forEach(([x, z], i) => (i === 0 ? shape.moveTo(x, -z) : shape.lineTo(x, -z)))
entry.polygon.forEach(([x, z], i) => {
if (i === 0) shape.moveTo(x, -z)
else shape.lineTo(x, -z)
})
shape.closePath()
const floorMaterial = createZoneFloorMaterial(entry.color)
@@ -668,6 +706,7 @@ export function GlbScene({
return (
<group ref={rootRef}>
{/* lod0: the interactive level (identity/selection/zones all bind here). */}
<primitive
object={gltf.scene}
onClick={handleClick}
@@ -675,6 +714,11 @@ export function GlbScene({
onPointerMove={handlePointerMove}
onPointerOut={handlePointerOut}
/>
{/* Simpler LODs are visual-only; the useFrame above toggles their
visibility by camera distance. They carry no interaction handlers. */}
{gltfs.slice(1).map((g, i) => (
<primitive key={lodScenes[i + 1]?.uuid ?? i} object={g.scene} />
))}
{/* Floating room labels. Each group's matrix is synced to its zone node
every frame (above) so the label rides level stacking; the div fades
with the room fill via a CSS transition. */}
+9 -3
View File
@@ -1,9 +1,15 @@
import { useGLTF } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { type ObjectMap, useThree } from '@react-three/fiber'
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { ensureKtx2Support, ktx2Loader } from '../lib/ktx2-loader'
const useGLTFKTX2 = (path: string): ReturnType<typeof useGLTF> => {
type GLTFKTX2Path = string | string[]
type GLTFKTX2Result<T extends GLTFKTX2Path> = T extends string[]
? Array<GLTF & ObjectMap>
: GLTF & ObjectMap
const useGLTFKTX2 = <T extends GLTFKTX2Path>(path: T): GLTFKTX2Result<T> => {
const gl = useThree((state) => state.gl)
return useGLTF(path, true, true, (loader) => {
@@ -12,7 +18,7 @@ const useGLTFKTX2 = (path: string): ReturnType<typeof useGLTF> => {
loader.setKTX2Loader(ktx2Loader as any)
}
loader.setMeshoptDecoder(MeshoptDecoder)
})
}) as unknown as GLTFKTX2Result<T>
}
export { useGLTFKTX2 }