From 0a7cbd2081b4bc324853b01ed15e9987e968709c Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 23 Jun 2026 09:14:39 -0400 Subject: [PATCH] feat(viewer): GLB-consuming /viewer path (baked-glb-export phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/editor/src/lib/glb-export.test.ts | 48 ++ packages/editor/src/lib/glb-export.ts | 114 ++- .../src/components/viewer/glb-scene.tsx | 717 ++++++++++++++++++ .../src/components/viewer/post-processing.tsx | 8 +- packages/viewer/src/index.ts | 6 + 5 files changed, 882 insertions(+), 11 deletions(-) create mode 100644 packages/viewer/src/components/viewer/glb-scene.tsx diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index b6f616bd..b7d7c988 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -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 = { + [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() diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index b312314d..280bca7c 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -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() + 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) { 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 = { 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 } } diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx new file mode 100644 index 00000000..5dd8d28b --- /dev/null +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -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 + +/** 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(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() + 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([]) + 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()) + 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() + 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()) + const labelDivs = useRef(new Map()) + + // 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(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(null) + const handlePointerMove = useCallback( + (event: ThreeEvent) => { + 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) => { + 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 ( + + + {/* 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) => ( + { + if (group) labelGroups.current.set(zone.id, group) + else labelGroups.current.delete(zone.id) + }} + > + +
{ + 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} +
+ +
+ ))} +
+ ) +} diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 68b4041b..801b1174 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -338,7 +338,13 @@ const PostProcessingPasses = ({ const hasGeometry = scenePassColor.a const contentAlpha = hasGeometry.max(zonePass.a) - let sceneColor = scenePassColor as unknown as ReturnType + // 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 // Depth + normal MRT — shared by SSGI (diffuse/normal) and the ink pass // (depth/normal). Built whenever either is active. diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 06aaa6dd..f5b9960c 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -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,