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:
co-authored by
Claude Opus 4.8
parent
06e4cd7748
commit
0a7cbd2081
@@ -146,6 +146,54 @@ describe('prepareSceneForExport', () => {
|
|||||||
expect(leafMarkerSurvived).toBe(false)
|
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', () => {
|
test('bakes a swing door into an open quaternion clip', () => {
|
||||||
const root = new THREE.Group()
|
const root = new THREE.Group()
|
||||||
const doorGroup = new THREE.Group()
|
const doorGroup = new THREE.Group()
|
||||||
|
|||||||
@@ -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 { poseWindowMovingParts, SCENE_LAYER } from '@pascal-app/viewer'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
|
||||||
@@ -45,7 +52,17 @@ export function prepareSceneForExport(
|
|||||||
const scene = source.clone(true)
|
const scene = source.clone(true)
|
||||||
const cloneByOriginal = pairClones(source, scene)
|
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)
|
convertMaterials(scene)
|
||||||
|
|
||||||
const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
|
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
|
* With children (it parents the visible frame + leaf) it keeps its node but
|
||||||
* loses its geometry; childless ones are removed outright.
|
* 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[] = []
|
const toRemove: THREE.Object3D[] = []
|
||||||
root.traverse((object) => {
|
root.traverse((object) => {
|
||||||
// Editor-only overlays (gizmos, selection handles, ground grid, zone fills)
|
// Editor-only overlays (gizmos, selection handles, ground grid, zone fills)
|
||||||
// live off the scene layer; the editor camera renders them via extra layers
|
// 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.
|
// 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 (!object.layers.isEnabled(SCENE_LAYER)) {
|
||||||
|
if (identityNodes.has(object)) return
|
||||||
toRemove.push(object)
|
toRemove.push(object)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -148,12 +168,62 @@ function convertMaterials(root: THREE.Object3D) {
|
|||||||
const mesh = object as THREE.Mesh
|
const mesh = object as THREE.Mesh
|
||||||
if (!mesh.isMesh) return
|
if (!mesh.isMesh) return
|
||||||
const material = mesh.material
|
const material = mesh.material
|
||||||
mesh.material = Array.isArray(material)
|
if (Array.isArray(material)) {
|
||||||
? material.map((m) => convertMaterial(m, cache))
|
mesh.material = material.map((m) => convertMaterial(m, cache))
|
||||||
: convertMaterial(material, 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
|
* Convert a viewer NodeMaterial into the classic `MeshStandardMaterial` the
|
||||||
* glTF exporter understands. Classic materials pass through untouched, and 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.
|
// rough, non-metallic surface is the faithful lit fallback.
|
||||||
target.roughness = typeof src.roughness === 'number' ? src.roughness : 1
|
target.roughness = typeof src.roughness === 'number' ? src.roughness : 1
|
||||||
target.metalness = typeof src.metalness === 'number' ? src.metalness : 0
|
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.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.alphaTest = material.alphaTest
|
||||||
target.depthWrite = material.depthWrite
|
target.depthWrite = material.depthWrite
|
||||||
target.depthTest = material.depthTest
|
target.depthTest = material.depthTest
|
||||||
@@ -368,11 +444,29 @@ 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
|
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') {
|
if (node.type === 'door' || node.type === 'window') {
|
||||||
extras.openable = true
|
extras.openable = true
|
||||||
const clipNames = clipNamesByNode.get(id)
|
const clipNames = clipNamesByNode.get(id)
|
||||||
if (clipNames) extras.clips = clipNames
|
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
|
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 hasGeometry = scenePassColor.a
|
||||||
const contentAlpha = hasGeometry.max(zonePass.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 MRT — shared by SSGI (diffuse/normal) and the ink pass
|
||||||
// (depth/normal). Built whenever either is active.
|
// (depth/normal). Built whenever either is active.
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ 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 {
|
||||||
|
GlbScene,
|
||||||
|
type GlbHover,
|
||||||
|
type GlbIdentity,
|
||||||
|
type GlbLevel,
|
||||||
|
} from './components/viewer/glb-scene'
|
||||||
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user