From 06e4cd7748201578975477b6e96299921e221502 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 22 Jun 2026 11:55:27 -0400 Subject: [PATCH 01/16] feat(export): baked GLB export with identity, clips, cutout fix (phases 0-1) Promote the client GLB export into the baked-artifact format from plans/editor-baked-glb-export.md (phases 0 and 1). - NodeMaterial -> classic MeshStandardMaterial conversion at export. The viewer's MeshStandard/LambertNodeMaterial set isNodeMaterial, not isMeshStandardMaterial, so GLTFExporter would otherwise drop every surface to a blank default. KTX2 (compressed) maps are decompressed via WebGPUTextureUtils so the exporter can embed them (PNG for now; KTX2 re-encode is deferred to the phase-3 bake worker). - Identity stamping from sceneRegistry: node.name = pascalId and extras = { pascalId, kind, label?, openable?, clips? }; all other userData stripped so editor/runtime ephemera never reach glTF extras. - Door/window open clips baked from the build-once + pose-at-t primitives (door pascalSwingLeaf marker, window poseWindowMovingParts). Clips named by label ("Door 1: open"), carry extras.loop = false (consumers play once and hold; dumb glTF players still loop). - Cutout fix: door/window selection hitboxes hide via material.visible, which onlyVisible misses, so the hitbox box plugged the wall opening. Non-renderable container meshes now keep their node but lose geometry; childless ones are removed. - Editor-overlay stripping mirrors the thumbnail capture: emit thumbnail:before/after-capture so scene-layer affordances (handles, ceiling/site brackets) self-hide, and drop anything off SCENE_LAYER (gizmos, grid, zone fills). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/editor/export-manager.tsx | 53 +-- packages/editor/src/lib/glb-export.test.ts | 187 +++++++++ packages/editor/src/lib/glb-export.ts | 378 ++++++++++++++++++ packages/viewer/src/index.ts | 5 +- .../viewer/src/systems/door/door-system.tsx | 12 + .../window/window-animation-system.tsx | 27 +- 6 files changed, 624 insertions(+), 38 deletions(-) create mode 100644 packages/editor/src/lib/glb-export.test.ts create mode 100644 packages/editor/src/lib/glb-export.ts diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index abbfebb3d..7f31f7add 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -1,12 +1,14 @@ 'use client' +import { emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect } from 'react' -import type { Mesh, Object3D } from 'three' import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js' import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' +import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js' +import { prepareSceneForExport } from '../../lib/glb-export' export function ExportManager() { const scene = useThree((state) => state.scene) @@ -22,7 +24,18 @@ export function ExportManager() { } const date = new Date().toISOString().split('T')[0] - const exportScene = prepareSceneForExport(sceneGroup) + // Hide editor affordances that live on the scene layer (selection handles, + // ceiling/site brackets) and let wall-cutout reveal all walls — the same + // synchronous capture path thumbnails use. We clone the scene inside the + // window, so the export snapshots the clean building, then restore. + emitter.emit('thumbnail:before-capture', undefined) + let prepared: ReturnType + try { + prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes) + } finally { + emitter.emit('thumbnail:after-capture', undefined) + } + const { scene: exportScene, animations } = prepared if (format === 'stl') { const exporter = new STLExporter() @@ -40,8 +53,13 @@ export function ExportManager() { return } - // Default: GLB export (existing behavior) + // Default: GLB export with baked identity + door/window animation clips. const exporter = new GLTFExporter() + // Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read + // those directly. WebGPUTextureUtils blits each one to RGBA on its own + // offscreen renderer (passing the live renderer would resize/draw over the + // editor canvas), letting the exporter embed standard textures. + exporter.setTextureUtils(WebGPUTextureUtils) return new Promise((resolve, reject) => { exporter.parse( @@ -55,7 +73,7 @@ export function ExportManager() { console.error('Export error:', error) reject(error) }, - { binary: true }, + { binary: true, animations }, ) }) } @@ -70,33 +88,6 @@ export function ExportManager() { return null } -function prepareSceneForExport(source: Object3D) { - const clone = source.clone(true) - const meshesToRemove: Mesh[] = [] - - clone.traverse((object) => { - if (isMeshWithInvalidGeometry(object)) meshesToRemove.push(object) - }) - - for (const mesh of meshesToRemove) { - mesh.removeFromParent() - } - - return clone -} - -function isMeshWithInvalidGeometry(object: Object3D): object is Mesh { - if (!isMesh(object)) return false - - // Three exporters can crash when a Mesh has no readable position attribute. - const position = object.geometry?.getAttribute('position') - return !position || position.count === 0 -} - -function isMesh(object: Object3D): object is Mesh { - return (object as Mesh).isMesh === true -} - function downloadBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob) const link = document.createElement('a') diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts new file mode 100644 index 000000000..b6f616bd8 --- /dev/null +++ b/packages/editor/src/lib/glb-export.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { type AnyNode, sceneRegistry } from '@pascal-app/core' +import * as THREE from 'three' +import { prepareSceneForExport } from './glb-export' + +afterEach(() => { + sceneRegistry.clear() +}) + +function nodeMaterial(overrides: Record = {}) { + // Duck-typed stand-in for the viewer's MeshStandard/LambertNodeMaterial: + // the exporter keys off `isNodeMaterial` and reads plain PBR props. + return { + isNodeMaterial: true, + name: 'painted', + color: new THREE.Color('#cc3300'), + roughness: 0.3, + metalness: 0.7, + transparent: false, + opacity: 1, + side: THREE.FrontSide, + alphaTest: 0, + depthWrite: true, + depthTest: true, + vertexColors: false, + toneMapped: true, + ...overrides, + } as unknown as THREE.Material +} + +function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh { + const geometry = new THREE.BoxGeometry(1, 1, 1) + return new THREE.Mesh(geometry, material) +} + +describe('prepareSceneForExport', () => { + test('converts NodeMaterials to classic glTF-standard materials', () => { + const root = new THREE.Group() + root.name = 'scene-renderer' + const mesh = meshWithNodeMaterial(nodeMaterial()) + root.add(mesh) + + const { scene } = prepareSceneForExport(root, {}) + + const exported = scene.children[0] as THREE.Mesh + const material = exported.material as THREE.MeshStandardMaterial + expect(material.isMeshStandardMaterial).toBe(true) + expect(material.roughness).toBeCloseTo(0.3) + expect(material.metalness).toBeCloseTo(0.7) + expect(material.color.getHexString()).toBe('cc3300') + }) + + test('shared NodeMaterial instances convert to a single shared material', () => { + const root = new THREE.Group() + const shared = nodeMaterial() + root.add(meshWithNodeMaterial(shared), meshWithNodeMaterial(shared)) + + const { scene } = prepareSceneForExport(root, {}) + + const meshes = scene.children as THREE.Mesh[] + expect(meshes[0]!.material).toBe(meshes[1]!.material) + }) + + test('strips editor overlays that live off the scene layer', () => { + const root = new THREE.Group() + const realMesh = meshWithNodeMaterial(nodeMaterial()) + const overlay = meshWithNodeMaterial(nodeMaterial()) + overlay.layers.set(1) // OVERLAY_LAYER / EDITOR_LAYER — off scene layer 0 + root.add(realMesh, overlay) + + const { scene } = prepareSceneForExport(root, {}) + + const meshes: THREE.Mesh[] = [] + scene.traverse((o) => { + if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh) + }) + expect(meshes).toHaveLength(1) + }) + + test('neutralises an invisible hitbox root but keeps its visible children', () => { + // Door/window roots are selection hitboxes: a box geometry with an invisible + // material (object stays visible). Left intact it would plug the wall opening. + const root = new THREE.Group() + const hitbox = new THREE.Mesh( + new THREE.BoxGeometry(1, 2, 0.2), + new THREE.MeshBasicMaterial({ visible: false }), + ) + const leaf = meshWithNodeMaterial(nodeMaterial()) + hitbox.add(leaf) + root.add(hitbox) + + const doorId = 'door_hitbox' + sceneRegistry.nodes.set(doorId, hitbox) + const nodes: Record = { + [doorId]: { object: 'node', id: doorId, type: 'door' } as unknown as AnyNode, + } + + const { scene } = prepareSceneForExport(root, nodes) + + const exported = scene.getObjectByProperty('name', doorId) as THREE.Mesh + expect(exported).toBeDefined() + // Geometry emptied -> GLTFExporter emits a plain node, no solid block. + expect(exported.geometry.getAttribute('position')).toBeUndefined() + // The visible leaf survives as a child. + const visibleChildren = exported.children.filter((c) => (c as THREE.Mesh).isMesh) + expect(visibleChildren).toHaveLength(1) + }) + + test('stamps identity from the scene registry and strips other userData', () => { + const root = new THREE.Group() + const doorGroup = new THREE.Group() + const leaf = new THREE.Group() + leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 } + leaf.add(meshWithNodeMaterial(nodeMaterial())) + doorGroup.add(leaf) + root.add(doorGroup) + + const doorId = 'door_test' + sceneRegistry.nodes.set(doorId, doorGroup) + const nodes: Record = { + [doorId]: { + object: 'node', + id: doorId, + type: 'door', + name: 'Front door', + } as unknown as AnyNode, + } + + const { scene } = prepareSceneForExport(root, nodes) + + const exportedDoor = scene.getObjectByProperty('name', doorId) + expect(exportedDoor).toBeDefined() + expect(exportedDoor?.userData).toEqual({ + pascalId: doorId, + kind: 'door', + label: 'Front door', + openable: true, + clips: ['Front door: open'], + }) + + // The swing-leaf marker must not survive into glTF extras. + let leafMarkerSurvived = false + scene.traverse((object) => { + if (object.userData.pascalSwingLeaf) leafMarkerSurvived = true + }) + expect(leafMarkerSurvived).toBe(false) + }) + + test('bakes a swing door into an open quaternion clip', () => { + const root = new THREE.Group() + const doorGroup = new THREE.Group() + const leaf = new THREE.Group() + leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 } + leaf.add(meshWithNodeMaterial(nodeMaterial())) + doorGroup.add(leaf) + root.add(doorGroup) + + const doorId = 'door_swing' + sceneRegistry.nodes.set(doorId, doorGroup) + const nodes: Record = { + [doorId]: { object: 'node', id: doorId, type: 'door', name: 'Door' } as unknown as AnyNode, + } + + const { scene, animations } = prepareSceneForExport(root, nodes) + + expect(animations).toHaveLength(1) + const clip = animations[0]! + expect(clip.name).toBe('Door: open') + expect(clip.duration).toBe(1) + // Playback intent carried in extras so consumers can play once and hold. + expect(clip.userData).toEqual({ loop: false }) + + const track = clip.tracks[0]! + expect(track).toBeInstanceOf(THREE.QuaternionKeyframeTrack) + expect(track.name.endsWith('.quaternion')).toBe(true) + expect(Array.from(track.times)).toEqual([0, 1]) + + // The track must target an object that exists in the exported tree. + const targetUuid = track.name.replace('.quaternion', '') + const target = scene.getObjectByProperty('uuid', targetUuid) + expect(target).toBeDefined() + + // Rest pose is closed: the first keyframe is the identity rotation. + const closed = new THREE.Quaternion().fromArray(Array.from(track.values).slice(0, 4)) + expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0) + }) +}) diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts new file mode 100644 index 000000000..b312314d0 --- /dev/null +++ b/packages/editor/src/lib/glb-export.ts @@ -0,0 +1,378 @@ +import { type AnyNode, sceneRegistry, type WindowNode } from '@pascal-app/core' +import { poseWindowMovingParts, SCENE_LAYER } from '@pascal-app/viewer' +import * as THREE from 'three' + +/** + * Two TRS samples (closed vs open) differing by less than this are treated as + * stationary, so only genuinely moving parts get an animation track. + */ +const POSE_EPSILON = 1e-5 + +/** + * Marker stamped on a door's swing-leaf group by the door system. `axis` is the + * hinge axis and `openRotationY` is the fully-open angle (radians). The export + * reads it to bake an open clip from a single closed pose; see `door-system`. + */ +type SwingLeafMarker = { axis: 'y'; openRotationY: number } + +export type GlbExport = { + scene: THREE.Object3D + animations: THREE.AnimationClip[] +} + +/** + * Build an engine-agnostic export tree from the live scene graph. The result is + * a standalone three.js scene plus glTF animation clips, ready for + * `GLTFExporter` — it carries no Pascal runtime dependency. + * + * - Clones the source so live objects are never mutated. + * - Converts WebGPU NodeMaterials to classic glTF-standard materials. + * `GLTFExporter` only recognises `isMeshStandardMaterial` / + * `isMeshBasicMaterial`; the viewer's `MeshStandard/LambertNodeMaterial` set + * `isNodeMaterial` instead, so without this every surface exports as a blank + * default material. + * - Bakes each openable door/window's open motion into a glTF animation clip + * via the build-once + pose-at-t primitives (`pascalSwingLeaf` for doors, + * `poseWindowMovingParts` for windows). + * - Stamps `name` + `extras` identity from `sceneRegistry` so selection/hover + * survive the bake with no in-memory registry, and strips all other userData + * so editor/runtime ephemera never leak into glTF extras. + */ +export function prepareSceneForExport( + source: THREE.Object3D, + nodes: Record, +): GlbExport { + const scene = source.clone(true) + const cloneByOriginal = pairClones(source, scene) + + pruneNonRenderableMeshes(scene) + convertMaterials(scene) + + const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes) + + stampIdentity(scene, cloneByOriginal, nodes, clipNamesByNode) + + return { scene, animations: clips } +} + +/** + * Pair each original Object3D with its clone. `clone(true)` builds children in + * source order, so parallel pre-order traversals line up 1:1 — this is how we + * map `sceneRegistry`'s live refs onto the export tree without mutating either. + */ +function pairClones( + source: THREE.Object3D, + clone: THREE.Object3D, +): Map { + const originals: THREE.Object3D[] = [] + const clones: THREE.Object3D[] = [] + source.traverse((object) => originals.push(object)) + clone.traverse((object) => clones.push(object)) + + const map = new Map() + for (let i = 0; i < originals.length; i++) { + const target = clones[i] + if (target) map.set(originals[i]!, target) + } + return map +} + +// A single empty geometry shared by every container mesh we neutralise below — +// it has no attributes, so GLTFExporter's processMesh returns null and emits a +// plain transform node instead of a primitive. +const EMPTY_GEOMETRY = new THREE.BufferGeometry() + +/** + * Strip everything that must not bake into the model: + * - Editor overlays on non-scene layers (gizmos, selection handles, ground + * grid, zone fills). The editor camera shows them via extra layers; a + * thumbnail/bake is layer 0 only. Scene-layer affordances that can't be + * layer-filtered (ceiling/site brackets) are hidden by the caller's + * `thumbnail:before-capture` emit before the clone instead. + * - Selection hitboxes, whose invisibility lives on `material.visible = false` + * (which GLTFExporter's `onlyVisible` does not catch). A door/window's hitbox + * root is a box spanning the wall opening — left in, it plugs the cutout. + * 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) { + 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. + if (!object.layers.isEnabled(SCENE_LAYER)) { + toRemove.push(object) + return + } + const mesh = object as THREE.Mesh + if (!mesh.isMesh || isRenderableMesh(mesh)) return + if (mesh.children.length > 0) { + mesh.geometry = EMPTY_GEOMETRY + } else { + toRemove.push(mesh) + } + }) + for (const object of toRemove) { + object.removeFromParent() + } +} + +function isRenderableMesh(mesh: THREE.Mesh): boolean { + const position = mesh.geometry?.getAttribute('position') + if (!position || position.count === 0) return false + const material = mesh.material + return Array.isArray(material) + ? material.some((m) => m?.visible !== false) + : material?.visible !== false +} + +// --- Material conversion ------------------------------------------------- + +const STANDARD_MAP_SLOTS = [ + 'map', + 'normalMap', + 'roughnessMap', + 'metalnessMap', + 'aoMap', + 'emissiveMap', + 'alphaMap', + 'lightMap', + 'displacementMap', + 'bumpMap', +] as const + +function convertMaterials(root: THREE.Object3D) { + const cache = new Map() + root.traverse((object) => { + 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) + }) +} + +/** + * Convert a viewer NodeMaterial into the classic `MeshStandardMaterial` the + * glTF exporter understands. Classic materials pass through untouched, and the + * cache preserves material sharing (one source instance -> one target), so the + * exporter still dedups shared surfaces. + */ +function convertMaterial( + material: THREE.Material, + cache: Map, +): THREE.Material { + if ((material as { isNodeMaterial?: boolean }).isNodeMaterial !== true) return material + + const cached = cache.get(material) + if (cached) return cached + + const src = material as THREE.Material & Record + const target = new THREE.MeshStandardMaterial() + + target.name = material.name + if (src.color instanceof THREE.Color) target.color.copy(src.color) + if (src.emissive instanceof THREE.Color) target.emissive.copy(src.emissive) + if (typeof src.emissiveIntensity === 'number') target.emissiveIntensity = src.emissiveIntensity + // Lambert (solid-shading / glass) node materials carry no PBR scalars; a fully + // 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 + target.opacity = material.opacity + target.side = material.side + target.alphaTest = material.alphaTest + target.depthWrite = material.depthWrite + target.depthTest = material.depthTest + target.vertexColors = material.vertexColors + target.toneMapped = material.toneMapped + if (src.normalScale instanceof THREE.Vector2) target.normalScale.copy(src.normalScale) + if (typeof src.aoMapIntensity === 'number') target.aoMapIntensity = src.aoMapIntensity + if (typeof src.displacementScale === 'number') target.displacementScale = src.displacementScale + + for (const slot of STANDARD_MAP_SLOTS) { + const texture = src[slot] + if (texture instanceof THREE.Texture) { + ;(target as unknown as Record)[slot] = texture + } + } + + cache.set(material, target) + return target +} + +// --- Animation clip baking ---------------------------------------------- + +function bakeAnimationClips( + cloneByOriginal: Map, + nodes: Record, +): { clips: THREE.AnimationClip[]; clipNamesByNode: Map } { + const clips: THREE.AnimationClip[] = [] + const clipNamesByNode = new Map() + + for (const [id, original] of sceneRegistry.nodes) { + const node = nodes[id] + const target = cloneByOriginal.get(original) + if (!node || !target) continue + + const clip = + node.type === 'door' + ? bakeDoorClip(id, node, target) + : node.type === 'window' + ? bakeWindowClip(id, node as WindowNode, target) + : null + + if (clip) { + clips.push(clip) + clipNamesByNode.set(id, [clip.name]) + } + } + + return { clips, clipNamesByNode } +} + +/** + * Bake a swing door's open motion. Each marked leaf is rotated from closed + * (rest pose) to its fully-open angle and emitted as a 1-second quaternion + * track; the leaf is left at the closed pose so the GLB's rest state is shut. + */ +function bakeDoorClip( + id: string, + node: AnyNode, + doorObject: THREE.Object3D, +): THREE.AnimationClip | null { + const tracks: THREE.KeyframeTrack[] = [] + + doorObject.traverse((object) => { + const marker = object.userData.pascalSwingLeaf as SwingLeafMarker | undefined + if (!marker || marker.axis !== 'y') return + + object.rotation.y = 0 + const closed = object.quaternion.clone() + object.rotation.y = marker.openRotationY + const open = object.quaternion.clone() + object.rotation.y = 0 + + tracks.push( + new THREE.QuaternionKeyframeTrack( + `${object.uuid}.quaternion`, + [0, 1], + [...closed.toArray(), ...open.toArray()], + ), + ) + }) + + if (tracks.length === 0) return null + return openClip(id, node, tracks) +} + +/** + * Wrap an open motion in a named 1-second clip. The name uses the node's label + * when set (e.g. "Door 1: open") so a glTF player lists readable clips, falling + * back to the id. glTF has no core loop flag — the player decides — so we stamp + * `extras.loop = false` (via the clip's userData, which `GLTFExporter` + * serialises onto the animation): Pascal's `/viewer` and any extras-aware + * consumer play it once and hold the open pose; a dumb glTF player still loops. + * Consumers map a clip back to its node by walking up from a channel's target to + * the nearest ancestor carrying `extras.pascalId`, so the name stays cosmetic. + */ +function openClip(id: string, node: AnyNode, tracks: THREE.KeyframeTrack[]): THREE.AnimationClip { + const clip = new THREE.AnimationClip(`${node.name ?? id}: open`, 1, tracks) + clip.userData = { loop: false } + return clip +} + +/** + * Bake a window's open motion generically: snapshot every part's pose closed, + * pose the subtree open, and emit a track for whichever parts actually moved + * (translation for sliding/hung sashes, rotation for casement/awning/louvre). + * Reusing the live `poseWindowMovingParts` keeps one source of truth for window + * kinematics. The subtree is left posed closed as the GLB's rest state. + */ +function bakeWindowClip( + id: string, + node: WindowNode, + windowObject: THREE.Object3D, +): THREE.AnimationClip | null { + poseWindowMovingParts(node, windowObject, 0) + + const closedPoses = new Map< + THREE.Object3D, + { position: THREE.Vector3; quaternion: THREE.Quaternion } + >() + windowObject.traverse((object) => { + closedPoses.set(object, { + position: object.position.clone(), + quaternion: object.quaternion.clone(), + }) + }) + + if (!poseWindowMovingParts(node, windowObject, 1)) return null + + const tracks: THREE.KeyframeTrack[] = [] + windowObject.traverse((object) => { + const closed = closedPoses.get(object) + if (!closed) return + + if (object.position.distanceToSquared(closed.position) > POSE_EPSILON) { + tracks.push( + new THREE.VectorKeyframeTrack( + `${object.uuid}.position`, + [0, 1], + [...closed.position.toArray(), ...object.position.toArray()], + ), + ) + } + if (closed.quaternion.angleTo(object.quaternion) > POSE_EPSILON) { + tracks.push( + new THREE.QuaternionKeyframeTrack( + `${object.uuid}.quaternion`, + [0, 1], + [...closed.quaternion.toArray(), ...object.quaternion.toArray()], + ), + ) + } + }) + + poseWindowMovingParts(node, windowObject, 0) + + if (tracks.length === 0) return null + return openClip(id, node, tracks) +} + +// --- Identity stamping --------------------------------------------------- + +/** + * Replace every clone's userData with `{}`, then stamp identity onto the nodes + * that `sceneRegistry` tracks. Wiping first guarantees no editor/runtime marker + * (e.g. `pascalSwingLeaf`, cached-material flags) leaks into glTF extras — the + * file describes itself with exactly the fields a consumer needs. + */ +function stampIdentity( + scene: THREE.Object3D, + cloneByOriginal: Map, + nodes: Record, + clipNamesByNode: Map, +) { + scene.traverse((object) => { + object.userData = {} + }) + + for (const [id, original] of sceneRegistry.nodes) { + const node = nodes[id] + const target = cloneByOriginal.get(original) + if (!node || !target) continue + + target.name = id + const extras: Record = { pascalId: id, kind: node.type } + if (node.name) extras.label = node.name + if (node.type === 'door' || node.type === 'window') { + extras.openable = true + const clipNames = clipNamesByNode.get(id) + if (clipNames) extras.clips = clipNames + } + target.userData = extras + } +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 562fc73ec..06aaa6ddd 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -157,6 +157,9 @@ export { getVisibleWallMaterials } from './systems/wall/wall-materials' // 800+ lines of CSG / mitering logic during Phase 3. These exports are // removed in Phase 6 when the legacy mount points are deleted. export { WallSystem } from './systems/wall/wall-system' -export { WindowAnimationSystem } from './systems/window/window-animation-system' +export { + poseWindowMovingParts, + WindowAnimationSystem, +} from './systems/window/window-animation-system' export { buildWindowPreviewMesh, WindowSystem } from './systems/window/window-system' export { ZoneSystem } from './systems/zone/zone-system' diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 047639910..b059989c6 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1092,6 +1092,7 @@ function addDoorLeaf( hingeX, hingeSide, swingRotation, + openRotationY, segments, contentPadding, handle, @@ -1117,6 +1118,10 @@ function addDoorLeaf( hingeX: number hingeSide: 'left' | 'right' swingRotation: number + // Leaf rotation (radians, about the hinge Y axis) at fully-open. The GLB + // exporter reads this off the leaf group to bake an open/close clip; it is + // the kinematic endpoint, independent of the current `swingRotation`. + openRotationY: number segments: DoorNode['segments'] contentPadding: DoorNode['contentPadding'] handle: boolean @@ -1147,6 +1152,10 @@ function addDoorLeaf( const leafGroup = new THREE.Group() leafGroup.position.set(hingeX, 0, 0) leafGroup.rotation.y = swingRotation + // Marks this group as the swing leaf and records its fully-open angle so the + // GLB exporter can bake an open/close animation clip from a single pose. The + // exporter strips this marker before writing the file. + leafGroup.userData.pascalSwingLeaf = { axis: 'y', openRotationY } mesh.add(leafGroup) const addLeafBox = ( @@ -2461,6 +2470,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { hingeX: -insideWidth / 2, hingeSide: 'left', swingRotation: -clampedSwingAngle * swingDirectionSign, + openRotationY: (-Math.PI / 2) * swingDirectionSign, segments, contentPadding, handle, @@ -2489,6 +2499,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { hingeX: insideWidth / 2, hingeSide: 'right', swingRotation: clampedSwingAngle * swingDirectionSign, + openRotationY: (Math.PI / 2) * swingDirectionSign, segments, contentPadding, handle, @@ -2520,6 +2531,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { hingeX, hingeSide: hingesSide, swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign, + openRotationY: (Math.PI / 2) * swingDirectionSign * hingeDirectionSign, segments, contentPadding, handle, diff --git a/packages/viewer/src/systems/window/window-animation-system.tsx b/packages/viewer/src/systems/window/window-animation-system.tsx index bf6dbfd90..f5833c8c4 100644 --- a/packages/viewer/src/systems/window/window-animation-system.tsx +++ b/packages/viewer/src/systems/window/window-animation-system.tsx @@ -7,6 +7,7 @@ import { type WindowNode, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' +import type { Object3D } from 'three' import { AWNING_WINDOW_SASH_NAME, CASEMENT_WINDOW_SASH_NAME, @@ -28,12 +29,20 @@ function markWindowDirty(windowId: AnyNodeId) { scene.dirtyNodes.add(windowId) } -function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) { - const node = useScene.getState().nodes[windowId] - if (node?.type !== 'window') return false - - const mesh = sceneRegistry.nodes.get(windowId) - +/** + * Pose a window's moving parts (sash/panel/slats) at `value` (0 = closed, + * 1 = open) by mutating the named child groups under `mesh`. Returns true when + * the window type has a direct pose path and the named parts were found. + * + * This is the single source of truth for window kinematics: the live animation + * system poses the registered scene mesh, and the GLB exporter poses an export + * clone to sample the open/close keyframes for a baked animation clip. + */ +export function poseWindowMovingParts( + node: WindowNode, + mesh: Object3D | undefined, + value: number, +): boolean { if (node.windowType === 'sliding') { const activePanel = mesh?.getObjectByName(SLIDING_WINDOW_ACTIVE_PANEL_NAME) if (!activePanel) return false @@ -120,6 +129,12 @@ function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) { return false } +function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) { + const node = useScene.getState().nodes[windowId] + if (node?.type !== 'window') return false + return poseWindowMovingParts(node, sceneRegistry.nodes.get(windowId), value) +} + export const WindowAnimationSystem = () => { useFrame(({ clock }) => { const interactive = useInteractive.getState() From 0a7cbd2081b4bc324853b01ed15e9987e968709c Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 23 Jun 2026 09:14:39 -0400 Subject: [PATCH 02/16] 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 b6f616bd8..b7d7c988c 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 b312314d0..280bca7cf 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 000000000..5dd8d28b3 --- /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 68b4041ba..801b11742 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 06aaa6ddd..f5b9960cb 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, From 064be98d7f5e40c5eb5720334df8c3c2ef449dac Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 23 Jun 2026 09:52:14 -0400 Subject: [PATCH 03/16] feat(export): reusable exportSceneToGlb + BakeExporter for headless bake Extract the GLB build (prepare + GLTFExporter + WebGPUTextureUtils) out of ExportManager into exportSceneToGlb so it can run outside the editor download flow. Add a BakeExporter R3F component that fires the export once a host viewer signals scene-ready and hands back the ArrayBuffer. Both exported for the headless bake route (baked-glb-export phase 3, headless de-risk). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/editor/bake-exporter.tsx | 35 +++++++++++++++++ .../src/components/editor/export-manager.tsx | 36 +++++------------- packages/editor/src/index.tsx | 2 + packages/editor/src/lib/glb-export.ts | 38 +++++++++++++++++++ 4 files changed, 84 insertions(+), 27 deletions(-) create mode 100644 packages/editor/src/components/editor/bake-exporter.tsx diff --git a/packages/editor/src/components/editor/bake-exporter.tsx b/packages/editor/src/components/editor/bake-exporter.tsx new file mode 100644 index 000000000..5771adee7 --- /dev/null +++ b/packages/editor/src/components/editor/bake-exporter.tsx @@ -0,0 +1,35 @@ +'use client' + +import { useScene } from '@pascal-app/core' +import { useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { exportSceneToGlb } from '../../lib/glb-export' + +export function BakeExporter({ + active, + onComplete, + onError, +}: { + active: boolean + onComplete: (buffer: ArrayBuffer) => void + onError: (message: string) => void +}) { + const scene = useThree((s) => s.scene) + const doneRef = useRef(false) + useEffect(() => { + if (!(active && !doneRef.current)) return + doneRef.current = true + const run = async () => { + try { + const sceneGroup = scene.getObjectByName('scene-renderer') + if (!sceneGroup) throw new Error('scene-renderer group not found') + const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) + onComplete(buffer) + } catch (err) { + onError(err instanceof Error ? err.message : String(err)) + } + } + void run() + }, [active, scene, onComplete, onError]) + return null +} diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index 7f31f7add..974c164f5 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -4,11 +4,9 @@ import { emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect } from 'react' -import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js' import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' -import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js' -import { prepareSceneForExport } from '../../lib/glb-export' +import { exportSceneToGlb, prepareSceneForExport } from '../../lib/glb-export' export function ExportManager() { const scene = useThree((state) => state.scene) @@ -24,6 +22,14 @@ export function ExportManager() { } const date = new Date().toISOString().split('T')[0] + + if (format === 'glb') { + const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) + const blob = new Blob([buffer], { type: 'model/gltf-binary' }) + downloadBlob(blob, `model_${date}.glb`) + return + } + // Hide editor affordances that live on the scene layer (selection handles, // ceiling/site brackets) and let wall-cutout reveal all walls — the same // synchronous capture path thumbnails use. We clone the scene inside the @@ -52,30 +58,6 @@ export function ExportManager() { downloadBlob(blob, `model_${date}.obj`) return } - - // Default: GLB export with baked identity + door/window animation clips. - const exporter = new GLTFExporter() - // Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read - // those directly. WebGPUTextureUtils blits each one to RGBA on its own - // offscreen renderer (passing the live renderer would resize/draw over the - // editor canvas), letting the exporter embed standard textures. - exporter.setTextureUtils(WebGPUTextureUtils) - - return new Promise((resolve, reject) => { - exporter.parse( - exportScene, - (gltf) => { - const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' }) - downloadBlob(blob, `model_${date}.glb`) - resolve() - }, - (error) => { - console.error('Export error:', error) - reject(error) - }, - { binary: true, animations }, - ) - }) } setExportScene(exportFn) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 1ef26d771..39b8bbdbd 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -11,6 +11,7 @@ export { default as Editor } from './components/editor' // they're referenced throughout the editor's own internals; the public // surface uses the shorter, shell-friendly names from the unified // preset-system spec. +export { BakeExporter } from './components/editor/bake-exporter' export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu' // Embed surface — the editor's real in-canvas affordances, so a host can mount // authentic selection handles, interactive build tools, and the mover on top @@ -217,6 +218,7 @@ export { resolveCeilingPlanPointSnap, } from './lib/ceiling-plan-snap' export { EDITOR_LAYER } from './lib/constants' +export { exportSceneToGlb } from './lib/glb-export' // Helper libs used by the kind-owned roof / stair / elevator panels. export { resolveCurrentBuildingId, diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index 280bca7cf..c8a8703ca 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -1,5 +1,6 @@ import { type AnyNode, + emitter, getLevelDisplayName, type LevelNode, sceneRegistry, @@ -7,7 +8,10 @@ import { type ZoneNode, } from '@pascal-app/core' import { poseWindowMovingParts, SCENE_LAYER } from '@pascal-app/viewer' +import type { Object3D } from 'three' import * as THREE from 'three' +import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' +import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js' /** * Two TRS samples (closed vs open) differing by less than this are treated as @@ -27,6 +31,40 @@ export type GlbExport = { animations: THREE.AnimationClip[] } +export async function exportSceneToGlb( + sceneGroup: Object3D, + nodes: Record, +): Promise { + emitter.emit('thumbnail:before-capture', undefined) + let prepared: ReturnType + try { + prepared = prepareSceneForExport(sceneGroup, nodes) + } finally { + emitter.emit('thumbnail:after-capture', undefined) + } + const { scene: exportScene, animations } = prepared + + const exporter = new GLTFExporter() + // Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read + // those directly. WebGPUTextureUtils blits each one to RGBA on its own + // offscreen renderer (passing the live renderer would resize/draw over the + // editor canvas), letting the exporter embed standard textures. + exporter.setTextureUtils(WebGPUTextureUtils) + + return new Promise((resolve, reject) => { + exporter.parse( + exportScene, + (gltf) => { + resolve(gltf as ArrayBuffer) + }, + (error) => { + reject(error) + }, + { binary: true, animations }, + ) + }) +} + /** * Build an engine-agnostic export tree from the live scene graph. The result is * a standalone three.js scene plus glTF animation clips, ready for From 0469b256391d834e804e37d31c444cb036de643b Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 23 Jun 2026 13:10:01 -0400 Subject: [PATCH 04/16] feat(viewer): distance-based LOD switching in GLB scene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GlbScene accepts an optional lodUrls list and swaps rendered detail by camera distance to the building's world-space center: lod0 (interactive — identity, selection, zones, clips all bind here) for normal + building-view distances, simpler visual-only levels only past ~2.5R / ~8R. Single-URL callers are unchanged. useGLTFKTX2 now accepts a URL array. Manual visibility toggling rather than THREE.LOD: THREE.LOD measures distance to its own origin (0,0,0), which mis-selected levels (blanking the scene) when the baked geometry is offset from the origin. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/viewer/glb-scene.tsx | 54 +++++++++++++++++-- packages/viewer/src/hooks/use-gltf-ktx2.tsx | 12 +++-- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 5dd8d28b3..415df5e8e 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -13,6 +13,7 @@ import useViewer from '../../store/use-viewer' /** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */ const EXPLODED_GAP = 5 +const LOD_FALLBACK_RADIUS = 10 /** A building floor discovered in the baked GLB, ordered bottom-to-top. */ export type GlbLevel = { id: `level_${string}`; label: string } @@ -48,6 +49,10 @@ type PascalExtras = { /** The resolved drill target for a raycast hit, given the current selection. */ type Target = { object: THREE.Object3D; id: string; kind: string; label: string } type HitCandidate = { object: THREE.Object3D; point?: THREE.Vector3 } +type GlbLoadResult = { + scene: THREE.Group + animations: THREE.AnimationClip[] +} function findIdentityAncestor(object: THREE.Object3D): THREE.Object3D | null { let current: THREE.Object3D | null = object @@ -228,19 +233,49 @@ function createZoneWallGeometry(polygon: [number, number][]): THREE.BufferGeomet */ export function GlbScene({ url, + lodUrls, onLevelsChange, onIdentityChange, onHoverChange, }: { url: string + lodUrls?: string[] onLevelsChange?: (levels: GlbLevel[]) => void onIdentityChange?: (identity: GlbIdentity) => void onHoverChange?: (hover: GlbHover) => void }) { - const gltf = useGLTFKTX2(url) as unknown as { - scene: THREE.Group - animations: THREE.AnimationClip[] - } + const urls = useMemo(() => (lodUrls && lodUrls.length > 1 ? lodUrls : [url]), [lodUrls, url]) + const loadedGltfs = useGLTFKTX2(urls) as unknown as GlbLoadResult | GlbLoadResult[] + const gltfs = Array.isArray(loadedGltfs) ? loadedGltfs : [loadedGltfs] + const gltf = gltfs[0]! + const lodScenes = useMemo(() => gltfs.map((g) => g.scene), [gltfs]) + // Distance-based LOD. We toggle scene visibility by camera distance to the + // building's world-space CENTER — `THREE.LOD` measures distance to its own + // origin (0,0,0), which mis-selects levels when the baked scene is offset + // from the origin. Interaction stays bound to lod0 only; the simpler levels + // are visual-only and appear when the camera pulls well past the building + // (so normal + building-view distances keep the interactive lod0). + const lodInfo = useMemo(() => { + if (lodScenes.length <= 1) return null + const box = new THREE.Box3().setFromObject(lodScenes[0]!) + const center = box.getCenter(new THREE.Vector3()) + const radius = box.getBoundingSphere(new THREE.Sphere()).radius + const r = Number.isFinite(radius) && radius > 0 ? radius : LOD_FALLBACK_RADIUS + return { center, dist1: r * 2.5, dist2: r * 8, maxLevel: lodScenes.length - 1 } + }, [lodScenes]) + useEffect(() => { + lodScenes.forEach((scene, i) => { + scene.visible = i === 0 + }) + }, [lodScenes]) + useFrame(({ camera }) => { + if (!lodInfo) return + const d = camera.position.distanceTo(lodInfo.center) + const level = Math.min(d >= lodInfo.dist2 ? 2 : d >= lodInfo.dist1 ? 1 : 0, lodInfo.maxLevel) + for (let i = 0; i < lodScenes.length; i++) { + lodScenes[i]!.visible = i === level + } + }) const rootRef = useRef(null!) const { actions } = useAnimations(gltf.animations, rootRef) const camera = useThree((state) => state.camera) @@ -345,7 +380,10 @@ export function GlbScene({ const built: ZoneFill[] = [] for (const entry of zoneEntries) { const shape = new THREE.Shape() - entry.polygon.forEach(([x, z], i) => (i === 0 ? shape.moveTo(x, -z) : shape.lineTo(x, -z))) + entry.polygon.forEach(([x, z], i) => { + if (i === 0) shape.moveTo(x, -z) + else shape.lineTo(x, -z) + }) shape.closePath() const floorMaterial = createZoneFloorMaterial(entry.color) @@ -668,6 +706,7 @@ export function GlbScene({ return ( + {/* lod0: the interactive level (identity/selection/zones all bind here). */} + {/* Simpler LODs are visual-only; the useFrame above toggles their + visibility by camera distance. They carry no interaction handlers. */} + {gltfs.slice(1).map((g, i) => ( + + ))} {/* 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. */} diff --git a/packages/viewer/src/hooks/use-gltf-ktx2.tsx b/packages/viewer/src/hooks/use-gltf-ktx2.tsx index 000b8ff6c..ec2f8506e 100644 --- a/packages/viewer/src/hooks/use-gltf-ktx2.tsx +++ b/packages/viewer/src/hooks/use-gltf-ktx2.tsx @@ -1,9 +1,15 @@ import { useGLTF } from '@react-three/drei' -import { useThree } from '@react-three/fiber' +import { type ObjectMap, useThree } from '@react-three/fiber' import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' +import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' import { ensureKtx2Support, ktx2Loader } from '../lib/ktx2-loader' -const useGLTFKTX2 = (path: string): ReturnType => { +type GLTFKTX2Path = string | string[] +type GLTFKTX2Result = T extends string[] + ? Array + : GLTF & ObjectMap + +const useGLTFKTX2 = (path: T): GLTFKTX2Result => { const gl = useThree((state) => state.gl) return useGLTF(path, true, true, (loader) => { @@ -12,7 +18,7 @@ const useGLTFKTX2 = (path: string): ReturnType => { loader.setKTX2Loader(ktx2Loader as any) } loader.setMeshoptDecoder(MeshoptDecoder) - }) + }) as unknown as GLTFKTX2Result } export { useGLTFKTX2 } From c8bd29e90923cff0698148b90f4ef4e36fb43098 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 23 Jun 2026 13:38:40 -0400 Subject: [PATCH 05/16] revert(viewer): drop distance-based LOD switching from GLB scene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the LOD switching (0469b256). Rendering lod1/lod2 on zoom-out produced WebGPU validation errors (invalid bindGroup_object) on real-GPU browsers — not caught earlier because headless Chromium falls back to the WebGL2 backend, so the WebGPU path was never exercised. The viewer returns to loading the baked KTX2 lod0 (still the 22MB->12MB win). LOD switching to revisit with a real-GPU test loop and on-demand loading instead of preload-all. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/viewer/glb-scene.tsx | 54 ++----------------- packages/viewer/src/hooks/use-gltf-ktx2.tsx | 12 ++--- 2 files changed, 8 insertions(+), 58 deletions(-) diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 415df5e8e..5dd8d28b3 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -13,7 +13,6 @@ import useViewer from '../../store/use-viewer' /** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */ const EXPLODED_GAP = 5 -const LOD_FALLBACK_RADIUS = 10 /** A building floor discovered in the baked GLB, ordered bottom-to-top. */ export type GlbLevel = { id: `level_${string}`; label: string } @@ -49,10 +48,6 @@ type PascalExtras = { /** The resolved drill target for a raycast hit, given the current selection. */ type Target = { object: THREE.Object3D; id: string; kind: string; label: string } type HitCandidate = { object: THREE.Object3D; point?: THREE.Vector3 } -type GlbLoadResult = { - scene: THREE.Group - animations: THREE.AnimationClip[] -} function findIdentityAncestor(object: THREE.Object3D): THREE.Object3D | null { let current: THREE.Object3D | null = object @@ -233,49 +228,19 @@ function createZoneWallGeometry(polygon: [number, number][]): THREE.BufferGeomet */ export function GlbScene({ url, - lodUrls, onLevelsChange, onIdentityChange, onHoverChange, }: { url: string - lodUrls?: string[] onLevelsChange?: (levels: GlbLevel[]) => void onIdentityChange?: (identity: GlbIdentity) => void onHoverChange?: (hover: GlbHover) => void }) { - const urls = useMemo(() => (lodUrls && lodUrls.length > 1 ? lodUrls : [url]), [lodUrls, url]) - const loadedGltfs = useGLTFKTX2(urls) as unknown as GlbLoadResult | GlbLoadResult[] - const gltfs = Array.isArray(loadedGltfs) ? loadedGltfs : [loadedGltfs] - const gltf = gltfs[0]! - const lodScenes = useMemo(() => gltfs.map((g) => g.scene), [gltfs]) - // Distance-based LOD. We toggle scene visibility by camera distance to the - // building's world-space CENTER — `THREE.LOD` measures distance to its own - // origin (0,0,0), which mis-selects levels when the baked scene is offset - // from the origin. Interaction stays bound to lod0 only; the simpler levels - // are visual-only and appear when the camera pulls well past the building - // (so normal + building-view distances keep the interactive lod0). - const lodInfo = useMemo(() => { - if (lodScenes.length <= 1) return null - const box = new THREE.Box3().setFromObject(lodScenes[0]!) - const center = box.getCenter(new THREE.Vector3()) - const radius = box.getBoundingSphere(new THREE.Sphere()).radius - const r = Number.isFinite(radius) && radius > 0 ? radius : LOD_FALLBACK_RADIUS - return { center, dist1: r * 2.5, dist2: r * 8, maxLevel: lodScenes.length - 1 } - }, [lodScenes]) - useEffect(() => { - lodScenes.forEach((scene, i) => { - scene.visible = i === 0 - }) - }, [lodScenes]) - useFrame(({ camera }) => { - if (!lodInfo) return - const d = camera.position.distanceTo(lodInfo.center) - const level = Math.min(d >= lodInfo.dist2 ? 2 : d >= lodInfo.dist1 ? 1 : 0, lodInfo.maxLevel) - for (let i = 0; i < lodScenes.length; i++) { - lodScenes[i]!.visible = i === level - } - }) + const 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) @@ -380,10 +345,7 @@ export function GlbScene({ const built: ZoneFill[] = [] for (const entry of zoneEntries) { const shape = new THREE.Shape() - entry.polygon.forEach(([x, z], i) => { - if (i === 0) shape.moveTo(x, -z) - else shape.lineTo(x, -z) - }) + entry.polygon.forEach(([x, z], i) => (i === 0 ? shape.moveTo(x, -z) : shape.lineTo(x, -z))) shape.closePath() const floorMaterial = createZoneFloorMaterial(entry.color) @@ -706,7 +668,6 @@ export function GlbScene({ return ( - {/* lod0: the interactive level (identity/selection/zones all bind here). */} - {/* Simpler LODs are visual-only; the useFrame above toggles their - visibility by camera distance. They carry no interaction handlers. */} - {gltfs.slice(1).map((g, i) => ( - - ))} {/* 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. */} diff --git a/packages/viewer/src/hooks/use-gltf-ktx2.tsx b/packages/viewer/src/hooks/use-gltf-ktx2.tsx index ec2f8506e..000b8ff6c 100644 --- a/packages/viewer/src/hooks/use-gltf-ktx2.tsx +++ b/packages/viewer/src/hooks/use-gltf-ktx2.tsx @@ -1,15 +1,9 @@ import { useGLTF } from '@react-three/drei' -import { type ObjectMap, useThree } from '@react-three/fiber' +import { useThree } from '@react-three/fiber' import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' -import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' import { ensureKtx2Support, ktx2Loader } from '../lib/ktx2-loader' -type GLTFKTX2Path = string | string[] -type GLTFKTX2Result = T extends string[] - ? Array - : GLTF & ObjectMap - -const useGLTFKTX2 = (path: T): GLTFKTX2Result => { +const useGLTFKTX2 = (path: string): ReturnType => { const gl = useThree((state) => state.gl) return useGLTF(path, true, true, (loader) => { @@ -18,7 +12,7 @@ const useGLTFKTX2 = (path: T): GLTFKTX2Result => { loader.setKTX2Loader(ktx2Loader as any) } loader.setMeshoptDecoder(MeshoptDecoder) - }) as unknown as GLTFKTX2Result + }) } export { useGLTFKTX2 } From 0629c7d3e375355c1dc1e170060c99d8c605d50d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 23 Jun 2026 14:06:20 -0400 Subject: [PATCH 06/16] fix(export): snap levels to stacked positions before GLB export exportSceneToGlb now calls snapLevelsToTruePositions() (the same clean stacked presentation thumbnail capture uses) before snapshotting the scene, so the GLB always reflects the stacked building regardless of the live levelMode (exploded/solo) or an unsettled level lerp. Fixes baked GLBs where a level was captured at a stray offset (e.g. ~ -100k Y), which inflated the bounding box and made the model unframable / invisible in third-party glTF viewers. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/editor/src/lib/glb-export.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index c8a8703ca..055afe469 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -7,7 +7,7 @@ import { type WindowNode, type ZoneNode, } from '@pascal-app/core' -import { poseWindowMovingParts, SCENE_LAYER } from '@pascal-app/viewer' +import { poseWindowMovingParts, SCENE_LAYER, snapLevelsToTruePositions } from '@pascal-app/viewer' import type { Object3D } from 'three' import * as THREE from 'three' import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' @@ -36,10 +36,16 @@ export async function exportSceneToGlb( nodes: Record, ): Promise { emitter.emit('thumbnail:before-capture', undefined) + // Snap levels to their true stacked positions (like thumbnail capture) so the + // export always reflects the clean stacked building, regardless of the live + // levelMode (exploded/solo) or an unsettled level lerp that could otherwise + // bake a level at a stray offset. + const restoreLevels = snapLevelsToTruePositions() let prepared: ReturnType try { prepared = prepareSceneForExport(sceneGroup, nodes) } finally { + restoreLevels() emitter.emit('thumbnail:after-capture', undefined) } const { scene: exportScene, animations } = prepared From 6cfc6ae01bd11d08c7feb4531a89ca32de06656e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 23 Jun 2026 15:44:36 -0400 Subject: [PATCH 07/16] fix(export): stamp openable only when an open clip bakes A door/window only carries extras.openable + extras.clips when an open animation actually bakes. Cased openings (no leaf) and fixed windows (no operable sash) are no longer mislabelled openable, so the GLB never claims a part opens when nothing moves. /viewer is unaffected (it already required openable && clips). Adds a regression test for the no-clip case. (Export barrels reordered by the formatter.) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/editor/src/index.tsx | 2 +- packages/editor/src/lib/glb-export.test.ts | 32 ++++++++++++++++++++++ packages/editor/src/lib/glb-export.ts | 9 ++++-- packages/viewer/src/index.ts | 2 +- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 39b8bbdbd..0369928c6 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -218,7 +218,6 @@ export { resolveCeilingPlanPointSnap, } from './lib/ceiling-plan-snap' export { EDITOR_LAYER } from './lib/constants' -export { exportSceneToGlb } from './lib/glb-export' // Helper libs used by the kind-owned roof / stair / elevator panels. export { resolveCurrentBuildingId, @@ -247,6 +246,7 @@ export { getFloorplanWallThickness, } from './lib/floorplan' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' +export { exportSceneToGlb } from './lib/glb-export' export { buildResetSurfaceMaterialUpdates, buildRoofSurfaceMaterialPatch, diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index b7d7c988c..74b24f70e 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -146,6 +146,38 @@ describe('prepareSceneForExport', () => { expect(leafMarkerSurvived).toBe(false) }) + test('does not flag a door/window openable when no open clip bakes', () => { + // A cased opening (no swing leaf) / fixed window (no operable sash) builds + // no movable part, so no clip bakes and the node must not claim openable. + const root = new THREE.Group() + const openingGroup = new THREE.Group() + openingGroup.add(meshWithNodeMaterial(nodeMaterial())) + root.add(openingGroup) + + const openingId = 'door_opening' + sceneRegistry.nodes.set(openingId, openingGroup) + const nodes: Record = { + [openingId]: { + object: 'node', + id: openingId, + type: 'door', + name: 'Cased opening', + } as unknown as AnyNode, + } + + const { scene, animations } = prepareSceneForExport(root, nodes) + + expect(animations).toHaveLength(0) + const exported = scene.getObjectByProperty('name', openingId) + expect(exported?.userData).toEqual({ + pascalId: openingId, + kind: 'door', + label: 'Cased opening', + }) + expect(exported?.userData.openable).toBeUndefined() + expect(exported?.userData.clips).toBeUndefined() + }) + test('keeps the zone identity node with its polygon and strips the fill mesh', () => { const root = new THREE.Group() const zoneGroup = new THREE.Group() diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index 055afe469..e0a731a32 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -497,10 +497,15 @@ function stampIdentity( extras.label = getLevelDisplayName(node as LevelNode) target.visible = true } + // Only doors/windows that actually baked an open clip are openable. A cased + // opening (no leaf) or a fixed window (no operable sash) produces no clip, so + // it stays unflagged — the file never claims a part opens when nothing moves. if (node.type === 'door' || node.type === 'window') { - extras.openable = true const clipNames = clipNamesByNode.get(id) - if (clipNames) extras.clips = clipNames + if (clipNames?.length) { + extras.openable = true + extras.clips = clipNames + } } if (node.type === 'zone') { // Zone fills are stripped from the bake; /viewer rebuilds the room from diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index f5b9960cb..f9b1eebf4 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -14,10 +14,10 @@ export { ErrorBoundary } from './components/error-boundary' export { NodeRenderer } from './components/renderers/node-renderer' export { default as Viewer, type ViewerHandle } from './components/viewer' export { - GlbScene, type GlbHover, type GlbIdentity, type GlbLevel, + GlbScene, } from './components/viewer/glb-scene' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { From 5aedc366a68879cb0277fcaf9b4534ac0fb4f7b3 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 24 Jun 2026 15:50:28 -0400 Subject: [PATCH 08/16] feat(viewer): GLB walkthrough in viewer, monochrome, spawn/export polish Move the first-person walkthrough into @pascal-app/viewer (BVHEcctrl + GlbWalkthroughController) and round out the GLB-consuming viewer: - Walkthrough: fallback ground only on level 0 (upper floors rely on baked slabs), hidden spawn marker, auto pointer-lock on enter, single-Esc exit via pointerlockchange, force perspective on enter / restore on exit. - GlbScene: monochrome strips baked textures and recolours meshes by surface role using the active theme's clay tints; spawn node hidden from render. - glb-export: camera/label/spawn identity extras for the viewer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/first-person-controls.tsx | 3 +- packages/editor/src/lib/glb-export.ts | 48 ++- .../src/components/viewer}/bvh-ecctrl.tsx | 3 +- .../src/components/viewer/glb-scene.tsx | 311 +++++++++++++- .../viewer/glb-walkthrough-controller.tsx | 404 ++++++++++++++++++ packages/viewer/src/index.ts | 7 + 6 files changed, 755 insertions(+), 21 deletions(-) rename packages/{editor/src/components/editor/first-person => viewer/src/components/viewer}/bvh-ecctrl.tsx (99%) create mode 100644 packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 980ae4205..38e6f33b1 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -45,6 +45,7 @@ import { } from 'three' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import '../../three-types' +import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer' import { closeDoorOpenState, DOOR_SWING_OPEN_ANGLE, @@ -64,8 +65,6 @@ import { type FirstPersonColliderWorld, type FirstPersonSpawn, } from './first-person/build-collider-world' -import type { BVHEcctrlApi, MovementInput } from './first-person/bvh-ecctrl' -import BVHEcctrl from './first-person/bvh-ecctrl' const CAMERA_EYE_OFFSET = 0.45 const LOOK_SENSITIVITY = 0.002 diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index e0a731a32..29ef52ce4 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -470,6 +470,39 @@ function bakeWindowClip( * (e.g. `pascalSwingLeaf`, cached-material flags) leaks into glTF extras — the * file describes itself with exactly the fields a consumer needs. */ +/** + * Human-readable label for a baked node, mirroring the viewer's `getNodeName`: + * an explicit name wins, items fall back to their catalog asset name, other + * kinds to a capitalized type. Levels override this with their display name. + */ +function nodeDisplayLabel(node: AnyNode): string { + if (node.name) return node.name + switch (node.type) { + case 'item': + return (node as { asset?: { name?: string } }).asset?.name || 'Item' + case 'wall': + return 'Wall' + case 'door': + return 'Door' + case 'window': + return 'Window' + case 'slab': + return 'Slab' + case 'ceiling': + return 'Ceiling' + case 'roof': + return 'Roof' + case 'fence': + return 'Fence' + case 'column': + return 'Column' + case 'stair': + return 'Stairs' + default: + return node.type + } +} + function stampIdentity( scene: THREE.Object3D, cloneByOriginal: Map, @@ -487,7 +520,12 @@ function stampIdentity( target.name = id const extras: Record = { pascalId: id, kind: node.type } - if (node.name) extras.label = node.name + // Stamp a human label for every node (catalog name for items, a type label + // otherwise) so the viewer breadcrumb/hover read names, not raw pascalIds. + extras.label = nodeDisplayLabel(node) + // Camera bookmarks ride on the identity node (any kind can carry one) so the + // baked viewer flies to a saved pose on selection without a side file. + if (node.camera) extras.camera = node.camera // 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 @@ -516,6 +554,14 @@ function stampIdentity( extras.color = zone.color target.visible = true } + if (node.type === 'spawn') { + // The spawn marker's visible mesh lives on a non-scene overlay layer (and + // is pruned), so this identity node is an empty transform. Keep it + force + // visible so the baked walkthrough can read its world position/yaw and + // start the player there (`extras.rotation` mirrors the node's yaw). + extras.rotation = (node as { rotation?: number }).rotation ?? 0 + target.visible = true + } target.userData = extras } } diff --git a/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx b/packages/viewer/src/components/viewer/bvh-ecctrl.tsx similarity index 99% rename from packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx rename to packages/viewer/src/components/viewer/bvh-ecctrl.tsx index 3c4596599..ef6a3c1fd 100644 --- a/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx +++ b/packages/viewer/src/components/viewer/bvh-ecctrl.tsx @@ -1,4 +1,5 @@ -import '../../../three-types' +// R3F JSX type augmentations (mesh, group, box3Helper, …) for the debug overlay. +import '@react-three/fiber' import { TransformControls, useKeyboardControls } from '@react-three/drei' import { type ThreeElements, useFrame, useThree } from '@react-three/fiber' import type { ReactNode } from 'react' diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 5dd8d28b3..6718d2358 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -1,5 +1,6 @@ 'use client' +import type { 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' @@ -9,11 +10,26 @@ 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 { createSurfaceRoleMaterial } from '../../lib/materials' import useViewer from '../../store/use-viewer' /** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */ const EXPLODED_GAP = 5 +/** Baked `kind` → surface role, so monochrome can recolor by role like the + * parametric viewer (textures-off collapses each face to its themed clay). */ +const ROLE_BY_KIND: Record = { + wall: 'wall', + slab: 'floor', + floor: 'floor', + ceiling: 'ceiling', + roof: 'roof', + 'roof-segment': 'roof', + window: 'glazing', + door: 'joinery', + item: 'furnishing', +} + /** A building floor discovered in the baked GLB, ordered bottom-to-top. */ export type GlbLevel = { id: `level_${string}`; label: string } @@ -23,6 +39,14 @@ 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 +/** Walkthrough HUD state, reported each frame: the floor/room the camera is in + * and the openable door/window directly in view (for the reticle prompt). */ +export type GlbWalkthrough = { + zoneLabel: string | null + floorLabel: string | null + door: { label: string; isOpen: boolean } | null +} | null + type GlbLevelEntry = { id: GlbLevel['id']; node: THREE.Object3D; baseY: number } type GlbZoneEntry = { id: string @@ -43,6 +67,24 @@ type PascalExtras = { clips?: string[] polygon?: [number, number][] color?: string + camera?: { position: [number, number, number]; target: [number, number, number] } +} + +/** The subset of the camera-controls instance the scene drives (drei makeDefault). */ +type LookAtControls = { + setLookAt: ( + px: number, + py: number, + pz: number, + tx: number, + ty: number, + tz: number, + enableTransition?: boolean, + ) => unknown + /** Wraps the wound-up azimuth so a transition rotates the short way, not 360°. */ + normalizeRotations?: () => unknown + /** Pans camera + target together (keeps angle + distance) to re-center a point. */ + moveTo?: (x: number, y: number, z: number, enableTransition?: boolean) => unknown } /** The resolved drill target for a raycast hit, given the current selection. */ @@ -76,6 +118,15 @@ const _up = new THREE.Vector3(0, 1, 0) const _bounds = new THREE.Box3() const _boundsCenter = new THREE.Vector3() const _sample = new THREE.Vector3() +const _camBox = new THREE.Box3() +const _camCenter = new THREE.Vector3() +const _camSize = new THREE.Vector3() +const _camPoint = new THREE.Vector3() +const _walkPos = new THREE.Vector3() +const _reticleNdc = new THREE.Vector2(0, 0) +const _reticleRaycaster = new THREE.Raycaster() +/** How far ahead (metres) a door/window counts as "in view" for activation. */ +const WALK_REACH = 3 const ZONE_FOOTPRINT_EPSILON = 0.05 const NO_RAYCAST: THREE.Mesh['raycast'] = () => {} @@ -231,11 +282,13 @@ export function GlbScene({ onLevelsChange, onIdentityChange, onHoverChange, + onWalkthroughChange, }: { url: string onLevelsChange?: (levels: GlbLevel[]) => void onIdentityChange?: (identity: GlbIdentity) => void onHoverChange?: (hover: GlbHover) => void + onWalkthroughChange?: (state: GlbWalkthrough) => void }) { const gltf = useGLTFKTX2(url) as unknown as { scene: THREE.Group @@ -245,21 +298,57 @@ export function GlbScene({ const { actions } = useAnimations(gltf.animations, rootRef) const camera = useThree((state) => state.camera) const raycaster = useThree((state) => state.raycaster) + const controls = useThree((state) => state.controls) as LookAtControls | null + const walkthroughMode = useViewer((s) => s.walkthroughMode) + const textures = useViewer((s) => s.textures) + const sceneTheme = useViewer((s) => s.sceneTheme) + + // Monochrome: strip the baked textures and recolor every building mesh with a + // flat themed-clay material by surface role — mirrors the parametric viewer's + // textures-off path. The original baked material is stashed on the mesh + // (`userData.__bakedMaterial`) so it survives the cached GLTF across remounts. + useEffect(() => { + gltf.scene.traverse((object) => { + const role = ROLE_BY_KIND[(object.userData as PascalExtras).kind ?? ''] + if (!role) return + object.traverse((child) => { + const mesh = child as THREE.Mesh + if (!mesh.isMesh || mesh.layers.isEnabled(ZONE_LAYER)) return + const ud = mesh.userData as { __bakedMaterial?: THREE.Material | THREE.Material[] } + if (!ud.__bakedMaterial) ud.__bakedMaterial = mesh.material + mesh.material = textures + ? ud.__bakedMaterial + : createSurfaceRoleMaterial(role, 'clay', THREE.DoubleSide, sceneTheme) + }) + }) + }, [gltf.scene, textures, sceneTheme]) // 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 { levels, identity, zoneEntries, occluders, rootNode, levelsWithZones } = 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[] = [] + // The building (or site) node anchors the building-view camera bookmark/fit. + let buildingNode: THREE.Object3D | null = null + let siteNode: THREE.Object3D | null = null gltf.scene.traverse((object) => { const extras = object.userData as PascalExtras + // The spawn marker is an authoring-only node (walkthrough start pose); it + // should never render in the viewer. Its transform still feeds the + // walkthrough controller — visibility doesn't affect that. + if (extras.kind === 'spawn') { + object.visible = false + return + } if (!extras.pascalId) return objects.set(extras.pascalId, object) + if (extras.kind === 'building') buildingNode = object + else if (extras.kind === 'site') siteNode = object if (extras.kind === 'ceiling' || extras.kind === 'roof') occluderNodes.push(object) if (extras.kind === 'level') { floors.push({ @@ -286,10 +375,106 @@ export function GlbScene({ } }) floors.sort((a, b) => a.baseY - b.baseY) - return { levels: floors, identity: objects, zoneEntries: zoneList, occluders: occluderNodes } + return { + levels: floors, + identity: objects, + zoneEntries: zoneList, + occluders: occluderNodes, + rootNode: (buildingNode ?? siteNode) as THREE.Object3D | null, + // Levels that have rooms — only these trigger the dollhouse occluder strip. + levelsWithZones: new Set(zoneList.map((zone) => zone.levelId)), + } }, [gltf.scene]) const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries]) + // Move the camera to match the drill depth: a saved bookmark (extras.camera) + // wins; otherwise fit to the target's bounds (the object, the room's polygon + // footprint for empty zone nodes, the level, or the whole building). Mirrors + // the parametric viewer's selection framing so the GLB path feels identical. + const focusLevelId = useViewer((s) => s.selection.levelId) + const focusZoneId = useViewer((s) => s.selection.zoneId) + const focusSelectedId = useViewer((s) => s.selection.selectedIds[0] ?? null) + useEffect(() => { + if (!controls) return + const flyToBookmark = (bookmark: NonNullable) => { + const { position: p, target: t } = bookmark + controls.setLookAt(p[0], p[1], p[2], t[0], t[1], t[2], true) + controls.normalizeRotations?.() + } + + // Item selection happens inside a room, where we're already at a good angle: + // fly to the item's own bookmark if it has one, otherwise just pan to it + // (keep the current orbit angle + distance) rather than reframing the camera. + if (focusSelectedId) { + const object = identity.get(focusSelectedId) + if (!object) return + const itemBookmark = (object.userData as PascalExtras).camera + if (itemBookmark) { + flyToBookmark(itemBookmark) + return + } + _camBox.makeEmpty() + _camBox.setFromObject(object) + if (_camBox.isEmpty()) return + _camBox.getCenter(_camCenter) + controls.moveTo?.(_camCenter.x, _camCenter.y, _camCenter.z, true) + return + } + + let bookmarkNode: THREE.Object3D | null = null + _camBox.makeEmpty() + if (focusZoneId) { + const zone = zoneById.get(focusZoneId) + if (!zone) return + bookmarkNode = zone.node + // Zone identity nodes carry no mesh — bound the room from its polygon. + zone.node.updateWorldMatrix(true, false) + for (const [x, z] of zone.polygon) { + _camBox.expandByPoint(_camPoint.set(x, 0, z).applyMatrix4(zone.node.matrixWorld)) + _camBox.expandByPoint( + _camPoint.set(x, ZONE_WALL_HEIGHT, z).applyMatrix4(zone.node.matrixWorld), + ) + } + } else if (focusLevelId) { + const object = identity.get(focusLevelId) + if (!object) return + bookmarkNode = object + _camBox.setFromObject(object) + } else { + bookmarkNode = rootNode + _camBox.setFromObject(gltf.scene) + } + + const bookmark = (bookmarkNode?.userData as PascalExtras | undefined)?.camera + if (bookmark) { + flyToBookmark(bookmark) + return + } + if (_camBox.isEmpty()) return + _camBox.getCenter(_camCenter) + _camBox.getSize(_camSize) + const distance = Math.max(Math.max(_camSize.x, _camSize.y, _camSize.z) * 2, 15) + controls.setLookAt( + _camCenter.x + distance * 0.7, + _camCenter.y + distance * 0.5, + _camCenter.z + distance * 0.7, + _camCenter.x, + _camCenter.y, + _camCenter.z, + true, + ) + controls.normalizeRotations?.() + }, [ + controls, + focusSelectedId, + focusZoneId, + focusLevelId, + identity, + zoneById, + rootNode, + gltf.scene, + ]) + useEffect(() => { const cameraMask = camera.layers.mask const raycasterMask = raycaster.layers.mask @@ -317,15 +502,22 @@ export function GlbScene({ } }, [levels, identity, onLevelsChange, onIdentityChange]) - // Apply the editor's level modes to the baked floors each frame. + // Apply the editor's level modes to the baked floors each frame. Walkthrough + // always shows the full stacked building (you're standing inside it) — and the + // first-person collider is built from the visible meshes, so a hidden solo + // floor would otherwise drop the player through the world. useFrame((_, delta) => { if (levels.length === 0) return - const { levelMode, selection } = useViewer.getState() + const { levelMode, selection, walkthroughMode } = 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 + const exploded = !walkthroughMode && levelMode === 'exploded' + const targetY = baseY + (exploded ? index * EXPLODED_GAP : 0) + // Snap (not lerp) in walkthrough so the first-person collider, built from + // these world positions, matches the stacked building immediately. + node.position.y = walkthroughMode ? targetY : lerp(node.position.y, targetY, delta * 12) + node.visible = + walkthroughMode || levelMode !== 'solo' || !selectedLevel || id === selectedLevel }) }, 5) @@ -520,17 +712,25 @@ export function GlbScene({ // selection + local hover. const hoveredTarget = useRef(null) useFrame((_, delta) => { - const { selection, outliner } = useViewer.getState() + const state = useViewer.getState() + const { selection, outliner } = state 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 + // Walkthrough is a first-person tour: no zone tints, no dollhouse cutaway, + // no selection outline — you're standing inside the real building. + const walk = state.walkthroughMode + + // Dollhouse: hide ceilings + roof so the rooms (and their zone tint) are + // visible from above and the ray reaches their contents — but only when the + // focused level actually has rooms. Focusing a zone-less floor keeps the + // building intact (otherwise its roof would just vanish with nothing to show). + const revealing = !walk && selection.levelId != null && levelsWithZones.has(selection.levelId) + for (const occluder of occluders) occluder.visible = !revealing for (const { id, levelId, meshes, uniforms } of zoneFills.current) { - const show = selection.levelId != null && levelId === selection.levelId && !selection.zoneId + const show = + !walk && 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) { @@ -554,23 +754,96 @@ export function GlbScene({ } } + outliner.selectedObjects.length = 0 + outliner.hoveredObjects.length = 0 + if (walk) return + 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) } }) + // ── Walkthrough: first-person HUD + door/window interaction ──────────────── + const walkDoorRef = useRef(null) + const lastWalkKey = useRef(null) + + // Each frame in walkthrough, report the floor + room the camera stands in and + // the openable directly ahead (a forward ray from screen centre) so the host + // can draw the reticle prompt. Fires the callback only when the state changes. + useFrame(() => { + if (!walkthroughMode) return + camera.getWorldPosition(_walkPos) + + let floor: GlbLevelEntry | null = levels[0] ?? null + for (const level of levels) { + if (_walkPos.y >= level.baseY - 0.5) floor = level + else break + } + const floorLabel = floor ? ((floor.node.userData as PascalExtras).label ?? floor.id) : null + const zone = floor ? zoneAtPoint(_walkPos, floor.id) : null + + _reticleRaycaster.far = WALK_REACH + _reticleRaycaster.setFromCamera(_reticleNdc, camera) + const hit = _reticleRaycaster.intersectObject(gltf.scene, true)[0] + let doorNode: THREE.Object3D | null = null + let doorId = '' + let door: { label: string; isOpen: boolean } | null = null + if (hit) { + const node = findIdentityAncestor(hit.object) + const extras = node?.userData as PascalExtras | undefined + if (node && extras?.openable && extras.clips?.length) { + doorNode = node + doorId = extras.pascalId as string + door = { label: extras.label ?? 'Door', isOpen: openIds.current.has(doorId) } + } + } + walkDoorRef.current = doorNode + + const key = `${floor?.id ?? ''}|${zone?.id ?? ''}|${door ? `${doorId}:${door.isOpen}` : ''}` + if (key !== lastWalkKey.current) { + lastWalkKey.current = key + onWalkthroughChange?.({ zoneLabel: zone?.label ?? null, floorLabel, door }) + } + }) + + // E or click activates the openable in view. The click also re-locks the + // pointer via WalkthroughControls — harmless overlap; no selection happens. + const activateWalkDoor = useCallback(() => { + if (walkDoorRef.current) toggleOpenable(walkDoorRef.current) + }, [toggleOpenable]) + useEffect(() => { + if (!walkthroughMode) return + const onKey = (event: KeyboardEvent) => { + if (event.key.toLowerCase() === 'e') activateWalkDoor() + } + const canvas = document.querySelector('canvas') + window.addEventListener('keydown', onKey) + canvas?.addEventListener('click', activateWalkDoor) + return () => { + window.removeEventListener('keydown', onKey) + canvas?.removeEventListener('click', activateWalkDoor) + } + }, [walkthroughMode, activateWalkDoor]) + + // Clear the HUD (and stale targeting) whenever walkthrough turns off. + useEffect(() => { + if (walkthroughMode) return + walkDoorRef.current = null + lastWalkKey.current = null + onWalkthroughChange?.(null) + }, [walkthroughMode, onWalkthroughChange]) + const lastHover = useRef(null) const handlePointerMove = useCallback( (event: ThreeEvent) => { event.stopPropagation() + if (walkthroughMode) return const target = resolveTarget( event.intersections.map((hit) => ({ object: hit.object, point: hit.point })), event.ray, @@ -583,7 +856,7 @@ export function GlbScene({ onHoverChange?.(target ? { kind: target.kind, label: target.label } : null) } }, - [resolveTarget, onHoverChange], + [resolveTarget, onHoverChange, walkthroughMode], ) const handlePointerOut = useCallback(() => { @@ -601,6 +874,9 @@ export function GlbScene({ const handleClick = useCallback( (event: ThreeEvent) => { event.stopPropagation() + // Walkthrough handles its own door activation (E / canvas click) and never + // selects — leave the drill hierarchy untouched. + if (walkthroughMode) return const target = resolveTarget( event.intersections.map((hit) => ({ object: hit.object, point: hit.point })), event.ray, @@ -637,12 +913,13 @@ export function GlbScene({ setSelection({ zoneId: null }) } }, - [resolveTarget, toggleOpenable], + [resolveTarget, toggleOpenable, walkthroughMode], ) // A click that hits nothing (empty space) steps one level back up the drill // hierarchy, like the legacy viewer. const handlePointerMissed = useCallback(() => { + if (useViewer.getState().walkthroughMode) return const { selection, setSelection, setLevelMode } = useViewer.getState() if (selection.selectedIds.length > 0) { setSelection({ selectedIds: [] }) diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx new file mode 100644 index 000000000..f235f773f --- /dev/null +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -0,0 +1,404 @@ +'use client' + +import { KeyboardControls } from '@react-three/drei' +import { useFrame, useThree } from '@react-three/fiber' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + Box3, + BoxGeometry, + type BufferAttribute, + BufferGeometry, + Euler, + Float32BufferAttribute, + type InterleavedBufferAttribute, + Matrix4, + Mesh, + MeshBasicMaterial, + type Object3D, + Quaternion, + Vector3, +} from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' +import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' +import { SCENE_LAYER } from '../../lib/layers' +import useViewer from '../../store/use-viewer' +import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl' + +// Eye/capsule geometry mirrors the editor's first-person controller so the +// baked walkthrough feels identical. The capsule centre sits below the eye; the +// camera rides the capsule with a small offset and the controller floats it to +// the ground. +const CAMERA_EYE_OFFSET = 0.45 +const CONTROLLER_CENTER_FROM_EYE = 0.85 +const SPAWN_EYE_HEIGHT = 1.65 +const LOOK_SENSITIVITY = 0.002 +const VOID_FALL_RESPAWN_DEPTH = 12 + +// Kinds that must not block the player: room helpers, the spawn marker, the +// ceiling/roof shell (you walk under them), and door/window leaves — excluding +// the latter lets you pass any doorway whether the leaf is open or shut (the +// wall already has the opening cut into its baked geometry). +const COLLIDER_EXCLUDED_KINDS = new Set(['zone', 'spawn', 'ceiling', 'roof', 'door', 'window']) + +const colliderMaterial = new MeshBasicMaterial({ visible: false }) + +const keyboardMap: Array<{ name: Exclude; keys: string[] }> = [ + { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, + { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, + { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] }, + { name: 'rightward', keys: ['ArrowRight', 'KeyD'] }, + { name: 'jump', keys: ['Space'] }, + { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] }, +] + +const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) +const cameraEuler = new Euler(0, 0, 0, 'YXZ') +const spawnQuat = new Quaternion() +const spawnEuler = new Euler(0, 0, 0, 'YXZ') +const spawnPos = new Vector3() + +type GlbColliderWorld = { mesh: Mesh; minY: number; dispose: () => void } + +/** Effective visibility — an invisible ancestor hides the whole subtree. */ +function isEffectivelyVisible(object: Object3D) { + let current: Object3D | null = object + while (current) { + if (!current.visible) return false + current = current.parent + } + return true +} + +function kindOf(object: Object3D): string | undefined { + let current: Object3D | null = object + while (current) { + const kind = (current.userData as { kind?: string }).kind + if (kind) return kind + current = current.parent + } + return undefined +} + +// Coerce any position attribute (quantized/interleaved) to a plain Float32 one +// so mergeGeometries can combine geometries that don't share an array type. +function toFloat32Position(source: BufferAttribute | InterleavedBufferAttribute) { + const array = new Float32Array(source.count * 3) + for (let i = 0; i < source.count; i++) { + array[i * 3] = source.getX(i) + array[i * 3 + 1] = source.getY(i) + array[i * 3 + 2] = source.getZ(i) + } + return new Float32BufferAttribute(array, 3) +} + +const FALLBACK_THICKNESS = 0.08 +const GROUND_MIN = 2000 + +/** A thin position-only floor box whose top face sits at `topY`. */ +function boxFloorGeometry( + cx: number, + topY: number, + cz: number, + width: number, + depth: number, +): BufferGeometry { + const box = new BoxGeometry(width, FALLBACK_THICKNESS, depth).toNonIndexed() + const geometry = new BufferGeometry() + geometry.setAttribute('position', (box.getAttribute('position') as BufferAttribute).clone()) + box.dispose() + geometry.applyMatrix4(new Matrix4().makeTranslation(cx, topY - FALLBACK_THICKNESS / 2, cz)) + return geometry +} + +// Fallback ground so the player never falls into the void: a single large +// ground plane at the lowest level (level 0). Upper levels rely on their own +// baked slabs — a slab-less upper floor lets you fall down to the ground, which +// the ground plane catches. (Mirrors the editor walkthrough's site ground.) +function addFallbackFloors(scene: Object3D, geometries: BufferGeometry[]) { + const sceneBounds = new Box3() + for (const geometry of geometries) { + geometry.computeBoundingBox() + if (geometry.boundingBox) sceneBounds.union(geometry.boundingBox) + } + + const center = new Vector3() + const levelPos = new Vector3() + let lowestLevelY = Number.POSITIVE_INFINITY + + scene.traverse((object) => { + if ((object.userData as { kind?: string }).kind !== 'level') return + object.updateWorldMatrix(true, false) + object.getWorldPosition(levelPos) + lowestLevelY = Math.min(lowestLevelY, levelPos.y) + }) + + if (sceneBounds.isEmpty()) return + sceneBounds.getCenter(center) + const groundY = Number.isFinite(lowestLevelY) ? lowestLevelY : sceneBounds.min.y + geometries.push(boxFloorGeometry(center.x, groundY, center.z, GROUND_MIN, GROUND_MIN)) +} + +/** Merge the baked GLB's walkable/blocking meshes into one BVH collider. */ +function buildGlbColliderWorld(scene: Object3D): GlbColliderWorld | null { + scene.updateWorldMatrix(true, true) + const geometries: BufferGeometry[] = [] + + scene.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + // Zone fills live on a separate layer (and never collide). + if (!mesh.layers.isEnabled(SCENE_LAYER)) return + if (!isEffectivelyVisible(mesh)) return + const kind = kindOf(mesh) + if (kind && COLLIDER_EXCLUDED_KINDS.has(kind)) return + const position = mesh.geometry?.getAttribute('position') + if (!position || position.count < 3) return + + const geometry = new BufferGeometry() + const source = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry + geometry.setAttribute('position', toFloat32Position(source.getAttribute('position'))) + if (mesh.geometry.index) source.dispose() + geometry.applyMatrix4(mesh.matrixWorld) + geometries.push(geometry) + }) + + if (geometries.length === 0) return null + + addFallbackFloors(scene, geometries) + + const merged = mergeGeometries(geometries, false) + for (const geometry of geometries) geometry.dispose() + if (!merged || merged.getAttribute('position') == null) { + merged?.dispose() + return null + } + // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype + ;(merged as any).computeBoundsTree = computeBoundsTree + // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype + ;(merged as any).disposeBoundsTree = disposeBoundsTree + // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension + ;(merged as any).computeBoundsTree({ maxLeafSize: 12, strategy: 0 }) + merged.computeBoundingBox() + + const mesh = new Mesh(merged, colliderMaterial) + mesh.raycast = acceleratedRaycast + mesh.visible = true + mesh.userData = { + type: 'STATIC', + friction: 0.8, + restitution: 0.05, + excludeFloatHit: false, + excludeCollisionCheck: false, + } + mesh.updateMatrixWorld(true) + + return { + mesh, + minY: merged.boundingBox?.min.y ?? 0, + dispose: () => { + // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension + ;(merged as any).disposeBoundsTree?.() + merged.dispose() + }, + } +} + +/** The baked spawn marker's eye position + yaw, if the artifact carries one. */ +function resolveGlbSpawn( + scene: Object3D, +): { position: [number, number, number]; yaw: number } | null { + let spawn: Object3D | null = null + scene.traverse((object) => { + if ((object.userData as { kind?: string }).kind === 'spawn') spawn = object + }) + if (!spawn) return null + const node = spawn as Object3D + node.updateWorldMatrix(true, false) + node.getWorldPosition(spawnPos) + node.getWorldQuaternion(spawnQuat) + spawnEuler.setFromQuaternion(spawnQuat, 'YXZ') + return { + position: [spawnPos.x, spawnPos.y + SPAWN_EYE_HEIGHT, spawnPos.z], + yaw: spawnEuler.y, + } +} + +/** + * First-person walkthrough controller for the baked GLB. Reuses the editor's + * `BVHEcctrl` capsule character controller (gravity, jump, sprint, ground-float, + * mesh collision) fed a collider built from the artifact's own geometry — so the + * baked viewer walks the building with the same physics as the editor, without + * the parametric scene. Pointer-lock drives look; WASD moves; Space jumps; Shift + * sprints. Door/window interaction stays in `GlbScene` (its centre-ray HUD). + */ +export function GlbWalkthroughController({ url }: { url: string }) { + const { camera, gl } = useThree() + const gltf = useGLTFKTX2(url) as unknown as { scene: Object3D } + + const worldRef = useRef(null) + const controllerRef = useRef(null) + const yawRef = useRef(0) + const pitchRef = useRef(0) + const [start, setStart] = useState<{ position: [number, number, number] } | null>(null) + const [world, setWorld] = useState(null) + + // Build the collider on the first frame (priority after GlbScene's level loop, + // which snaps the floors to their stacked world positions in walkthrough) so it + // matches the rendered building rather than a mid-lerp / exploded layout. + const builtRef = useRef(false) + useFrame(() => { + if (builtRef.current) return + builtRef.current = true + setWorld(buildGlbColliderWorld(gltf.scene)) + }, 6) + + // First-person needs a perspective camera — an orthographic projection has no + // foreshortening and makes the walkthrough unusable. Force perspective while + // walking and restore the prior projection on exit. + useEffect(() => { + const prevMode = useViewer.getState().cameraMode + if (prevMode === 'orthographic') useViewer.getState().setCameraMode('perspective') + return () => { + if (prevMode === 'orthographic') useViewer.getState().setCameraMode('orthographic') + } + }, []) + + useEffect(() => { + worldRef.current = world + if (world) { + const triangles = world.mesh.geometry.getAttribute('position').count / 3 + console.warn('[glb-walkthrough] collider built', { + triangles, + minY: world.minY, + hasBoundsTree: !!(world.mesh.geometry as { boundsTree?: unknown }).boundsTree, + spawn: resolveGlbSpawn(gltf.scene), + }) + } else { + console.warn('[glb-walkthrough] NO collider world (no eligible meshes)') + } + return () => { + world?.dispose() + worldRef.current = null + } + }, [world, gltf.scene]) + + // Resolve the spawn once the collider exists; the capsule centre sits below + // the eye, then the controller floats it onto the ground. + useEffect(() => { + if (!world || start) return + const spawn = resolveGlbSpawn(gltf.scene) + const eye = spawn?.position ?? [0, SPAWN_EYE_HEIGHT, 0] + yawRef.current = spawn?.yaw ?? 0 + pitchRef.current = 0 + setStart({ position: [eye[0], eye[1] - CONTROLLER_CENTER_FROM_EYE, eye[2]] }) + }, [world, start, gltf.scene]) + + // Pointer-lock look + click-to-lock fallback + Esc/unlock to exit. Once the + // pointer has been locked, releasing it (Esc — the browser swallows that + // keydown, so we can't rely on it; or any other unlock) leaves the walkthrough + // in a single press rather than just freeing the cursor. + useEffect(() => { + const canvas = gl.domElement + let wasLocked = false + const onMouseMove = (event: MouseEvent) => { + if (document.pointerLockElement !== canvas) return + yawRef.current -= event.movementX * LOOK_SENSITIVITY + pitchRef.current = Math.max( + -(Math.PI / 2 - 0.05), + Math.min(Math.PI / 2 - 0.05, pitchRef.current - event.movementY * LOOK_SENSITIVITY), + ) + } + const onClick = () => { + if (document.pointerLockElement !== canvas) canvas.requestPointerLock?.() + } + const onKeyDown = (event: KeyboardEvent) => { + // When locked, the browser intercepts Esc to release the pointer and the + // pointerlockchange handler below exits; this only covers Esc while the + // pointer is already free (e.g. lock never engaged). + if (event.code === 'Escape' && document.pointerLockElement !== canvas) { + useViewer.getState().setWalkthroughMode(false) + } + } + const onPointerLockChange = () => { + if (document.pointerLockElement === canvas) wasLocked = true + else if (wasLocked) useViewer.getState().setWalkthroughMode(false) + } + document.addEventListener('mousemove', onMouseMove) + canvas.addEventListener('click', onClick) + document.addEventListener('keydown', onKeyDown) + document.addEventListener('pointerlockchange', onPointerLockChange) + return () => { + document.removeEventListener('mousemove', onMouseMove) + canvas.removeEventListener('click', onClick) + document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('pointerlockchange', onPointerLockChange) + if (document.pointerLockElement === canvas) document.exitPointerLock() + } + }, [gl]) + + // Lock the pointer the moment the walkthrough is ready, so the user doesn't + // have to click the canvas first. The walkthrough toggle is itself a user + // gesture; if the browser still rejects the request (no transient activation + // left), the click-to-lock fallback above covers it. + useEffect(() => { + if (!(world && start)) return + const canvas = gl.domElement + if (document.pointerLockElement === canvas) return + const result = canvas.requestPointerLock?.() as Promise | undefined + if (result && typeof result.catch === 'function') result.catch(() => {}) + }, [gl, world, start]) + + const setControllerApi = useCallback((api: BVHEcctrlApi | null) => { + controllerRef.current = api + }, []) + + // Drive the camera from the capsule each frame + respawn if it falls into void. + useFrame(() => { + const group = controllerRef.current?.group + if (!group) return + + if (start && world && group.position.y < world.minY - VOID_FALL_RESPAWN_DEPTH) { + group.position.set(start.position[0], start.position[1], start.position[2]) + controllerRef.current?.resetLinVel() + } + + group.rotation.y = 0 + camera.position.copy(group.position).add(cameraOffset) + cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') + camera.quaternion.setFromEuler(cameraEuler) + camera.updateMatrixWorld(true) + }, 2.5) + + if (!(world && start)) return null + + return ( + + + + ) +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index f9b1eebf4..4af1f2eb2 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -13,12 +13,19 @@ 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 { + type BVHEcctrlApi, + default as BVHEcctrl, + type MovementInput, +} from './components/viewer/bvh-ecctrl' export { type GlbHover, type GlbIdentity, type GlbLevel, GlbScene, + type GlbWalkthrough, } from './components/viewer/glb-scene' +export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-controller' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { DEFAULT_HOVER_STYLES, From 0e55d864161a4ccdbcbece5a739dbb7ae3dc0651 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 24 Jun 2026 16:51:00 -0400 Subject: [PATCH 09/16] fix(viewer): keep ceiling-hosted items visible in dollhouse Items mounted on a ceiling (lamps, fans, recessed lights) are child nodes of the ceiling, so hiding the whole occluder node hid them too. Hide only the ceiling/roof's own meshes (stop descending at nested identity nodes) so hosted items stay visible when a floor is opened up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/viewer/glb-scene.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 6718d2358..43563b26d 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -387,6 +387,26 @@ export function GlbScene({ }, [gltf.scene]) const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries]) + // The dollhouse hides ceilings/roof — but only their OWN geometry. Items hosted + // on a ceiling (lamps, fans, recessed lights) are child identity nodes; hiding + // the whole occluder node would hide them too, so collect just the occluder's + // own meshes (stop descending at any nested identity node) and toggle those. + const occluderOwnMeshes = useMemo(() => { + const meshes: THREE.Mesh[] = [] + const walk = (node: THREE.Object3D) => { + for (const child of node.children) { + if ((child.userData as PascalExtras).pascalId) continue // hosted item — keep visible + if ((child as THREE.Mesh).isMesh) meshes.push(child as THREE.Mesh) + walk(child) + } + } + for (const occluder of occluders) { + if ((occluder as THREE.Mesh).isMesh) meshes.push(occluder as THREE.Mesh) + walk(occluder) + } + return meshes + }, [occluders]) + // Move the camera to match the drill depth: a saved bookmark (extras.camera) // wins; otherwise fit to the target's bounds (the object, the room's polygon // footprint for empty zone nodes, the level, or the whole building). Mirrors @@ -726,7 +746,7 @@ export function GlbScene({ // focused level actually has rooms. Focusing a zone-less floor keeps the // building intact (otherwise its roof would just vanish with nothing to show). const revealing = !walk && selection.levelId != null && levelsWithZones.has(selection.levelId) - for (const occluder of occluders) occluder.visible = !revealing + for (const mesh of occluderOwnMeshes) mesh.visible = !revealing for (const { id, levelId, meshes, uniforms } of zoneFills.current) { const show = @@ -938,9 +958,9 @@ export function GlbScene({ 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 + for (const mesh of occluderOwnMeshes) mesh.visible = true }, - [occluders], + [occluderOwnMeshes], ) return ( From d80ffd67d57f9a9b7f48cfddd1e14a29619af936 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 24 Jun 2026 17:47:07 -0400 Subject: [PATCH 10/16] feat(viewer): re-light + re-control baked GLBs from the scene graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a GLB interactive layer (`GlbInteractive`) that re-creates the item interactivity the parametric viewer has — point lights and the controls overlay — on top of a baked artifact. Effects + controls come from the DB scene graph (joined to baked nodes by `pascalId`, no sidecar); world transforms come from the baked Object3Ds. Lights are portaled into their item node so they ride level stacking, and intensity tracks the shared `useInteractive` store so overlay dimming works. Baked scenes load "lit" (toggles default on) for a showcase viewer feel. Extract `ControlWidget` into its own module so the parametric `InteractiveSystem` and the GLB overlay render identical controls. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/viewer/glb-interactive.tsx | 270 ++++++++++++++++++ .../src/components/viewer/glb-scene.tsx | 10 + packages/viewer/src/index.ts | 5 + .../systems/interactive/control-widget.tsx | 87 ++++++ .../interactive/interactive-system.tsx | 86 +----- 5 files changed, 373 insertions(+), 85 deletions(-) create mode 100644 packages/viewer/src/components/viewer/glb-interactive.tsx create mode 100644 packages/viewer/src/systems/interactive/control-widget.tsx diff --git a/packages/viewer/src/components/viewer/glb-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx new file mode 100644 index 000000000..c4411d7ec --- /dev/null +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -0,0 +1,270 @@ +'use client' + +import { + type AnyNodeId, + type Control, + type ControlValue, + type Interactive, + type LightEffect, + pointInPolygon, + type SceneGraph, + type SliderControl, + useInteractive, +} from '@pascal-app/core' +import { Html } from '@react-three/drei' +import { createPortal } from '@react-three/fiber' +import { useEffect, useMemo, useState } from 'react' +import { type Object3D, Vector3 } from 'three' +import { lerp } from 'three/src/math/MathUtils.js' +import { useShallow } from 'zustand/react/shallow' +import useViewer from '../../store/use-viewer' +import { ControlWidget } from '../../systems/interactive/control-widget' + +/** An interactive item recovered from the scene graph so the baked GLB can be + * re-lit / re-animated by joining on `pascalId`. The GLB carries the geometry + * + identity; the effects + controls live in the DB scene graph (no sidecar). */ +export type GlbInteractiveItem = { + pascalId: AnyNodeId + label: string + /** Item height (world units) for placing the controls overlay above it. */ + height: number + interactive: Interactive +} + +/** A baked zone's identity node + its local floor polygon (from `extras`). */ +export type GlbZoneRef = { + id: string + node: Object3D + polygon: [number, number][] +} + +/** Pull the interactive items out of a scene graph. Only items that actually + * carry effects (light / animation) are returned — everything else baked + * faithfully and needs no runtime help. */ +export function buildGlbInteractiveItems( + sceneGraph: SceneGraph | null | undefined, +): GlbInteractiveItem[] { + const nodes = sceneGraph?.nodes + if (!nodes) return [] + const items: GlbInteractiveItem[] = [] + for (const [id, raw] of Object.entries(nodes)) { + const node = raw as { + type?: string + scale?: [number, number, number] + asset?: { name?: string; dimensions?: [number, number, number]; interactive?: Interactive } + } + if (node?.type !== 'item') continue + const interactive = node.asset?.interactive + if (!interactive?.effects?.length) continue + const dims = node.asset?.dimensions ?? [1, 1, 1] + const scaleY = node.scale?.[1] ?? 1 + items.push({ + pascalId: id as AnyNodeId, + label: node.asset?.name ?? id, + height: (dims[1] ?? 1) * scaleY, + interactive, + }) + } + return items +} + +/** Light intensity for the current control state. Mirrors the parametric + * `ItemLightSystem`: a missing toggle/slider value means the viewer default + * (lit / full). An explicit toggle-off drops to the range minimum. */ +function resolveLightIntensity( + effect: LightEffect, + controls: Control[], + values: ControlValue[] | undefined, +): number { + const toggleIndex = controls.findIndex((c) => c.kind === 'toggle') + const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex] ?? true) : true + if (!isOn) return effect.intensityRange[0] + const sliderIndex = controls.findIndex((c) => c.kind === 'slider') + let t = 1 + if (sliderIndex >= 0) { + const slider = controls[sliderIndex] as SliderControl + const raw = (values?.[sliderIndex] as number) ?? slider.default ?? slider.max + t = slider.max > slider.min ? (raw - slider.min) / (slider.max - slider.min) : 1 + } + return lerp(effect.intensityRange[0], effect.intensityRange[1], t) +} + +const _itemPos = new Vector3() + +/** + * Re-creates the item-driven interactivity the parametric viewer has — lights + * and (later) ambient animation + the controls overlay — on top of a baked + * GLB. Effects come from the DB scene graph (`items`); world transforms come + * from the baked Object3Ds (`identity`), joined on `pascalId`. Nothing is + * stamped into the GLB itself, so the artifact stays integrator-clean. + */ +export function GlbInteractive({ + items, + identity, + zones, +}: { + items: GlbInteractiveItem[] + identity: Map + zones: GlbZoneRef[] +}) { + // Seed control state for every interactive item. The viewer shows a baked + // scene "lit": toggles default ON (the editor defaults them off) and sliders + // to their authored default, so lamps glow and fans spin on load. Explicit + // overlay toggles then win. Cleared on unmount so the global store never + // carries state across scenes. + useEffect(() => { + const store = useInteractive.getState() + for (const item of items) { + store.initItem(item.pascalId, item.interactive) + item.interactive.controls.forEach((control, i) => { + if (control.kind === 'toggle') store.setControlValue(item.pascalId, i, true) + }) + } + return () => { + const store = useInteractive.getState() + for (const item of items) store.removeItem(item.pascalId) + } + }, [items]) + + const lightItems = useMemo( + () => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'light')), + [items], + ) + + // Controls overlay is scoped to the focused zone (matches the parametric + // viewer). Project the zone's baked-local polygon into world space once so an + // item's world position can be point-tested regardless of level stacking. + const focusedZoneId = useViewer((s) => s.selection.zoneId) + const worldPolygon = useMemo<[number, number][] | null>(() => { + if (!focusedZoneId) return null + const zone = zones.find((z) => z.id === focusedZoneId) + if (!zone) return null + zone.node.updateWorldMatrix(true, false) + return zone.polygon.map(([x, z]) => { + const v = new Vector3(x, 0, z).applyMatrix4(zone.node.matrixWorld) + return [v.x, v.z] + }) + }, [focusedZoneId, zones]) + + return ( + <> + {lightItems.map((item) => { + const object = identity.get(item.pascalId) + return object ? : null + })} + {items.map((item) => { + const object = identity.get(item.pascalId) + return object ? ( + + ) : null + })} + + ) +} + +/** One point light, portaled into its item's baked node so it rides level + * stacking. Intensity tracks the shared interactive store (overlay dimming). */ +function GlbItemLight({ item, object }: { item: GlbInteractiveItem; object: Object3D }) { + const values = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues)) + const effect = item.interactive.effects.find((e) => e.kind === 'light') as LightEffect | undefined + if (!effect) return null + const intensity = resolveLightIntensity(effect, item.interactive.controls, values) + return createPortal( + , + object, + ) +} + +const FADE_MS = 300 + +/** Controls overlay for one item — fades in while the item sits inside the + * focused zone, portaled above the baked node. */ +function GlbItemControls({ + item, + object, + worldPolygon, +}: { + item: GlbInteractiveItem + object: Object3D + worldPolygon: [number, number][] | null +}) { + const controlValues = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues)) + const setControlValue = useInteractive((s) => s.setControlValue) + + let visible = false + if (worldPolygon?.length) { + object.getWorldPosition(_itemPos) + visible = pointInPolygon(_itemPos.x, _itemPos.z, worldPolygon) + } + + // Fade in on mount and fade out before unmounting the . + const [mounted, setMounted] = useState(false) + const [shown, setShown] = useState(false) + useEffect(() => { + if (visible) { + setMounted(true) + let raf2 = 0 + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => setShown(true)) + }) + return () => { + cancelAnimationFrame(raf1) + cancelAnimationFrame(raf2) + } + } + setShown(false) + const timeout = setTimeout(() => setMounted(false), FADE_MS) + return () => clearTimeout(timeout) + }, [visible]) + + if (!(mounted && controlValues)) return null + + return createPortal( + +
+ {item.interactive.controls.map((control, i) => ( + setControlValue(item.pascalId, i, v)} + value={controlValues[i] ?? false} + /> + ))} +
+ , + object, + ) +} diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 43563b26d..cd518ad88 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -12,6 +12,7 @@ import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' import { ZONE_LAYER } from '../../lib/layers' import { createSurfaceRoleMaterial } from '../../lib/materials' import useViewer from '../../store/use-viewer' +import { GlbInteractive, type GlbInteractiveItem } from './glb-interactive' /** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */ const EXPLODED_GAP = 5 @@ -279,12 +280,16 @@ function createZoneWallGeometry(polygon: [number, number][]): THREE.BufferGeomet */ export function GlbScene({ url, + interactiveItems, onLevelsChange, onIdentityChange, onHoverChange, onWalkthroughChange, }: { url: string + /** 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[] onLevelsChange?: (levels: GlbLevel[]) => void onIdentityChange?: (identity: GlbIdentity) => void onHoverChange?: (hover: GlbHover) => void @@ -972,6 +977,11 @@ export function GlbScene({ onPointerMove={handlePointerMove} onPointerOut={handlePointerOut} /> + {/* Re-light + re-animate the baked artifact from the DB scene graph, + joined to the baked nodes by pascalId. */} + {interactiveItems?.length ? ( + + ) : 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. */} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 4af1f2eb2..2550ac367 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -18,6 +18,11 @@ export { default as BVHEcctrl, type MovementInput, } from './components/viewer/bvh-ecctrl' +export { + buildGlbInteractiveItems, + GlbInteractive, + type GlbInteractiveItem, +} from './components/viewer/glb-interactive' export { type GlbHover, type GlbIdentity, diff --git a/packages/viewer/src/systems/interactive/control-widget.tsx b/packages/viewer/src/systems/interactive/control-widget.tsx new file mode 100644 index 000000000..b6648fd72 --- /dev/null +++ b/packages/viewer/src/systems/interactive/control-widget.tsx @@ -0,0 +1,87 @@ +'use client' + +import type { Control, ControlValue } from '@pascal-app/core' + +/** One interactive control (toggle / slider / temperature) rendered inside the + * item controls overlay. Shared by the parametric `InteractiveSystem` and the + * baked-GLB `GlbInteractive` overlay so both look and behave identically. */ +export const ControlWidget = ({ + control, + value, + onChange, +}: { + control: Control + value: ControlValue + onChange: (v: ControlValue) => void +}) => { + const labelStyle: React.CSSProperties = { + color: 'white', + fontSize: 11, + fontFamily: 'monospace', + display: 'flex', + flexDirection: 'column', + gap: 2, + } + + if (control.kind === 'toggle') { + return ( + + ) + } + + if (control.kind === 'slider') { + return ( + + ) + } + + if (control.kind === 'temperature') { + return ( + + ) + } + + return null +} diff --git a/packages/viewer/src/systems/interactive/interactive-system.tsx b/packages/viewer/src/systems/interactive/interactive-system.tsx index 3d6e684cf..8559f0175 100644 --- a/packages/viewer/src/systems/interactive/interactive-system.tsx +++ b/packages/viewer/src/systems/interactive/interactive-system.tsx @@ -2,8 +2,6 @@ import { type AnyNodeId, - type Control, - type ControlValue, type ItemNode, pointInPolygon, sceneRegistry, @@ -17,6 +15,7 @@ import { useEffect, useState } from 'react' import { type Object3D, Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' import useViewer from '../../store/use-viewer' +import { ControlWidget } from './control-widget' const _tempVec = new Vector3() @@ -146,86 +145,3 @@ const ItemControlsOverlay = ({ itemObj, ) } - -// ---- Control widgets ---- - -const ControlWidget = ({ - control, - value, - onChange, -}: { - control: Control - value: ControlValue - onChange: (v: ControlValue) => void -}) => { - const labelStyle: React.CSSProperties = { - color: 'white', - fontSize: 11, - fontFamily: 'monospace', - display: 'flex', - flexDirection: 'column', - gap: 2, - } - - if (control.kind === 'toggle') { - return ( - - ) - } - - if (control.kind === 'slider') { - return ( - - ) - } - - if (control.kind === 'temperature') { - return ( - - ) - } - - return null -} From 0514ec185833621230a4280bb96057d62c54b013 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 24 Jun 2026 18:13:48 -0400 Subject: [PATCH 11/16] feat(bake): bake catalog item clips (fan spin) into the GLB + play in viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A catalog GLB ships its own animation clips (a ceiling fan's spin), but those clips aren't in the editor scene graph, so the bake couldn't see them. Add an `itemClipRegistry` (core, type-only three) that the item renderer fills with the resolved clip per node while the scene is live; the GLB export reads it and re-emits each item's clip onto the baked subtree, rebinding tracks to the cloned spinning node's uuid. Catalog node names repeat across instances and the glTF roundtrip rebinds by name, so the targeted node is uniquified per item (`__lamp_018`) — every fan animates independently. The baked viewer plays these as looping ambient motion: `: loop` clips are set to LoopRepeat (not the door/window LoopOnce) and GlbItemAnimation drives them off each item's toggle (lit/spinning by default). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scene-registry/item-clip-registry.ts | 19 +++++++ packages/core/src/index.ts | 1 + packages/editor/src/lib/glb-export.ts | 52 ++++++++++++++++++- packages/nodes/src/item/renderer.tsx | 15 ++++++ .../src/components/viewer/glb-interactive.tsx | 43 ++++++++++++++- .../src/components/viewer/glb-scene.tsx | 21 ++++++-- 6 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/hooks/scene-registry/item-clip-registry.ts diff --git a/packages/core/src/hooks/scene-registry/item-clip-registry.ts b/packages/core/src/hooks/scene-registry/item-clip-registry.ts new file mode 100644 index 000000000..3b8d63b0a --- /dev/null +++ b/packages/core/src/hooks/scene-registry/item-clip-registry.ts @@ -0,0 +1,19 @@ +import type * as THREE from 'three' + +export type ItemClipEntry = { + /** The catalog clip to re-emit (e.g. a fan's "On" spin). */ + clip: THREE.AnimationClip + /** Plays looping in the baked viewer (ambient motion) vs once. */ + loop: boolean +} + +/** + * Catalog-item animation clips the bake needs to re-emit. A catalog GLB ships + * its own clips (the live item renderer loads + plays them), but those clips + * are not part of the editor scene graph, so the GLB export can't see them on + * its own. The item renderer registers the resolved clip per node id while the + * scene is live; `glb-export` reads this and retargets the clip onto the baked + * item subtree. Door/window motion is synthesized separately and never goes + * here. Keyed by node id; cleared with the rest of the scene refs on unload. + */ +export const itemClipRegistry = new Map() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 67ffb1518..fc35f98f5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,6 +35,7 @@ export type { ZoneEvent, } from './events/bus' export { emitter, eventSuffixes } from './events/bus' +export { type ItemClipEntry, itemClipRegistry } from './hooks/scene-registry/item-clip-registry' export { sceneRegistry, useRegistry, diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index 29ef52ce4..e795ca761 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -2,6 +2,7 @@ import { type AnyNode, emitter, getLevelDisplayName, + itemClipRegistry, type LevelNode, sceneRegistry, type WindowNode, @@ -342,7 +343,9 @@ function bakeAnimationClips( ? bakeDoorClip(id, node, target) : node.type === 'window' ? bakeWindowClip(id, node as WindowNode, target) - : null + : node.type === 'item' + ? bakeItemClip(id, target) + : null if (clip) { clips.push(clip) @@ -353,6 +356,47 @@ function bakeAnimationClips( return { clips, clipNamesByNode } } +/** + * Re-emit a catalog item's ambient clip (e.g. a fan's spin) onto the baked + * subtree. The source clip targets the item GLB's nodes by name (`lamp_018`); + * since every fan shares those names, we rebind each track to the specific + * cloned node's uuid so multiple fans animate independently. The clip is named + * per node (`: loop`) so the baked viewer can drive each one on its own. + */ +function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationClip | null { + const entry = itemClipRegistry.get(id) + if (!entry) return null + + const tracks: THREE.KeyframeTrack[] = [] + // The catalog node names (e.g. "lamp_018") repeat across every instance of the + // item, and the glTF export→import roundtrip rebinds clip tracks by node name — + // so a shared name would make all fans share one clip. Uniquify the targeted + // node's name per item once, then bind tracks by its (stable) uuid. + const renamed = new Map() + for (const track of entry.clip.tracks) { + const dot = track.name.lastIndexOf('.') + if (dot < 0) continue + const targetName = track.name.slice(0, dot) + const property = track.name.slice(dot + 1) + let targetNode = renamed.get(targetName) + if (!targetNode) { + const found = itemObject.getObjectByName(targetName) + if (!found) continue + found.name = `${id}__${targetName}` + renamed.set(targetName, found) + targetNode = found + } + const retargeted = track.clone() + retargeted.name = `${targetNode.uuid}.${property}` + tracks.push(retargeted) + } + + if (tracks.length === 0) return null + const clip = new THREE.AnimationClip(`${id}: loop`, entry.clip.duration, tracks) + clip.userData = { loop: entry.loop } + return clip +} + /** * Bake a swing door's open motion. Each marked leaf is rotated from closed * (rest pose) to its fully-open angle and emitted as a 1-second quaternion @@ -545,6 +589,12 @@ function stampIdentity( extras.clips = clipNames } } + // Items with a baked ambient clip (a fan's spin) carry the clip name but no + // `openable` flag — nothing opens; the clip just loops. + if (node.type === 'item') { + const clipNames = clipNamesByNode.get(id) + if (clipNames?.length) 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 diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index dc18848ca..9d5eceec0 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -8,6 +8,7 @@ import { type Interactive, type ItemNode, isSlotMaterialName, + itemClipRegistry, LIBRARY_MATERIAL_REF_PREFIX, type LightEffect, SCENE_MATERIAL_REF_PREFIX, @@ -387,6 +388,20 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { const lightEffects = interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? [] + // Expose this item's ambient clip (e.g. a fan's spin) to the GLB bake. The + // catalog GLB owns the clip; it isn't in the scene graph, so the export can't + // find it without this registry. The bake retargets it onto the baked subtree. + useEffect(() => { + if (!animEffect) return + const clipName = animEffect.clips.on ?? animEffect.clips.loop + const clip = clipName ? animations.find((c) => c.name === clipName) : undefined + if (!clip) return + itemClipRegistry.set(node.id, { clip, loop: true }) + return () => { + itemClipRegistry.delete(node.id) + } + }, [node.id, animEffect, animations]) + // useGLTF caches scenes, and Clone shares child geometry/material references. // Undo can unmount one item while another clone of the same asset still needs them. return ( diff --git a/packages/viewer/src/components/viewer/glb-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx index c4411d7ec..b2da2cdb5 100644 --- a/packages/viewer/src/components/viewer/glb-interactive.tsx +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -14,7 +14,7 @@ import { import { Html } from '@react-three/drei' import { createPortal } from '@react-three/fiber' import { useEffect, useMemo, useState } from 'react' -import { type Object3D, Vector3 } from 'three' +import { type AnimationAction, LoopRepeat, type Object3D, Vector3 } from 'three' import { lerp } from 'three/src/math/MathUtils.js' import { useShallow } from 'zustand/react/shallow' import useViewer from '../../store/use-viewer' @@ -102,10 +102,14 @@ export function GlbInteractive({ items, identity, zones, + actions, }: { items: GlbInteractiveItem[] identity: Map zones: GlbZoneRef[] + /** Baked animation actions keyed by clip name — ambient item loops play from + * `: loop`. */ + actions: Record }) { // Seed control state for every interactive item. The viewer shows a baked // scene "lit": toggles default ON (the editor defaults them off) and sliders @@ -130,6 +134,10 @@ export function GlbInteractive({ () => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'light')), [items], ) + const animationItems = useMemo( + () => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'animation')), + [items], + ) // Controls overlay is scoped to the focused zone (matches the parametric // viewer). Project the zone's baked-local polygon into world space once so an @@ -152,6 +160,9 @@ export function GlbInteractive({ const object = identity.get(item.pascalId) return object ? : null })} + {animationItems.map((item) => ( + + ))} {items.map((item) => { const object = identity.get(item.pascalId) return object ? ( @@ -187,6 +198,36 @@ function GlbItemLight({ item, object }: { item: GlbInteractiveItem; object: Obje ) } +/** Plays an item's baked ambient loop (a fan's spin), gated on its toggle. + * The clip and its targets are already in the GLB; we only start/stop it. */ +function GlbItemAnimation({ + item, + actions, +}: { + item: GlbInteractiveItem + actions: Record +}) { + const values = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues)) + const toggleIndex = item.interactive.controls.findIndex((c) => c.kind === 'toggle') + const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex] ?? true) : true + + useEffect(() => { + const action = actions[`${item.pascalId}: loop`] + if (!action) return + action.loop = LoopRepeat + action.clampWhenFinished = false + if (isOn) { + action.enabled = true + action.paused = false + if (!action.isRunning()) action.play() + } else { + action.stop() + } + }, [actions, item.pascalId, isOn]) + + return null +} + const FADE_MS = 300 /** Controls overlay for one item — fades in while the item sits inside the diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index cd518ad88..fe6697805 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -607,10 +607,18 @@ export function GlbScene({ }, [zoneEntries]) useEffect(() => { - for (const action of Object.values(actions)) { + for (const [name, action] of Object.entries(actions)) { if (!action) continue - action.loop = THREE.LoopOnce - action.clampWhenFinished = true + // Ambient item loops (a fan's spin, `: loop`) repeat; door/window + // open clips play once and hold their end pose. GlbInteractive plays the + // loops, gated on the item's toggle. + if (name.endsWith(': loop')) { + action.loop = THREE.LoopRepeat + action.clampWhenFinished = false + } else { + action.loop = THREE.LoopOnce + action.clampWhenFinished = true + } } }, [actions]) @@ -980,7 +988,12 @@ export function GlbScene({ {/* Re-light + re-animate the baked artifact from the DB scene graph, joined to the baked nodes by pascalId. */} {interactiveItems?.length ? ( - + ) : 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 From a5c08b59505333ad4169bc2fa87a2a1b5186b288 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 25 Jun 2026 07:34:41 -0400 Subject: [PATCH 12/16] feat(viewer): pool GLB item lights + stop overlay click propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the parametric ItemLightSystem instead of mounting a light per item: a fixed pool of point lights is assigned to the nearest/most-visible lit items each tick (camera-proximity scored, hysteresis, level factor), snapped to each item's world position + offset, and faded on reassignment — so a large house doesn't blow the renderer's light budget. The controls overlay already mounts its only while the item sits in the focused zone (not hide/show); add stopPropagation on the overlay so toggling a control no longer bubbles to the canvas and deselects the zone. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/viewer/glb-interactive.tsx | 376 +++++++++++++++--- .../src/components/viewer/glb-scene.tsx | 3 + 2 files changed, 322 insertions(+), 57 deletions(-) diff --git a/packages/viewer/src/components/viewer/glb-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx index b2da2cdb5..e6d1f22f6 100644 --- a/packages/viewer/src/components/viewer/glb-interactive.tsx +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -2,8 +2,6 @@ import { type AnyNodeId, - type Control, - type ControlValue, type Interactive, type LightEffect, pointInPolygon, @@ -12,10 +10,9 @@ import { useInteractive, } from '@pascal-app/core' import { Html } from '@react-three/drei' -import { createPortal } from '@react-three/fiber' -import { useEffect, useMemo, useState } from 'react' -import { type AnimationAction, LoopRepeat, type Object3D, Vector3 } from 'three' -import { lerp } from 'three/src/math/MathUtils.js' +import { createPortal, useFrame } from '@react-three/fiber' +import { useEffect, useMemo, useRef, useState } from 'react' +import { type AnimationAction, LoopRepeat, MathUtils, type Object3D, type PointLight, Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' import useViewer from '../../store/use-viewer' import { ControlWidget } from '../../systems/interactive/control-widget' @@ -68,41 +65,21 @@ export function buildGlbInteractiveItems( return items } -/** Light intensity for the current control state. Mirrors the parametric - * `ItemLightSystem`: a missing toggle/slider value means the viewer default - * (lit / full). An explicit toggle-off drops to the range minimum. */ -function resolveLightIntensity( - effect: LightEffect, - controls: Control[], - values: ControlValue[] | undefined, -): number { - const toggleIndex = controls.findIndex((c) => c.kind === 'toggle') - const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex] ?? true) : true - if (!isOn) return effect.intensityRange[0] - const sliderIndex = controls.findIndex((c) => c.kind === 'slider') - let t = 1 - if (sliderIndex >= 0) { - const slider = controls[sliderIndex] as SliderControl - const raw = (values?.[sliderIndex] as number) ?? slider.default ?? slider.max - t = slider.max > slider.min ? (raw - slider.min) / (slider.max - slider.min) : 1 - } - return lerp(effect.intensityRange[0], effect.intensityRange[1], t) -} - const _itemPos = new Vector3() /** - * Re-creates the item-driven interactivity the parametric viewer has — lights - * and (later) ambient animation + the controls overlay — on top of a baked - * GLB. Effects come from the DB scene graph (`items`); world transforms come - * from the baked Object3Ds (`identity`), joined on `pascalId`. Nothing is - * stamped into the GLB itself, so the artifact stays integrator-clean. + * Re-creates the item-driven interactivity the parametric viewer has — pooled + * lights, ambient animation, and the controls overlay — on top of a baked GLB. + * Effects come from the DB scene graph (`items`); world transforms come from the + * baked Object3Ds (`identity`), joined on `pascalId`. Nothing is stamped into + * the GLB itself, so the artifact stays integrator-clean. */ export function GlbInteractive({ items, identity, zones, actions, + levelOrder, }: { items: GlbInteractiveItem[] identity: Map @@ -110,6 +87,9 @@ export function GlbInteractive({ /** Baked animation actions keyed by clip name — ambient item loops play from * `: loop`. */ actions: Record + /** Level pascalIds bottom-to-top, so the light pool can prefer ground-floor + * lights when nothing is focused (mirrors the parametric level factor). */ + levelOrder: string[] }) { // Seed control state for every interactive item. The viewer shows a baked // scene "lit": toggles default ON (the editor defaults them off) and sliders @@ -130,15 +110,45 @@ export function GlbInteractive({ } }, [items]) - const lightItems = useMemo( - () => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'light')), - [items], - ) const animationItems = useMemo( () => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'animation')), [items], ) + // Light registrations: one per item with a light effect, joined to its baked + // node. Fed to a fixed pool (below) rather than mounting a light per item. + const lightRegs = useMemo(() => { + const regs: GlbLightReg[] = [] + for (const item of items) { + const effect = item.interactive.effects.find((e) => e.kind === 'light') as + | LightEffect + | undefined + if (!effect) continue + const object = identity.get(item.pascalId) + if (!object) continue + const controls = item.interactive.controls + const toggleIndex = controls.findIndex((c) => c.kind === 'toggle') + const sliderIndex = controls.findIndex((c) => c.kind === 'slider') + const slider = sliderIndex >= 0 ? (controls[sliderIndex] as SliderControl) : null + regs.push({ + key: item.pascalId, + object, + effect, + toggleIndex, + sliderIndex, + hasSlider: !!slider, + sliderMin: slider?.min ?? 0, + sliderMax: slider?.max ?? 1, + levelId: findLevelId(object), + }) + } + return regs + }, [items, identity]) + const levelIndexById = useMemo( + () => new Map(levelOrder.map((id, i) => [id, i] as const)), + [levelOrder], + ) + // Controls overlay is scoped to the focused zone (matches the parametric // viewer). Project the zone's baked-local polygon into world space once so an // item's world position can be point-tested regardless of level stacking. @@ -156,10 +166,7 @@ export function GlbInteractive({ return ( <> - {lightItems.map((item) => { - const object = identity.get(item.pascalId) - return object ? : null - })} + {animationItems.map((item) => ( ))} @@ -178,23 +185,273 @@ export function GlbInteractive({ ) } -/** One point light, portaled into its item's baked node so it rides level - * stacking. Intensity tracks the shared interactive store (overlay dimming). */ -function GlbItemLight({ item, object }: { item: GlbInteractiveItem; object: Object3D }) { - const values = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues)) - const effect = item.interactive.effects.find((e) => e.kind === 'light') as LightEffect | undefined - if (!effect) return null - const intensity = resolveLightIntensity(effect, item.interactive.controls, values) - return createPortal( - , - object, +// ── Pooled item lights ────────────────────────────────────────────────────── +// +// Mirrors the parametric `ItemLightSystem`: a fixed pool of point lights is +// assigned to the nearest/most-visible lit items each tick (camera-proximity +// scored, with hysteresis), snapped to the item's world position + offset, and +// faded in/out on reassignment. Mounting a light per item instead would blow +// the renderer's light budget on a large house. + +const POOL_SIZE = 12 +const REASSIGN_INTERVAL = 0.2 +const HYSTERESIS = 0.15 +const CAM_MOVE_DIST = 0.5 +const CAM_ROT_DOT = 0.995 + +type GlbLightReg = { + key: AnyNodeId + object: Object3D + effect: LightEffect + toggleIndex: number + sliderIndex: number + hasSlider: boolean + sliderMin: number + sliderMax: number + levelId: string | null +} + +type SlotRuntime = { key: string | null; pendingKey: string | null; isFadingOut: boolean } + +const _camPos = new Vector3() +const _camFwd = new Vector3() +const _dir = new Vector3() +const _lightWorld = new Vector3() + +/** The nearest level-identity ancestor's pascalId, for the level factor. */ +function findLevelId(object: Object3D): string | null { + let cur: Object3D | null = object + while (cur) { + const ud = cur.userData as { kind?: string; pascalId?: string } + if (ud.kind === 'level' && ud.pascalId) return ud.pascalId + cur = cur.parent + } + return null +} + +function scoreReg( + reg: GlbLightReg, + selectedLevelId: string | null, + levelMode: string, + levelIndexById: Map, + interactiveState: ReturnType, +): number { + // Toggled-off lights contribute no illumination — drop them from the pool. + if (reg.toggleIndex >= 0 && !interactiveState.items[reg.key]?.controlValues?.[reg.toggleIndex]) { + return Number.POSITIVE_INFINITY + } + reg.object.getWorldPosition(_lightWorld) + _lightWorld.x += reg.effect.offset[0] + _lightWorld.y += reg.effect.offset[1] + _lightWorld.z += reg.effect.offset[2] + _dir.copy(_lightWorld).sub(_camPos).normalize() + const angular = 1 - _camFwd.dot(_dir) + const dist = _camPos.distanceTo(_lightWorld) / 200 + let levelPenalty = 0 + if (selectedLevelId) { + if (reg.levelId !== selectedLevelId) levelPenalty = levelMode === 'solo' ? 100 : 0.8 + } else if (reg.levelId && (levelIndexById.get(reg.levelId) ?? 0) !== 0) { + levelPenalty = 0.3 + } + return angular * 0.7 + dist * 0.3 + levelPenalty +} + +function GlbItemLights({ + regs, + levelIndexById, +}: { + regs: GlbLightReg[] + levelIndexById: Map +}) { + const lightRefs = useRef>(Array.from({ length: POOL_SIZE }, () => null)) + const slots = useRef( + Array.from({ length: POOL_SIZE }, () => ({ key: null, pendingKey: null, isFadingOut: false })), + ) + const reassignTimer = useRef(0) + const prevCamPos = useRef(new Vector3()) + const prevCamFwd = useRef(new Vector3(0, 0, -1)) + const regByKey = useMemo(() => new Map(regs.map((r) => [r.key as string, r])), [regs]) + + useFrame(({ camera }, delta) => { + const dt = Math.min(delta, 0.1) + const interactiveState = useInteractive.getState() + camera.getWorldPosition(_camPos) + camera.getWorldDirection(_camFwd) + + const camMoved = + _camPos.distanceTo(prevCamPos.current) > CAM_MOVE_DIST || + _camFwd.dot(prevCamFwd.current) < CAM_ROT_DOT + reassignTimer.current -= delta + + if (reassignTimer.current <= 0 || camMoved) { + reassignTimer.current = REASSIGN_INTERVAL + prevCamPos.current.copy(_camPos) + prevCamFwd.current.copy(_camFwd) + const viewer = useViewer.getState() + const selectedLevelId = viewer.selection.levelId + const levelMode = viewer.levelMode + + const scored = regs.map((reg) => ({ + key: reg.key as string, + score: scoreReg(reg, selectedLevelId, levelMode, levelIndexById, interactiveState), + })) + scored.sort((a, b) => a.score - b.score) + const desired = scored + .filter((s) => Number.isFinite(s.score)) + .slice(0, POOL_SIZE) + .map((s) => s.key) + + const currentlyAssigned = new Map() + for (let i = 0; i < POOL_SIZE; i++) { + const s = slots.current[i] + const k = s?.key ?? s?.pendingKey + if (k) currentlyAssigned.set(k, i) + } + + const usedSlots = new Set() + const assignedKeys = new Set() + // Pass 1: keep existing slots whose key is still wanted. + for (const key of desired) { + const existingSlot = currentlyAssigned.get(key) + if (existingSlot !== undefined && !usedSlots.has(existingSlot)) { + usedSlots.add(existingSlot) + assignedKeys.add(key) + } + } + // Pass 2: assign the rest to free slots, evicting only on a clear win. + let freeSlot = 0 + for (const key of desired) { + if (assignedKeys.has(key)) continue + while (freeSlot < POOL_SIZE && usedSlots.has(freeSlot)) freeSlot++ + if (freeSlot >= POOL_SIZE) break + + const freeSlotData = slots.current[freeSlot] + const currentKey = freeSlotData ? (freeSlotData.key ?? freeSlotData.pendingKey) : null + if (currentKey && !desired.includes(currentKey)) { + const currentScore = + scored.find((s) => s.key === currentKey)?.score ?? Number.POSITIVE_INFINITY + const newScore = scored.find((s) => s.key === key)?.score ?? 0 + if (currentScore - newScore < HYSTERESIS) { + freeSlot++ + continue + } + } + + usedSlots.add(freeSlot) + assignedKeys.add(key) + const slot = slots.current[freeSlot] + if (slot && slot.key !== key) { + slot.pendingKey = key + slot.isFadingOut = slot.key !== null + if (!slot.isFadingOut) { + slot.key = key + slot.pendingKey = null + const light = lightRefs.current[freeSlot] + const reg = regByKey.get(key) + if (light && reg) { + light.color.set(reg.effect.color) + light.distance = reg.effect.distance ?? 0 + } + } + } + freeSlot++ + } + + // Retire slots whose key is no longer wanted. + for (let i = 0; i < POOL_SIZE; i++) { + if (!usedSlots.has(i)) { + const slot = slots.current[i] + if (slot?.key && !desired.includes(slot.key)) { + slot.pendingKey = null + slot.isFadingOut = true + } + } + } + } + + // Per-frame: fade, snap position, and track intensity from control state. + for (let i = 0; i < POOL_SIZE; i++) { + const light = lightRefs.current[i] + const slot = slots.current[i] + if (!(light && slot)) continue + + if (slot.isFadingOut) { + light.visible = true + light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12) + if (light.intensity < 0.01) { + light.intensity = 0 + light.visible = false + slot.isFadingOut = false + slot.key = slot.pendingKey + slot.pendingKey = null + if (slot.key) { + const reg = regByKey.get(slot.key) + if (reg) { + light.color.set(reg.effect.color) + light.distance = reg.effect.distance ?? 0 + } + } + } + continue + } + + if (!slot.key) { + light.intensity = 0 + light.visible = false + continue + } + const reg = regByKey.get(slot.key) + if (!reg) { + slot.key = null + light.intensity = 0 + light.visible = false + continue + } + + reg.object.getWorldPosition(_lightWorld) + light.position.set( + _lightWorld.x + reg.effect.offset[0], + _lightWorld.y + reg.effect.offset[1], + _lightWorld.z + reg.effect.offset[2], + ) + + const values = interactiveState.items[reg.key]?.controlValues + const isOn = reg.toggleIndex >= 0 ? Boolean(values?.[reg.toggleIndex]) : true + let t = 1 + if (reg.hasSlider) { + const raw = (values?.[reg.sliderIndex] as number) ?? reg.sliderMin + t = + reg.sliderMax > reg.sliderMin + ? (raw - reg.sliderMin) / (reg.sliderMax - reg.sliderMin) + : 1 + } + const targetIntensity = isOn + ? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t) + : reg.effect.intensityRange[0] + + if (targetIntensity > 0) light.visible = true + light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12) + if (targetIntensity <= 0 && light.intensity < 0.01) { + light.intensity = 0 + light.visible = false + } + } + }) + + return ( + <> + {Array.from({ length: POOL_SIZE }, (_, i) => ( + { + lightRefs.current[i] = el + }} + visible={false} + /> + ))} + ) } @@ -280,7 +537,12 @@ function GlbItemControls({ position={[0, item.height + 0.3, 0]} zIndexRange={[20, 0]} > + {/* Stop pointer/click events from reaching the canvas — otherwise R3F's + pointer-missed fires and deselects the zone the moment you toggle. */}
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onPointerUp={(e) => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index fe6697805..7169f4c0b 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -391,6 +391,8 @@ export function GlbScene({ } }, [gltf.scene]) const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries]) + // Level pascalIds bottom-to-top, for the interactive light pool's level factor. + const levelOrder = useMemo(() => levels.map((entry) => entry.id), [levels]) // The dollhouse hides ceilings/roof — but only their OWN geometry. Items hosted // on a ceiling (lamps, fans, recessed lights) are child identity nodes; hiding @@ -992,6 +994,7 @@ export function GlbScene({ actions={actions} identity={identity} items={interactiveItems} + levelOrder={levelOrder} zones={zoneEntries} /> ) : null} From df901b1d4eb76967b203ac5feafdf57d516b6acf Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 25 Jun 2026 07:46:17 -0400 Subject: [PATCH 13/16] perf(viewer): keep GLB light pool at a fixed visible count (no WebGPU recompiles) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggling pointLight.visible changes the active-light count, which makes the WebGPU renderer rebuild every material's lighting node + pipeline — a multi- hundred-ms stall. The pool reassigns on every camera move, so zooming churned visibility and tanked FPS. Keep all 12 pool lights permanently visible and animate only intensity (an idle light lerps to 0); the light-set never changes, so no recompiles. Also make reassignment O(n) (score lookup map, not find). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/viewer/glb-interactive.tsx | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/viewer/src/components/viewer/glb-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx index e6d1f22f6..bb657924d 100644 --- a/packages/viewer/src/components/viewer/glb-interactive.tsx +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -296,6 +296,7 @@ function GlbItemLights({ score: scoreReg(reg, selectedLevelId, levelMode, levelIndexById, interactiveState), })) scored.sort((a, b) => a.score - b.score) + const scoreByKey = new Map(scored.map((s) => [s.key, s.score] as const)) const desired = scored .filter((s) => Number.isFinite(s.score)) .slice(0, POOL_SIZE) @@ -328,9 +329,8 @@ function GlbItemLights({ const freeSlotData = slots.current[freeSlot] const currentKey = freeSlotData ? (freeSlotData.key ?? freeSlotData.pendingKey) : null if (currentKey && !desired.includes(currentKey)) { - const currentScore = - scored.find((s) => s.key === currentKey)?.score ?? Number.POSITIVE_INFINITY - const newScore = scored.find((s) => s.key === key)?.score ?? 0 + const currentScore = scoreByKey.get(currentKey) ?? Number.POSITIVE_INFINITY + const newScore = scoreByKey.get(key) ?? 0 if (currentScore - newScore < HYSTERESIS) { freeSlot++ continue @@ -370,17 +370,20 @@ function GlbItemLights({ } // Per-frame: fade, snap position, and track intensity from control state. + // The pool lights stay permanently `visible` — only `intensity` is animated + // (an idle light just lerps to 0). Toggling `visible` would change the + // active-light count, which forces the WebGPU renderer to recompile every + // material's lighting node — a hard frame-time spike on every reassignment + // (i.e. on every camera move). Keeping the count fixed avoids that entirely. for (let i = 0; i < POOL_SIZE; i++) { const light = lightRefs.current[i] const slot = slots.current[i] if (!(light && slot)) continue if (slot.isFadingOut) { - light.visible = true light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12) if (light.intensity < 0.01) { light.intensity = 0 - light.visible = false slot.isFadingOut = false slot.key = slot.pendingKey slot.pendingKey = null @@ -396,15 +399,12 @@ function GlbItemLights({ } if (!slot.key) { - light.intensity = 0 - light.visible = false + light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12) continue } const reg = regByKey.get(slot.key) if (!reg) { slot.key = null - light.intensity = 0 - light.visible = false continue } @@ -428,13 +428,7 @@ function GlbItemLights({ const targetIntensity = isOn ? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t) : reg.effect.intensityRange[0] - - if (targetIntensity > 0) light.visible = true light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12) - if (targetIntensity <= 0 && light.intensity < 0.01) { - light.intensity = 0 - light.visible = false - } } }) @@ -448,7 +442,6 @@ function GlbItemLights({ ref={(el) => { lightRefs.current[i] = el }} - visible={false} /> ))} From 5938cb6805f742017a5577f6d08836dcc14af018 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 25 Jun 2026 08:36:02 -0400 Subject: [PATCH 14/16] feat(viewer): strip scans/guides from the bake, re-add from scene data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/editor/src/lib/glb-export.ts | 12 ++++ .../components/renderers/node-renderer.tsx | 2 +- .../components/viewer/glb-reference-nodes.tsx | 63 +++++++++++++++++++ .../src/components/viewer/glb-scene.tsx | 12 +++- packages/viewer/src/index.ts | 1 + 5 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 packages/viewer/src/components/viewer/glb-reference-nodes.tsx diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index e795ca761..bc80a2fb5 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -97,6 +97,18 @@ export function prepareSceneForExport( const scene = source.clone(true) const cloneByOriginal = pairClones(source, scene) + // Scans (LiDAR meshes) and guides (floorplan images) are heavy reference + // assets stored elsewhere and aren't part of the compiled building. Drop them + // from the artifact entirely — `/viewer` re-adds them from the scene graph, + // gated by the project's public-visibility flags, so they never bloat the + // shared GLB nor slip past those flags into a static public file. + for (const [id, original] of sceneRegistry.nodes) { + const node = nodes[id] + if (node?.type === 'scan' || node?.type === 'guide') { + cloneByOriginal.get(original)?.removeFromParent() + } + } + // 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 diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 75f5afddd..831bf725d 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -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, ComponentType<{ node: AnyNode }>>() -function getRegistryRenderer( +export function getRegistryRenderer( source: RendererSource, ): ComponentType<{ node: AnyNode }> | null { const cached = lazyCache.get(source) diff --git a/packages/viewer/src/components/viewer/glb-reference-nodes.tsx b/packages/viewer/src/components/viewer/glb-reference-nodes.tsx new file mode 100644 index 000000000..f1814a2d2 --- /dev/null +++ b/packages/viewer/src/components/viewer/glb-reference-nodes.tsx @@ -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 +}) { + return ( + <> + {nodes.map((node) => { + const anchor = node.parentId ? identity.get(node.parentId) : undefined + return anchor ? : 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) : null + if (!Renderer) return null + return createPortal( + + + , + anchor, + ) +} diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 7169f4c0b..b46dc7655 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -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 ? ( + + ) : 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. */} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 2550ac367..498ad03ea 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -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, From 636f8ed4f7eb83f8c8ae0382f32cf7412c326807 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 25 Jun 2026 10:01:13 -0400 Subject: [PATCH 15/16] style(viewer): wrap three import in glb-interactive (formatter) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../viewer/src/components/viewer/glb-interactive.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/viewer/src/components/viewer/glb-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx index bb657924d..df17bc1c2 100644 --- a/packages/viewer/src/components/viewer/glb-interactive.tsx +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -12,7 +12,14 @@ import { import { Html } from '@react-three/drei' import { createPortal, useFrame } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' -import { type AnimationAction, LoopRepeat, MathUtils, type Object3D, type PointLight, Vector3 } from 'three' +import { + type AnimationAction, + LoopRepeat, + MathUtils, + type Object3D, + type PointLight, + Vector3, +} from 'three' import { useShallow } from 'zustand/react/shallow' import useViewer from '../../store/use-viewer' import { ControlWidget } from '../../systems/interactive/control-widget' From b7386f2dcb4524de1222c45c396d62deb3373a8a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 25 Jun 2026 12:37:03 -0400 Subject: [PATCH 16/16] fix(viewer): block body for zone-shape forEach (useIterableCallbackReturn) Main's biome config flags a callback returning a value; the ternary in the zone-shape forEach implicitly returned moveTo/lineTo. Use a block body. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/viewer/src/components/viewer/glb-scene.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index b46dc7655..3383b5998 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -569,7 +569,10 @@ export function GlbScene({ const built: ZoneFill[] = [] for (const entry of zoneEntries) { const shape = new THREE.Shape() - entry.polygon.forEach(([x, z], i) => (i === 0 ? shape.moveTo(x, -z) : shape.lineTo(x, -z))) + entry.polygon.forEach(([x, z], i) => { + if (i === 0) shape.moveTo(x, -z) + else shape.lineTo(x, -z) + }) shape.closePath() const floorMaterial = createZoneFloorMaterial(entry.color)