feat(viewer): strip scans/guides from the bake, re-add from scene data

Scans (LiDAR meshes) and guides (floorplan images) are heavy reference assets
stored elsewhere, not part of the compiled building — and baking them into a
public static GLB would also bypass the per-project show_*_public flags. Strip
both from the export entirely (previously they leaked in as empty identity
nodes, timing-dependent).

The GLB viewer re-adds them at runtime from the scene graph, like lights:
GlbReferenceNodes resolves each scan/guide's registry renderer (no static nodes
import — same nodeRegistry path the viewer already uses) and portals it into its
parent level's baked node, so the node's level-local transform resolves to the
right world pose and rides level stacking. Uses the same GuideRenderer/
ScanRenderer + asset resolver as the parametric viewer, so http-backed assets
show for everyone and local asset:// ones for the owner — exact parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 08:36:02 -04:00
co-authored by Claude Opus 4.8
parent df901b1d4e
commit 5938cb6805
5 changed files with 88 additions and 2 deletions
@@ -8,7 +8,7 @@ import { ParametricNodeRenderer } from './parametric-node-renderer'
// on every render — that would create a new Suspense boundary each time.
const lazyCache = new WeakMap<RendererSource<AnyNode>, ComponentType<{ node: AnyNode }>>()
function getRegistryRenderer(
export function getRegistryRenderer(
source: RendererSource<AnyNode>,
): ComponentType<{ node: AnyNode }> | null {
const cached = lazyCache.get(source)
@@ -0,0 +1,63 @@
'use client'
import { type AnyNode, nodeRegistry, type RendererSource, type SceneGraph } from '@pascal-app/core'
import { createPortal } from '@react-three/fiber'
import { Suspense } from 'react'
import type { Object3D } from 'three'
import { getRegistryRenderer } from '../renderers/node-renderer'
/**
* Scans (LiDAR meshes) and guides (floorplan images) are stripped from the
* baked GLB — they're heavy reference assets stored elsewhere. The GLB viewer
* re-adds them at runtime from the scene graph, portaled into their parent
* level's baked node so they ride level stacking, using the same registry
* renderers as the parametric viewer. Privacy is enforced upstream: the page
* only includes nodes whose `show_*_public` flag (or owner/admin) allows it, so
* a disallowed asset is never even fetched.
*/
export function buildGlbReferenceNodes(
sceneGraph: SceneGraph | null | undefined,
allow: { scans: boolean; guides: boolean },
): AnyNode[] {
const nodes = sceneGraph?.nodes
if (!nodes) return []
const out: AnyNode[] = []
for (const raw of Object.values(nodes)) {
const node = raw as AnyNode
if (node.type === 'scan' && allow.scans) out.push(node)
else if (node.type === 'guide' && allow.guides) out.push(node)
}
return out
}
export function GlbReferenceNodes({
nodes,
identity,
}: {
nodes: AnyNode[]
identity: Map<string, Object3D>
}) {
return (
<>
{nodes.map((node) => {
const anchor = node.parentId ? identity.get(node.parentId) : undefined
return anchor ? <GlbReferenceNode anchor={anchor} key={node.id} node={node} /> : null
})}
</>
)
}
/** Render one scan/guide via its registry renderer, portaled into its parent
* level's baked Object3D (so the node's level-local transform resolves to the
* same world pose as the parametric scene). */
function GlbReferenceNode({ node, anchor }: { node: AnyNode; anchor: Object3D }) {
const source = nodeRegistry.get(node.type)?.renderer
const Renderer = source ? getRegistryRenderer(source as RendererSource<AnyNode>) : null
if (!Renderer) return null
return createPortal(
<Suspense fallback={null}>
<Renderer node={node} />
</Suspense>,
anchor,
)
}
@@ -1,6 +1,6 @@
'use client'
import type { SurfaceRole } from '@pascal-app/core'
import type { AnyNode, SurfaceRole } from '@pascal-app/core'
import { Html, useAnimations } from '@react-three/drei'
import { type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -13,6 +13,7 @@ import { ZONE_LAYER } from '../../lib/layers'
import { createSurfaceRoleMaterial } from '../../lib/materials'
import useViewer from '../../store/use-viewer'
import { GlbInteractive, type GlbInteractiveItem } from './glb-interactive'
import { GlbReferenceNodes } from './glb-reference-nodes'
/** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
const EXPLODED_GAP = 5
@@ -281,6 +282,7 @@ function createZoneWallGeometry(polygon: [number, number][]): THREE.BufferGeomet
export function GlbScene({
url,
interactiveItems,
referenceNodes,
onLevelsChange,
onIdentityChange,
onHoverChange,
@@ -290,6 +292,9 @@ export function GlbScene({
/** Light / animation effects + controls recovered from the DB scene graph,
* joined to the baked nodes by `pascalId` to re-light + re-animate the GLB. */
interactiveItems?: GlbInteractiveItem[]
/** Scan / guide nodes from the scene graph, re-added at runtime (they're
* stripped from the bake). Already filtered by the privacy flags upstream. */
referenceNodes?: AnyNode[]
onLevelsChange?: (levels: GlbLevel[]) => void
onIdentityChange?: (identity: GlbIdentity) => void
onHoverChange?: (hover: GlbHover) => void
@@ -998,6 +1003,11 @@ export function GlbScene({
zones={zoneEntries}
/>
) : null}
{/* Scans + guides, stripped from the bake and re-added from scene data,
anchored to their parent level's baked node. */}
{referenceNodes?.length ? (
<GlbReferenceNodes identity={identity} nodes={referenceNodes} />
) : null}
{/* 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. */}
+1
View File
@@ -23,6 +23,7 @@ export {
GlbInteractive,
type GlbInteractiveItem,
} from './components/viewer/glb-interactive'
export { buildGlbReferenceNodes } from './components/viewer/glb-reference-nodes'
export {
type GlbHover,
type GlbIdentity,