feat(viewer): GLB-consuming /viewer path (baked-glb-export phase 2)

Add `GlbScene` — the viewer that renders a baked GLB artifact with no
parametric scene graph, and finish the phase-1 export contract it consumes.

Viewer (packages/viewer):
- GlbScene: loads the artifact, drives a building → level → zone → node drill
  hierarchy on useViewer.selection. Room/zone picking resolves from the floor
  plane (ray ∩ floor + point-in-polygon), node picking uses a footprint test,
  so walls/ceilings/zone-helpers never skew or block selection. Level modes
  (stacked/exploded/solo), dollhouse (hide a focused floor's ceilings + roof so
  rooms are visible and pickable), reconstructed zone floor fills + gradient
  edge borders + room labels (faded, hover-brightened), baked door/window
  open clips, click-outside / empty-space deselect, and the shared outline
  post-FX. Exported as GlbScene/GlbLevel/GlbIdentity/GlbHover.
- post-processing: composite the zone-pass tint into the base scene so rooms
  show whether or not SSGI is enabled (previously only added in the SSGI branch).

Export contract (packages/editor/src/lib/glb-export.ts):
- Convert WebGPU NodeMaterials to classic glTF-standard materials; decompress
  KTX2 maps via WebGPUTextureUtils so GLTFExporter can embed them.
- Stamp name + extras identity (pascalId/kind/label/openable/clips), bake
  door/window open clips (loop:false), strip editor overlays (off-layer +
  invisible-material hitboxes) so cutouts aren't plugged.
- Fix opaque-but-flagged-transparent materials (no spurious alphaMode=BLEND)
  and BackSide → FrontSide + winding flip (glTF has no back-face-only).
- Force levels + zones visible and stamp level display names + zone polygons so
  every floor bakes and /viewer can reconstruct rooms and label the breadcrumb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-23 09:14:39 -04:00
