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/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 83f98a33d..974c164f5 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -1,13 +1,12 @@ '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 { exportSceneToGlb, prepareSceneForExport } from '../../lib/glb-export' export function ExportManager() { const scene = useThree((state) => state.scene) @@ -23,7 +22,26 @@ export function ExportManager() { } const date = new Date().toISOString().split('T')[0] - const exportScene = prepareSceneForExport(sceneGroup) + + 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 + // 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,37 +58,6 @@ export function ExportManager() { downloadBlob(blob, `model_${date}.obj`) return } - - // Default: GLB export (existing behavior) - const exporter = new GLTFExporter() - - // Compressed (KTX2/basis) textures must be decompressed during export or - // three r184's GLTFExporter throws "setTextureUtils() must be called". - // The app renders with WebGPURenderer, so use the WebGPU texture utils. - // We intentionally do NOT pass the live renderer: decompress() resizes - // whatever renderer it's given (and never restores it), which would - // corrupt the visible canvas. Omitting it lets three spin up and dispose - // its own throwaway renderer for the blit instead. - exporter.setTextureUtils({ - decompress: (texture, maxTextureSize) => - WebGPUTextureUtils.decompress(texture, maxTextureSize), - }) - - 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 }, - ) - }) } setExportScene(exportFn) @@ -83,33 +70,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/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/index.tsx b/packages/editor/src/index.tsx index 0fd27078e..68bfff0c2 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 @@ -265,6 +266,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 new file mode 100644 index 000000000..74b24f70e --- /dev/null +++ b/packages/editor/src/lib/glb-export.test.ts @@ -0,0 +1,267 @@ +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('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() + 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() + 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..bc80a2fb5 --- /dev/null +++ b/packages/editor/src/lib/glb-export.ts @@ -0,0 +1,629 @@ +import { + type AnyNode, + emitter, + getLevelDisplayName, + itemClipRegistry, + type LevelNode, + sceneRegistry, + type WindowNode, + type ZoneNode, +} from '@pascal-app/core' +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' +import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js' + +/** + * 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[] +} + +export async function exportSceneToGlb( + sceneGroup: Object3D, + 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 + + 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 + * `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) + + // 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 + // 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) + + 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, 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 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 + } + 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 + 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 + * 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 + // 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 + // 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 + 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) + : node.type === 'item' + ? bakeItemClip(id, target) + : null + + if (clip) { + clips.push(clip) + clipNamesByNode.set(id, [clip.name]) + } + } + + 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 + * 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. + */ +/** + * 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, + 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 } + // 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 + // 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 + } + // 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') { + const clipNames = clipNamesByNode.get(id) + if (clipNames?.length) { + extras.openable = true + 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 + // `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 + } + 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/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index a13c3c405..d1d26929a 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/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/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-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx new file mode 100644 index 000000000..df17bc1c2 --- /dev/null +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -0,0 +1,573 @@ +'use client' + +import { + type AnyNodeId, + type Interactive, + type LightEffect, + pointInPolygon, + type SceneGraph, + type SliderControl, + useInteractive, +} from '@pascal-app/core' +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 { 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 +} + +const _itemPos = new Vector3() + +/** + * 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 + zones: GlbZoneRef[] + /** 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 + // 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 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. + 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 ( + <> + + {animationItems.map((item) => ( + + ))} + {items.map((item) => { + const object = identity.get(item.pascalId) + return object ? ( + + ) : null + })} + + ) +} + +// ── 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 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) + .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 = scoreByKey.get(currentKey) ?? Number.POSITIVE_INFINITY + const newScore = scoreByKey.get(key) ?? 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. + // 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.intensity = MathUtils.lerp(light.intensity, 0, dt * 12) + if (light.intensity < 0.01) { + light.intensity = 0 + 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 = MathUtils.lerp(light.intensity, 0, dt * 12) + continue + } + const reg = regByKey.get(slot.key) + if (!reg) { + slot.key = null + 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] + light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12) + } + }) + + return ( + <> + {Array.from({ length: POOL_SIZE }, (_, i) => ( + { + lightRefs.current[i] = el + }} + /> + ))} + + ) +} + +/** 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 + * 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( + + {/* 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', + gap: 6, + background: 'rgba(0,0,0,0.75)', + backdropFilter: 'blur(8px)', + borderRadius: 8, + padding: '8px 12px', + minWidth: 120, + pointerEvents: visible ? 'auto' : 'none', + userSelect: 'none', + opacity: shown ? 1 : 0, + transition: `opacity ${FADE_MS}ms ease`, + }} + > + {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-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 new file mode 100644 index 000000000..3383b5998 --- /dev/null +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -0,0 +1,1053 @@ +'use client' + +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' +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 { 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 + +/** 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 } + +/** 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 + +/** 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 + 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 + 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. */ +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 _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'] = () => {} + +/** 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, + interactiveItems, + referenceNodes, + 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[] + /** 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 + onWalkthroughChange?: (state: GlbWalkthrough) => 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) + 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, 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({ + 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, + 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]) + // 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 + // 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 + // 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 + 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. 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, walkthroughMode } = useViewer.getState() + const selectedLevel = selection.levelId + levels.forEach(({ id, node, baseY }, index) => { + 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) + + // 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) => { + if (i === 0) shape.moveTo(x, -z) + else 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 [name, action] of Object.entries(actions)) { + if (!action) continue + // 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]) + + 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 state = useViewer.getState() + const { selection, outliner } = state + const t = Math.min(1, delta * 8) + const hoveredZoneId = hoveredTarget.current?.kind === 'zone' ? hoveredTarget.current.id : null + + // 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 mesh of occluderOwnMeshes) mesh.visible = !revealing + + for (const { id, levelId, meshes, uniforms } of zoneFills.current) { + 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) { + 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})` + } + } + + outliner.selectedObjects.length = 0 + outliner.hoveredObjects.length = 0 + if (walk) return + + const selectedObject = selection.selectedIds[0] + ? (identity.get(selection.selectedIds[0]) ?? null) + : null + if (selectedObject) outliner.selectedObjects.push(selectedObject) + // Rooms show hover via the fill brightness; everything else uses the outline. + 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, + ) + 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, walkthroughMode], + ) + + 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() + // 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, + ) + 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, 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: [] }) + } 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 mesh of occluderOwnMeshes) mesh.visible = true + }, + [occluderOwnMeshes], + ) + + return ( + + + {/* Re-light + re-animate the baked artifact from the DB scene graph, + joined to the baked nodes by pascalId. */} + {interactiveItems?.length ? ( + + ) : 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. */} + {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/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/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 562fc73ec..498ad03ea 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -13,6 +13,25 @@ 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 { + buildGlbInteractiveItems, + GlbInteractive, + type GlbInteractiveItem, +} from './components/viewer/glb-interactive' +export { buildGlbReferenceNodes } from './components/viewer/glb-reference-nodes' +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, @@ -157,6 +176,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 626090ac5..ea3413c56 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1093,6 +1093,7 @@ function addDoorLeaf( hingeX, hingeSide, swingRotation, + openRotationY, segments, contentPadding, handle, @@ -1118,6 +1119,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 @@ -1148,6 +1153,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 = ( @@ -2462,6 +2471,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { hingeX: -insideWidth / 2, hingeSide: 'left', swingRotation: -clampedSwingAngle * swingDirectionSign, + openRotationY: (-Math.PI / 2) * swingDirectionSign, segments, contentPadding, handle, @@ -2490,6 +2500,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { hingeX: insideWidth / 2, hingeSide: 'right', swingRotation: clampedSwingAngle * swingDirectionSign, + openRotationY: (Math.PI / 2) * swingDirectionSign, segments, contentPadding, handle, @@ -2521,6 +2532,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/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 -} 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()