co-authored by Claude Opus 4.8
parent 06e4cd7748
commit 0a7cbd2081
5 changed files with 882 additions and 11 deletions
@@ -146,6 +146,54 @@ describe('prepareSceneForExport', () => {
expect(leafMarkerSurvived).toBe(false)
})
test('keeps the zone identity node with its polygon and strips the fill mesh', () => {
const root = new THREE.Group()
const zoneGroup = new THREE.Group()
const fill = meshWithNodeMaterial(nodeMaterial())
fill.layers.set(2) // ZONE_LAYER
zoneGroup.add(fill)
zoneGroup.visible = false // the editor often hides zones at export time
root.add(zoneGroup)
const zoneId = 'zone_living'
const polygon: [number, number][] = [
[0, 0],
[4, 0],
[4, 3],
]
sceneRegistry.nodes.set(zoneId, zoneGroup)
const nodes: Record<string, AnyNode> = {
[zoneId]: {
object: 'node',
id: zoneId,
type: 'zone',
name: 'Living Room',
polygon,
color: '#ff0000',
} as unknown as AnyNode,
}
const { scene } = prepareSceneForExport(root, nodes)
const exported = scene.getObjectByProperty('name', zoneId)
expect(exported).toBeDefined()
// Forced visible so GLTFExporter's onlyVisible keeps the metadata node.
expect(exported?.visible).toBe(true)
expect(exported?.userData).toEqual({
pascalId: zoneId,
kind: 'zone',
label: 'Living Room',
polygon,
color: '#ff0000',
})
// The ZONE_LAYER fill mesh must not survive (rebuilt in /viewer instead).
let hasMesh = false
exported?.traverse((o) => {
if ((o as THREE.Mesh).isMesh) hasMesh = true
})
expect(hasMesh).toBe(false)
})
test('bakes a swing door into an open quaternion clip', () => {
const root = new THREE.Group()
const doorGroup = new THREE.Group()
+104 -10
View File
@@ -1,4 +1,11 @@
import { type AnyNode, sceneRegistry, type WindowNode } from '@pascal-app/core'
import {
type AnyNode,
getLevelDisplayName,
type LevelNode,
sceneRegistry,
type WindowNode,
type ZoneNode,
} from '@pascal-app/core'
import { poseWindowMovingParts, SCENE_LAYER } from '@pascal-app/viewer'
import * as THREE from 'three'
@@ -45,7 +52,17 @@ export function prepareSceneForExport(
const scene = source.clone(true)
const cloneByOriginal = pairClones(source, scene)
pruneNonRenderableMeshes(scene)
// Object3Ds that carry node identity — never strip these even when they sit on
// a non-scene layer. Some are metadata-only: a zone's visible fill/wall meshes
// are stripped, but its identity node stays to carry the polygon that /viewer
// reconstructs the room from.
const identityNodes = new Set<THREE.Object3D>()
for (const original of sceneRegistry.nodes.values()) {
const clone = cloneByOriginal.get(original)
if (clone) identityNodes.add(clone)
}
pruneNonRenderableMeshes(scene, identityNodes)
convertMaterials(scene)
const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
@@ -95,13 +112,16 @@ const EMPTY_GEOMETRY = new THREE.BufferGeometry()
* With children (it parents the visible frame + leaf) it keeps its node but
* loses its geometry; childless ones are removed outright.
*/
function pruneNonRenderableMeshes(root: THREE.Object3D) {
function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
const toRemove: THREE.Object3D[] = []
root.traverse((object) => {
// Editor-only overlays (gizmos, selection handles, ground grid, zone fills)
// live off the scene layer; the editor camera renders them via extra layers
// but a thumbnail/bake only wants layer 0. Drop the whole overlay subtree.
// live off the scene layer; the editor camera shows them via extra layers
// but a thumbnail/bake only wants layer 0. Drop the whole overlay subtree
// except identity nodes, which we keep (their off-layer mesh children are
// still pruned as the traversal continues).
if (!object.layers.isEnabled(SCENE_LAYER)) {
if (identityNodes.has(object)) return
toRemove.push(object)
return
}
@@ -148,12 +168,62 @@ function convertMaterials(root: THREE.Object3D) {
const mesh = object as THREE.Mesh
if (!mesh.isMesh) return
const material = mesh.material
mesh.material = Array.isArray(material)
? material.map((m) => convertMaterial(m, cache))
: convertMaterial(material, cache)
if (Array.isArray(material)) {
mesh.material = material.map((m) => convertMaterial(m, cache))
return
}
// glTF has no BackSide — GLTFExporter renders the *front* face for any
// non-DoubleSide material, which inverts a BackSide surface (e.g. the
// ceiling underside, meant to be seen from the room). Flip the mesh winding
// so the intended face shows with the FrontSide material convertMaterial
// produces. Per-mesh geometry clone keeps shared geometry untouched.
if (
(material as { isNodeMaterial?: boolean }).isNodeMaterial &&
material.side === THREE.BackSide
) {
mesh.geometry = flipGeometryWinding(mesh.geometry)
}
mesh.material = convertMaterial(material, cache)
})
}
/**
* Reverse triangle winding and negate normals so a surface authored for
* `BackSide` reads correctly once exported as `FrontSide` (glTF can't express
* back-face-only rendering).
*/
function flipGeometryWinding(geometry: THREE.BufferGeometry): THREE.BufferGeometry {
const flipped = geometry.clone()
const index = flipped.getIndex()
if (index) {
const a = index.array
for (let i = 0; i < a.length; i += 3) {
const tmp = a[i]!
a[i] = a[i + 2]!
a[i + 2] = tmp
}
index.needsUpdate = true
} else {
for (const attribute of Object.values(flipped.attributes)) {
const { array, itemSize } = attribute
for (let i = 0; i < array.length; i += itemSize * 3) {
for (let k = 0; k < itemSize; k++) {
const tmp = array[i + k]!
array[i + k] = array[i + 2 * itemSize + k]!
array[i + 2 * itemSize + k] = tmp
}
}
attribute.needsUpdate = true
}
}
const normal = flipped.getAttribute('normal')
if (normal) {
for (let i = 0; i < normal.array.length; i++) normal.array[i] = -normal.array[i]!
normal.needsUpdate = true
}
return flipped
}
/**
* Convert a viewer NodeMaterial into the classic `MeshStandardMaterial` the
* glTF exporter understands. Classic materials pass through untouched, and the
@@ -180,9 +250,15 @@ function convertMaterial(
// rough, non-metallic surface is the faithful lit fallback.
target.roughness = typeof src.roughness === 'number' ? src.roughness : 1
target.metalness = typeof src.metalness === 'number' ? src.metalness : 0
target.transparent = material.transparent
// Only genuinely see-through surfaces stay transparent. Several viewer
// materials set `transparent: true` while fully opaque (opacity 1); exporting
// those as alphaMode=BLEND makes them render see-through with no depth write
// (e.g. the ceiling looked semi-transparent). Glass (opacity < 1) is kept.
target.transparent = material.transparent && material.opacity < 1
target.opacity = material.opacity
target.side = material.side
// BackSide is flipped to FrontSide (with the mesh winding reversed in
// convertMaterials) because glTF has no back-face-only mode.
target.side = material.side === THREE.BackSide ? THREE.FrontSide : material.side
target.alphaTest = material.alphaTest
target.depthWrite = material.depthWrite
target.depthTest = material.depthTest
@@ -368,11 +444,29 @@ function stampIdentity(
target.name = id
const extras: Record<string, unknown> = { pascalId: id, kind: node.type }
if (node.name) extras.label = node.name
// 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
// current level mode (solo/hidden floors would otherwise be dropped by
// GLTFExporter's `onlyVisible`).
if (node.type === 'level') {
extras.label = getLevelDisplayName(node as LevelNode)
target.visible = true
}
if (node.type === 'door' || node.type === 'window') {
extras.openable = true
const clipNames = clipNamesByNode.get(id)
if (clipNames) extras.clips = clipNames
}
if (node.type === 'zone') {
// Zone fills are stripped from the bake; /viewer rebuilds the room from
// this polygon. Force the identity node visible so GLTFExporter's
// `onlyVisible` keeps it even when the editor had zones hidden at export.
const zone = node as ZoneNode
extras.polygon = zone.polygon
extras.color = zone.color
target.visible = true
}
target.userData = extras
}
}
@@ -0,0 +1,717 @@
'use client'
import { Html, useAnimations } from '@react-three/drei'
import { type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { lerp } from 'three/src/math/MathUtils.js'
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 useViewer from '../../store/use-viewer'
/** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
const EXPLODED_GAP = 5
/** A building floor discovered in the baked GLB, ordered bottom-to-top. */
export type GlbLevel = { id: `level_${string}`; label: string }
/** pascalId → display info, reported so a host can label the breadcrumb. */
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
type GlbLevelEntry = { id: GlbLevel['id']; node: THREE.Object3D; baseY: number }
type GlbZoneEntry = {
id: string
node: THREE.Object3D
levelId: string | null
polygon: [number, number][]
label: string
color: string
/** Polygon centroid (zone-local x, z) for placing the room label. */
centroid: [number, number]
}
type PascalExtras = {
pascalId?: string
kind?: string
label?: string
openable?: boolean
clips?: string[]
polygon?: [number, number][]
color?: string
}
/** 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 }
function findIdentityAncestor(object: THREE.Object3D): THREE.Object3D | null {
let current: THREE.Object3D | null = object
while (current) {
if ((current.userData as PascalExtras).pascalId) return current
current = current.parent
}
return null
}
function findAncestorLevelId(object: THREE.Object3D): string | null {
let current = object.parent
while (current) {
const extras = current.userData as PascalExtras
if (extras.kind === 'level' && extras.pascalId) return extras.pascalId
current = current.parent
}
return null
}
const _local = new THREE.Vector3()
const _floorHit = new THREE.Vector3()
const _floorPlanePoint = new THREE.Vector3()
const _floorPlane = new THREE.Plane()
const _up = new THREE.Vector3(0, 1, 0)
const _bounds = new THREE.Box3()
const _boundsCenter = new THREE.Vector3()
const _sample = new THREE.Vector3()
const ZONE_FOOTPRINT_EPSILON = 0.05
const NO_RAYCAST: THREE.Mesh['raycast'] = () => {}
/** Ray-cast point-in-polygon (polygon is a list of [x, z] in the test frame). */
function pointInPolygon(x: number, z: number, polygon: [number, number][]): boolean {
let inside = false
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, zi] = polygon[i]!
const [xj, zj] = polygon[j]!
if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) inside = !inside
}
return inside
}
function pointOnSegment(
x: number,
z: number,
ax: number,
az: number,
bx: number,
bz: number,
): boolean {
const dx = bx - ax
const dz = bz - az
const lengthSq = dx * dx + dz * dz
if (lengthSq === 0) return Math.hypot(x - ax, z - az) <= ZONE_FOOTPRINT_EPSILON
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / lengthSq))
const px = ax + t * dx
const pz = az + t * dz
return Math.hypot(x - px, z - pz) <= ZONE_FOOTPRINT_EPSILON
}
function pointInPolygonInclusive(x: number, z: number, polygon: [number, number][]): boolean {
if (pointInPolygon(x, z, polygon)) return true
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, zi] = polygon[i]!
const [xj, zj] = polygon[j]!
if (pointOnSegment(x, z, xi, zi, xj, zj)) return true
}
return false
}
function worldPointInZoneFootprint(worldPoint: THREE.Vector3, zone: GlbZoneEntry): boolean {
_local.copy(worldPoint)
zone.node.worldToLocal(_local)
return pointInPolygonInclusive(_local.x, _local.z, zone.polygon)
}
function objectFootprintTouchesZone(object: THREE.Object3D, zone: GlbZoneEntry): boolean {
_bounds.setFromObject(object)
if (_bounds.isEmpty()) {
object.getWorldPosition(_sample)
return worldPointInZoneFootprint(_sample, zone)
}
_bounds.getCenter(_boundsCenter)
const y = _bounds.min.y
const samples: Array<[number, number]> = [
[_boundsCenter.x, _boundsCenter.z],
[_bounds.min.x, _bounds.min.z],
[_bounds.min.x, _bounds.max.z],
[_bounds.max.x, _bounds.min.z],
[_bounds.max.x, _bounds.max.z],
]
for (const [x, z] of samples) {
_sample.set(x, y, z)
if (worldPointInZoneFootprint(_sample, zone)) return true
}
return false
}
const Y_OFFSET = 0.01
const ZONE_WALL_HEIGHT = 2.3
/** Floor fill — flat 0.25 tint scaled by the fade uniform (matches the editor). */
function createZoneFloorMaterial(zoneColor: string) {
const o = uniform(0)
const material = new MeshBasicNodeMaterial({
colorNode: color(new THREE.Color(zoneColor)),
depthTest: false,
depthWrite: false,
opacityNode: float(0.25).mul(o),
side: THREE.DoubleSide,
transparent: true,
})
material.userData.uOpacity = o
return material
}
/** Vertical border — color at the base fading to transparent at the top. */
function createZoneWallMaterial(zoneColor: string) {
const o = uniform(0)
const material = new MeshBasicNodeMaterial({
colorNode: color(new THREE.Color(zoneColor)),
depthTest: false,
depthWrite: false,
opacityNode: float(0.6).mul(float(1).sub(uv().y)).mul(o),
side: THREE.DoubleSide,
transparent: true,
})
material.userData.uOpacity = o
return material
}
/** Vertical quads along each polygon edge (UV.y 0 at the floor, 1 at the top). */
function createZoneWallGeometry(polygon: [number, number][]): THREE.BufferGeometry {
const positions: number[] = []
const uvs: number[] = []
const indices: number[] = []
for (let i = 0; i < polygon.length; i++) {
const [cx, cz] = polygon[i]!
const [nx, nz] = polygon[(i + 1) % polygon.length]!
const base = i * 4
positions.push(
cx,
Y_OFFSET,
cz,
nx,
Y_OFFSET,
nz,
nx,
Y_OFFSET + ZONE_WALL_HEIGHT,
nz,
cx,
Y_OFFSET + ZONE_WALL_HEIGHT,
cz,
)
uvs.push(0, 0, 1, 0, 1, 1, 0, 1)
indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.setIndex(indices)
geometry.computeVertexNormals()
return geometry
}
/**
* GLB-consuming viewer scene (plan phase 2). Loads a baked artifact and drives
* the editor's presentation/interaction with no parametric scene graph. Hover
* and click resolve through the drill hierarchy (building → level → zone →
* object): the cursor targets the floor in the building view, the room or
* structure in a level, and items/structure inside a room. Selection feeds the
* existing outline post-FX, openables play their baked clips, and the shared
* `useViewer.selection` (with its hierarchy guard) holds the drill state. The
* host disables the parametric `SelectionManager` (`selectionManager="custom"`).
*/
export function GlbScene({
url,
onLevelsChange,
onIdentityChange,
onHoverChange,
}: {
url: 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 rootRef = useRef<THREE.Group>(null!)
const { actions } = useAnimations(gltf.animations, rootRef)
const camera = useThree((state) => state.camera)
const raycaster = useThree((state) => state.raycaster)
// 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 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[] = []
gltf.scene.traverse((object) => {
const extras = object.userData as PascalExtras
if (!extras.pascalId) return
objects.set(extras.pascalId, object)
if (extras.kind === 'ceiling' || extras.kind === 'roof') occluderNodes.push(object)
if (extras.kind === 'level') {
floors.push({
id: extras.pascalId as GlbLevel['id'],
node: object,
baseY: object.position.y,
})
}
if (extras.kind === 'zone' && extras.polygon && extras.polygon.length >= 3) {
const polygon = extras.polygon
const centroid: [number, number] = [
polygon.reduce((sum, [x]) => sum + x, 0) / polygon.length,
polygon.reduce((sum, [, z]) => sum + z, 0) / polygon.length,
]
zoneList.push({
id: extras.pascalId,
node: object,
levelId: findAncestorLevelId(object),
polygon,
label: extras.label ?? extras.pascalId,
color: extras.color ?? '#3b82f6',
centroid,
})
}
})
floors.sort((a, b) => a.baseY - b.baseY)
return { levels: floors, identity: objects, zoneEntries: zoneList, occluders: occluderNodes }
}, [gltf.scene])
const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries])
useEffect(() => {
const cameraMask = camera.layers.mask
const raycasterMask = raycaster.layers.mask
camera.layers.enable(ZONE_LAYER)
raycaster.layers.disable(ZONE_LAYER)
return () => {
camera.layers.mask = cameraMask
raycaster.layers.mask = raycasterMask
}
}, [camera, raycaster])
useEffect(() => {
onLevelsChange?.(
levels.map(({ id, node }) => ({ id, label: (node.userData as PascalExtras).label ?? id })),
)
const labels: GlbIdentity = {}
identity.forEach((object, id) => {
const extras = object.userData as PascalExtras
labels[id] = { kind: extras.kind ?? 'node', label: extras.label ?? id }
})
onIdentityChange?.(labels)
return () => {
onLevelsChange?.([])
onIdentityChange?.({})
}
}, [levels, identity, onLevelsChange, onIdentityChange])
// Apply the editor's level modes to the baked floors each frame.
useFrame((_, delta) => {
if (levels.length === 0) return
const { levelMode, selection } = 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
})
}, 5)
// Reconstruct each room from `extras.polygon` as the editor renders it: a flat
// floor fill plus vertical gradient borders (color at the base fading up). The
// geometry isn't baked (engine-agnostic GLB); /viewer rebuilds it, parented to
// the zone node so it rides level stacking. Both meshes live on ZONE_LAYER so
// the post-FX zone pass composites them (the default scene pass skips them).
type ZoneFill = {
id: string
levelId: string | null
meshes: THREE.Mesh[]
uniforms: { value: number }[]
}
const zoneFills = useRef<ZoneFill[]>([])
useEffect(() => {
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)))
shape.closePath()
const floorMaterial = createZoneFloorMaterial(entry.color)
const floor = new THREE.Mesh(new THREE.ShapeGeometry(shape), floorMaterial)
floor.rotation.x = -Math.PI / 2
floor.position.y = 0.02
const wallMaterial = createZoneWallMaterial(entry.color)
const walls = new THREE.Mesh(createZoneWallGeometry(entry.polygon), wallMaterial)
const meshes = [floor, walls]
for (const mesh of meshes) {
mesh.visible = false
mesh.layers.set(ZONE_LAYER)
// Visual helpers only — never participate in picking. Hover/selection
// resolves against the real building geometry + point-in-polygon, so the
// tall wall helpers can't occlude items or fight at shared boundaries.
mesh.raycast = NO_RAYCAST
entry.node.add(mesh)
}
built.push({
id: entry.id,
levelId: entry.levelId,
meshes,
uniforms: [
floorMaterial.userData.uOpacity as { value: number },
wallMaterial.userData.uOpacity as { value: number },
],
})
}
zoneFills.current = built
return () => {
for (const { meshes } of built) {
for (const mesh of meshes) {
mesh.removeFromParent()
mesh.geometry.dispose()
;(mesh.material as THREE.Material).dispose()
}
}
zoneFills.current = []
}
}, [zoneEntries])
useEffect(() => {
for (const action of Object.values(actions)) {
if (!action) continue
action.loop = THREE.LoopOnce
action.clampWhenFinished = true
}
}, [actions])
const openIds = useRef(new Set<string>())
const toggleOpenable = useCallback(
(node: THREE.Object3D) => {
const extras = node.userData as PascalExtras
const clipName = extras.clips?.[0]
if (!extras.openable || !clipName) return
const action = actions[clipName]
if (!action) return
const id = extras.pascalId as string
const willOpen = !openIds.current.has(id)
action.enabled = true
action.paused = false
action.loop = THREE.LoopOnce
action.clampWhenFinished = true
action.timeScale = willOpen ? 1 : -1
action.play()
if (willOpen) openIds.current.add(id)
else openIds.current.delete(id)
},
[actions],
)
// The room whose polygon contains a world point. Resolving by the raycast hit
// point (rather than a node origin) means any surface inside a room footprint —
// floor, slab, or furniture — maps to that room.
const zoneAtPoint = useCallback(
(worldPoint: THREE.Vector3, levelId: string): GlbZoneEntry | null => {
for (const entry of zoneEntries) {
if (entry.levelId !== levelId) continue
_local.copy(worldPoint)
entry.node.worldToLocal(_local)
if (pointInPolygonInclusive(_local.x, _local.z, entry.polygon)) return entry
}
return null
},
[zoneEntries],
)
// The room the cursor points at, found by intersecting the pointer ray with the
// level's floor plane — independent of what 3D object the ray actually hits.
// This is the editor's model: zone helpers and walls are ignored, so adjacent
// rooms never fight and an item against a wall still maps to its own room.
const zoneAtRay = useCallback(
(ray: THREE.Ray, levelId: string): GlbZoneEntry | null => {
const levelNode = identity.get(levelId)
const floorY = levelNode ? levelNode.getWorldPosition(_floorPlanePoint).y : 0
_floorPlane.setFromNormalAndCoplanarPoint(_up, _floorPlanePoint.set(0, floorY, 0))
if (!ray.intersectPlane(_floorPlane, _floorHit)) return null
return zoneAtPoint(_floorHit, levelId)
},
[identity, zoneAtPoint],
)
// Resolve a pointer ray to the unit the current drill depth acts on:
// - building view → the floor the hit object belongs to
// - level view → the room the cursor points at on the floor
// - zone view → the first hit node whose hit/footprint is inside the room
const resolveTarget = useCallback(
(hits: HitCandidate[], ray: THREE.Ray): Target | null => {
const firstNode = hits.length > 0 ? findIdentityAncestor(hits[0]!.object) : null
const toTarget = (object: THREE.Object3D, tid: string): Target => {
const e = object.userData as PascalExtras
return { object, id: tid, kind: e.kind ?? 'node', label: e.label ?? tid }
}
const { selection } = useViewer.getState()
// Building view → drill to the floor the hit object belongs to.
if (!selection.levelId) {
if (!firstNode) return null
const extras = firstNode.userData as PascalExtras
const levelId = extras.kind === 'level' ? extras.pascalId : findAncestorLevelId(firstNode)
const levelObject = levelId ? identity.get(levelId) : undefined
return levelObject && levelId ? toTarget(levelObject, levelId) : null
}
// Level view → the room the cursor is over (floor-plane intersection).
if (!selection.zoneId) {
const zone = zoneAtRay(ray, selection.levelId)
return zone ? toTarget(zone.node, zone.id) : null
}
// Zone view → scan through all R3F intersections so room helpers or slabs
// can't hide a selectable item/structure behind the first hit.
const activeZone = zoneById.get(selection.zoneId)
if (!activeZone) return null
const seen = new Set<string>()
for (const hit of hits) {
const node = findIdentityAncestor(hit.object)
if (!node) continue
const extras = node.userData as PascalExtras
const id = extras.pascalId
if (!id || seen.has(id)) continue
seen.add(id)
const kind = extras.kind ?? 'node'
if (kind === 'site' || kind === 'building' || kind === 'level' || kind === 'zone') {
continue
}
const hitLevel = findAncestorLevelId(node)
if (hitLevel !== selection.levelId) continue
if (hit.point && worldPointInZoneFootprint(hit.point, activeZone)) {
return toTarget(node, id)
}
if (objectFootprintTouchesZone(node, activeZone)) {
return toTarget(node, id)
}
}
return null
},
[identity, zoneAtRay, zoneById],
)
// DOM nodes for each zone's floating room label, plus a group whose transform
// tracks the zone node so the label rides level stacking.
const labelGroups = useRef(new Map<string, THREE.Group>())
const labelDivs = useRef(new Map<string, HTMLDivElement>())
// Per-frame: fade a level's rooms in/out (hidden once a zone is entered, like
// the editor) with a brightness bump on the hovered room, keep room labels
// positioned + faded with them, and sync the outline post-FX from the shared
// selection + local hover.
const hoveredTarget = useRef<Target | null>(null)
useFrame((_, delta) => {
const { selection, outliner } = useViewer.getState()
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
for (const { id, levelId, meshes, uniforms } of zoneFills.current) {
const show = 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) {
u.value = lerp(u.value, target, t)
if (u.value > 0.01) visible = true
}
for (const mesh of meshes) mesh.visible = visible
const group = labelGroups.current.get(id)
const zoneNode = identity.get(id)
if (group && zoneNode) {
group.matrixAutoUpdate = false
group.matrix.copy(zoneNode.matrixWorld)
group.visible = visible
}
const div = labelDivs.current.get(id)
if (div) {
div.style.opacity = show ? '1' : '0'
// Small by default, smoothly zooming up when its room is hovered.
div.style.transform = `scale(${id === hoveredZoneId ? 1 : 0.82})`
}
}
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)
}
})
const lastHover = useRef<string | null>(null)
const handlePointerMove = useCallback(
(event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
const target = resolveTarget(
event.intersections.map((hit) => ({ object: hit.object, point: hit.point })),
event.ray,
)
hoveredTarget.current = target
document.body.style.cursor = target ? 'pointer' : 'auto'
const key = target ? `${target.kind}:${target.id}` : null
if (key !== lastHover.current) {
lastHover.current = key
onHoverChange?.(target ? { kind: target.kind, label: target.label } : null)
}
},
[resolveTarget, onHoverChange],
)
const handlePointerOut = useCallback(() => {
hoveredTarget.current = null
document.body.style.cursor = 'auto'
if (lastHover.current !== null) {
lastHover.current = null
onHoverChange?.(null)
}
}, [onHoverChange])
// Drill the building → level → zone → object hierarchy, deselecting back up
// when the click lands outside the current scope. setSelection's hierarchy
// guard clears deeper selections automatically when a parent changes.
const handleClick = useCallback(
(event: ThreeEvent<MouseEvent>) => {
event.stopPropagation()
const target = resolveTarget(
event.intersections.map((hit) => ({ object: hit.object, point: hit.point })),
event.ray,
)
const { selection, setSelection, setLevelMode } = useViewer.getState()
// Building view → drill into the clicked floor.
if (!selection.levelId) {
if (target) {
setLevelMode('solo')
setSelection({ levelId: target.id as `level_${string}` })
}
return
}
// Level view → enter the clicked room; clicking outside any room exits to
// the building.
if (!selection.zoneId) {
if (target) {
setSelection({ zoneId: target.id as `zone_${string}` })
} else {
setLevelMode('stacked')
setSelection({ levelId: null })
}
return
}
// Zone view → select the clicked node; clicking outside the room exits to
// the level.
if (target) {
setSelection({ selectedIds: [target.id] })
toggleOpenable(target.object)
} else {
setSelection({ zoneId: null })
}
},
[resolveTarget, toggleOpenable],
)
// A click that hits nothing (empty space) steps one level back up the drill
// hierarchy, like the legacy viewer.
const handlePointerMissed = useCallback(() => {
const { selection, setSelection, setLevelMode } = useViewer.getState()
if (selection.selectedIds.length > 0) {
setSelection({ selectedIds: [] })
} else if (selection.zoneId) {
setSelection({ zoneId: null })
} else if (selection.levelId) {
setLevelMode('stacked')
setSelection({ levelId: null })
}
}, [])
useEffect(
() => () => {
const { outliner } = useViewer.getState()
outliner.selectedObjects.length = 0
outliner.hoveredObjects.length = 0
document.body.style.cursor = 'auto'
// Restore ceilings/roof — the GLB scene is cached by drei and may be reused.
for (const occluder of occluders) occluder.visible = true
},
[occluders],
)
return (
<group ref={rootRef}>
<primitive
object={gltf.scene}
onClick={handleClick}
onPointerMissed={handlePointerMissed}
onPointerMove={handlePointerMove}
onPointerOut={handlePointerOut}
/>
{/* 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. */}
{zoneEntries.map((zone) => (
<group
key={zone.id}
ref={(group) => {
if (group) labelGroups.current.set(zone.id, group)
else labelGroups.current.delete(zone.id)
}}
>
<Html
center
position={[zone.centroid[0], 1, zone.centroid[1]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[10, 0]}
>
<div
ref={(div) => {
if (div) labelDivs.current.set(zone.id, div)
else labelDivs.current.delete(zone.id)
}}
style={{
color: 'white',
opacity: 0,
textShadow: `-1px -1px 0 ${zone.color}, 1px -1px 0 ${zone.color}, -1px 1px 0 ${zone.color}, 1px 1px 0 ${zone.color}`,
transform: 'scale(0.82)',
transformOrigin: 'center',
transition: 'opacity 0.3s ease-in-out, transform 0.2s ease-out',
whiteSpace: 'nowrap',
}}
>
{zone.label}
</div>
</Html>
</group>
))}
</group>
)
}
@@ -338,7 +338,13 @@ const PostProcessingPasses = ({
const hasGeometry = scenePassColor.a
const contentAlpha = hasGeometry.max(zonePass.a)
let sceneColor = scenePassColor as unknown as ReturnType<typeof vec4>
// Composite the zone-pass tint into the base scene so rooms show whether or
// not SSGI is enabled. When SSGI is on, the branch below overwrites this
// with its own zone-inclusive composite (no double-add).
let sceneColor = vec4(
add(scenePassColor.rgb, zonePass.rgb),
contentAlpha,
) as unknown as ReturnType<typeof vec4>
// Depth + normal MRT — shared by SSGI (diffuse/normal) and the ink pass
// (depth/normal). Built whenever either is active.
+6
View File
@@ -13,6 +13,12 @@ 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 {
GlbScene,
type GlbHover,
type GlbIdentity,
type GlbLevel,
} from './components/viewer/glb-scene'
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
export {
DEFAULT_HOVER_STYLES,