Merge pull request #441 from pascalorg/feat/baked-glb-export

viewer: baked GLB export + GLB-consuming /viewer (lights, clips, perf)
This commit is contained in:
Wassim SAMAD
2026-06-25 12:39:58 -04:00
committed by GitHub
21 changed files with 3238 additions and 159 deletions
@@ -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<string, ItemClipEntry>()
+1
View File
@@ -35,6 +35,7 @@ export type {
ZoneEvent, ZoneEvent,
} from './events/bus' } from './events/bus'
export { emitter, eventSuffixes } from './events/bus' export { emitter, eventSuffixes } from './events/bus'
export { type ItemClipEntry, itemClipRegistry } from './hooks/scene-registry/item-clip-registry'
export { export {
sceneRegistry, sceneRegistry,
useRegistry, useRegistry,
@@ -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
}
@@ -1,13 +1,12 @@
'use client' 'use client'
import { emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useEffect } from 'react' 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 { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.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() { export function ExportManager() {
const scene = useThree((state) => state.scene) const scene = useThree((state) => state.scene)
@@ -23,7 +22,26 @@ export function ExportManager() {
} }
const date = new Date().toISOString().split('T')[0] 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<typeof prepareSceneForExport>
try {
prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
} finally {
emitter.emit('thumbnail:after-capture', undefined)
}
const { scene: exportScene, animations } = prepared
if (format === 'stl') { if (format === 'stl') {
const exporter = new STLExporter() const exporter = new STLExporter()
@@ -40,37 +58,6 @@ export function ExportManager() {
downloadBlob(blob, `model_${date}.obj`) downloadBlob(blob, `model_${date}.obj`)
return 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<void>((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) setExportScene(exportFn)
@@ -83,33 +70,6 @@ export function ExportManager() {
return null 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) { function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
@@ -45,6 +45,7 @@ import {
} from 'three' } from 'three'
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
import '../../three-types' import '../../three-types'
import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
import { import {
closeDoorOpenState, closeDoorOpenState,
DOOR_SWING_OPEN_ANGLE, DOOR_SWING_OPEN_ANGLE,
@@ -64,8 +65,6 @@ import {
type FirstPersonColliderWorld, type FirstPersonColliderWorld,
type FirstPersonSpawn, type FirstPersonSpawn,
} from './first-person/build-collider-world' } 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 CAMERA_EYE_OFFSET = 0.45
const LOOK_SENSITIVITY = 0.002 const LOOK_SENSITIVITY = 0.002
+2
View File
@@ -11,6 +11,7 @@ export { default as Editor } from './components/editor'
// they're referenced throughout the editor's own internals; the public // they're referenced throughout the editor's own internals; the public
// surface uses the shorter, shell-friendly names from the unified // surface uses the shorter, shell-friendly names from the unified
// preset-system spec. // preset-system spec.
export { BakeExporter } from './components/editor/bake-exporter'
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu' export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
// Embed surface — the editor's real in-canvas affordances, so a host can mount // 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 // authentic selection handles, interactive build tools, and the mover on top
@@ -265,6 +266,7 @@ export {
getFloorplanWallThickness, getFloorplanWallThickness,
} from './lib/floorplan' } from './lib/floorplan'
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
export { exportSceneToGlb } from './lib/glb-export'
export { export {
buildResetSurfaceMaterialUpdates, buildResetSurfaceMaterialUpdates,
buildRoofSurfaceMaterialPatch, buildRoofSurfaceMaterialPatch,
+267
View File
@@ -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<string, unknown> = {}) {
// 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<string, AnyNode> = {
[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<string, AnyNode> = {
[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<string, AnyNode> = {
[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<string, AnyNode> = {
[zoneId]: {
object: 'node',
id: zoneId,
type: 'zone',
name: 'Living Room',
polygon,
color: '#ff0000',
} as unknown as AnyNode,
}
const { scene } = prepareSceneForExport(root, nodes)
const exported = scene.getObjectByProperty('name', zoneId)
expect(exported).toBeDefined()
// Forced visible so GLTFExporter's onlyVisible keeps the metadata node.
expect(exported?.visible).toBe(true)
expect(exported?.userData).toEqual({
pascalId: zoneId,
kind: 'zone',
label: 'Living Room',
polygon,
color: '#ff0000',
})
// The ZONE_LAYER fill mesh must not survive (rebuilt in /viewer instead).
let hasMesh = false
exported?.traverse((o) => {
if ((o as THREE.Mesh).isMesh) hasMesh = true
})
expect(hasMesh).toBe(false)
})
test('bakes a swing door into an open quaternion clip', () => {
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<string, AnyNode> = {
[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)
})
})
+629
View File
@@ -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<string, AnyNode>,
): Promise<ArrayBuffer> {
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<typeof prepareSceneForExport>
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<ArrayBuffer>((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<string, AnyNode>,
): 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<THREE.Object3D>()
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<THREE.Object3D, THREE.Object3D> {
const originals: THREE.Object3D[] = []
const clones: THREE.Object3D[] = []
source.traverse((object) => originals.push(object))
clone.traverse((object) => clones.push(object))
const map = new Map<THREE.Object3D, THREE.Object3D>()
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<THREE.Object3D>) {
const toRemove: THREE.Object3D[] = []
root.traverse((object) => {
// Editor-only overlays (gizmos, selection handles, ground grid, zone fills)
// live off the scene layer; the editor camera 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<THREE.Material, THREE.Material>()
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, THREE.Material>,
): 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<string, unknown>
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<string, THREE.Texture>)[slot] = texture
}
}
cache.set(material, target)
return target
}
// --- Animation clip baking ----------------------------------------------
function bakeAnimationClips(
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
nodes: Record<string, AnyNode>,
): { clips: THREE.AnimationClip[]; clipNamesByNode: Map<string, string[]> } {
const clips: THREE.AnimationClip[] = []
const clipNamesByNode = new Map<string, string[]>()
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 (`<id>: 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<string, THREE.Object3D>()
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<THREE.Object3D, THREE.Object3D>,
nodes: Record<string, AnyNode>,
clipNamesByNode: Map<string, string[]>,
) {
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<string, unknown> = { 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
}
}
+15
View File
@@ -8,6 +8,7 @@ import {
type Interactive, type Interactive,
type ItemNode, type ItemNode,
isSlotMaterialName, isSlotMaterialName,
itemClipRegistry,
LIBRARY_MATERIAL_REF_PREFIX, LIBRARY_MATERIAL_REF_PREFIX,
type LightEffect, type LightEffect,
SCENE_MATERIAL_REF_PREFIX, SCENE_MATERIAL_REF_PREFIX,
@@ -387,6 +388,20 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
const lightEffects = const lightEffects =
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? [] 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. // 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. // Undo can unmount one item while another clone of the same asset still needs them.
return ( return (
@@ -8,7 +8,7 @@ import { ParametricNodeRenderer } from './parametric-node-renderer'
// on every render — that would create a new Suspense boundary each time. // on every render — that would create a new Suspense boundary each time.
const lazyCache = new WeakMap<RendererSource<AnyNode>, ComponentType<{ node: AnyNode }>>() const lazyCache = new WeakMap<RendererSource<AnyNode>, ComponentType<{ node: AnyNode }>>()
function getRegistryRenderer( export function getRegistryRenderer(
source: RendererSource<AnyNode>, source: RendererSource<AnyNode>,
): ComponentType<{ node: AnyNode }> | null { ): ComponentType<{ node: AnyNode }> | null {
const cached = lazyCache.get(source) const cached = lazyCache.get(source)
@@ -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 { TransformControls, useKeyboardControls } from '@react-three/drei'
import { type ThreeElements, useFrame, useThree } from '@react-three/fiber' import { type ThreeElements, useFrame, useThree } from '@react-three/fiber'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
@@ -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<string, Object3D>
zones: GlbZoneRef[]
/** Baked animation actions keyed by clip name — ambient item loops play from
* `<pascalId>: loop`. */
actions: Record<string, AnimationAction | null>
/** 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<GlbLightReg[]>(() => {
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 (
<>
<GlbItemLights levelIndexById={levelIndexById} regs={lightRegs} />
{animationItems.map((item) => (
<GlbItemAnimation actions={actions} item={item} key={item.pascalId} />
))}
{items.map((item) => {
const object = identity.get(item.pascalId)
return object ? (
<GlbItemControls
item={item}
key={item.pascalId}
object={object}
worldPolygon={worldPolygon}
/>
) : 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<string, number>,
interactiveState: ReturnType<typeof useInteractive.getState>,
): 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<string, number>
}) {
const lightRefs = useRef<Array<PointLight | null>>(Array.from({ length: POOL_SIZE }, () => null))
const slots = useRef<SlotRuntime[]>(
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<string, number>()
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<number>()
const assignedKeys = new Set<string>()
// 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) => (
<pointLight
castShadow={false}
intensity={0}
key={i}
ref={(el) => {
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<string, AnimationAction | null>
}) {
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 <Html>.
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(
<Html
center
distanceFactor={8}
eps={-1}
position={[0, item.height + 0.3, 0]}
zIndexRange={[20, 0]}
>
{/* Stop pointer/click events from reaching the canvas — otherwise R3F's
pointer-missed fires and deselects the zone the moment you toggle. */}
<div
onClick={(e) => 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) => (
<ControlWidget
control={control}
key={i}
onChange={(v) => setControlValue(item.pascalId, i, v)}
value={controlValues[i] ?? false}
/>
))}
</div>
</Html>,
object,
)
}
@@ -0,0 +1,63 @@
'use client'
import { type AnyNode, nodeRegistry, type RendererSource, type SceneGraph } from '@pascal-app/core'
import { createPortal } from '@react-three/fiber'
import { Suspense } from 'react'
import type { Object3D } from 'three'
import { getRegistryRenderer } from '../renderers/node-renderer'
/**
* Scans (LiDAR meshes) and guides (floorplan images) are stripped from the
* baked GLB — they're heavy reference assets stored elsewhere. The GLB viewer
* re-adds them at runtime from the scene graph, portaled into their parent
* level's baked node so they ride level stacking, using the same registry
* renderers as the parametric viewer. Privacy is enforced upstream: the page
* only includes nodes whose `show_*_public` flag (or owner/admin) allows it, so
* a disallowed asset is never even fetched.
*/
export function buildGlbReferenceNodes(
sceneGraph: SceneGraph | null | undefined,
allow: { scans: boolean; guides: boolean },
): AnyNode[] {
const nodes = sceneGraph?.nodes
if (!nodes) return []
const out: AnyNode[] = []
for (const raw of Object.values(nodes)) {
const node = raw as AnyNode
if (node.type === 'scan' && allow.scans) out.push(node)
else if (node.type === 'guide' && allow.guides) out.push(node)
}
return out
}
export function GlbReferenceNodes({
nodes,
identity,
}: {
nodes: AnyNode[]
identity: Map<string, Object3D>
}) {
return (
<>
{nodes.map((node) => {
const anchor = node.parentId ? identity.get(node.parentId) : undefined
return anchor ? <GlbReferenceNode anchor={anchor} key={node.id} node={node} /> : null
})}
</>
)
}
/** Render one scan/guide via its registry renderer, portaled into its parent
* level's baked Object3D (so the node's level-local transform resolves to the
* same world pose as the parametric scene). */
function GlbReferenceNode({ node, anchor }: { node: AnyNode; anchor: Object3D }) {
const source = nodeRegistry.get(node.type)?.renderer
const Renderer = source ? getRegistryRenderer(source as RendererSource<AnyNode>) : null
if (!Renderer) return null
return createPortal(
<Suspense fallback={null}>
<Renderer node={node} />
</Suspense>,
anchor,
)
}
File diff suppressed because it is too large Load Diff
@@ -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<keyof MovementInput, 'joystick'>; 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<GlbColliderWorld | null>(null)
const controllerRef = useRef<BVHEcctrlApi | null>(null)
const yawRef = useRef(0)
const pitchRef = useRef(0)
const [start, setStart] = useState<{ position: [number, number, number] } | null>(null)
const [world, setWorld] = useState<GlbColliderWorld | null>(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<void> | 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 (
<KeyboardControls map={keyboardMap}>
<BVHEcctrl
acceleration={26}
airDragFactor={0.3}
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderMeshes={[world.mesh]}
collisionCheckIteration={3}
collisionPushBackDamping={0.1}
collisionPushBackThreshold={0.001}
deceleration={30}
delay={0}
fallGravityFactor={4}
floatCheckType="BOTH"
floatDampingC={36}
floatHeight={0.5}
floatPullBackHeight={0.35}
floatSensorRadius={0.15}
floatSpringK={1200}
gravity={9.81}
jumpVel={6}
maxRunSpeed={5.5}
maxSlope={1.2}
maxWalkSpeed={4}
position={start.position}
ref={setControllerApi}
/>
</KeyboardControls>
)
}
@@ -338,7 +338,13 @@ const PostProcessingPasses = ({
const hasGeometry = scenePassColor.a const hasGeometry = scenePassColor.a
const contentAlpha = hasGeometry.max(zonePass.a) const contentAlpha = hasGeometry.max(zonePass.a)
let sceneColor = scenePassColor as unknown as ReturnType<typeof vec4> // Composite the zone-pass tint into the base scene so rooms show whether or
// not SSGI is enabled. When SSGI is on, the branch below overwrites this
// with its own zone-inclusive composite (no double-add).
let sceneColor = vec4(
add(scenePassColor.rgb, zonePass.rgb),
contentAlpha,
) as unknown as ReturnType<typeof vec4>
// Depth + normal MRT — shared by SSGI (diffuse/normal) and the ink pass // Depth + normal MRT — shared by SSGI (diffuse/normal) and the ink pass
// (depth/normal). Built whenever either is active. // (depth/normal). Built whenever either is active.
+23 -1
View File
@@ -13,6 +13,25 @@ export { ErrorBoundary } from './components/error-boundary'
// — no per-kind re-exports needed. // — no per-kind re-exports needed.
export { NodeRenderer } from './components/renderers/node-renderer' export { NodeRenderer } from './components/renderers/node-renderer'
export { default as Viewer, type ViewerHandle } from './components/viewer' export { default as Viewer, type ViewerHandle } from './components/viewer'
export {
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 type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
export { export {
DEFAULT_HOVER_STYLES, 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 // 800+ lines of CSG / mitering logic during Phase 3. These exports are
// removed in Phase 6 when the legacy mount points are deleted. // removed in Phase 6 when the legacy mount points are deleted.
export { WallSystem } from './systems/wall/wall-system' 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 { buildWindowPreviewMesh, WindowSystem } from './systems/window/window-system'
export { ZoneSystem } from './systems/zone/zone-system' export { ZoneSystem } from './systems/zone/zone-system'
@@ -1093,6 +1093,7 @@ function addDoorLeaf(
hingeX, hingeX,
hingeSide, hingeSide,
swingRotation, swingRotation,
openRotationY,
segments, segments,
contentPadding, contentPadding,
handle, handle,
@@ -1118,6 +1119,10 @@ function addDoorLeaf(
hingeX: number hingeX: number
hingeSide: 'left' | 'right' hingeSide: 'left' | 'right'
swingRotation: number 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'] segments: DoorNode['segments']
contentPadding: DoorNode['contentPadding'] contentPadding: DoorNode['contentPadding']
handle: boolean handle: boolean
@@ -1148,6 +1153,10 @@ function addDoorLeaf(
const leafGroup = new THREE.Group() const leafGroup = new THREE.Group()
leafGroup.position.set(hingeX, 0, 0) leafGroup.position.set(hingeX, 0, 0)
leafGroup.rotation.y = swingRotation 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) mesh.add(leafGroup)
const addLeafBox = ( const addLeafBox = (
@@ -2462,6 +2471,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
hingeX: -insideWidth / 2, hingeX: -insideWidth / 2,
hingeSide: 'left', hingeSide: 'left',
swingRotation: -clampedSwingAngle * swingDirectionSign, swingRotation: -clampedSwingAngle * swingDirectionSign,
openRotationY: (-Math.PI / 2) * swingDirectionSign,
segments, segments,
contentPadding, contentPadding,
handle, handle,
@@ -2490,6 +2500,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
hingeX: insideWidth / 2, hingeX: insideWidth / 2,
hingeSide: 'right', hingeSide: 'right',
swingRotation: clampedSwingAngle * swingDirectionSign, swingRotation: clampedSwingAngle * swingDirectionSign,
openRotationY: (Math.PI / 2) * swingDirectionSign,
segments, segments,
contentPadding, contentPadding,
handle, handle,
@@ -2521,6 +2532,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
hingeX, hingeX,
hingeSide: hingesSide, hingeSide: hingesSide,
swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign, swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign,
openRotationY: (Math.PI / 2) * swingDirectionSign * hingeDirectionSign,
segments, segments,
contentPadding, contentPadding,
handle, handle,
@@ -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 (
<button
onClick={() => onChange(!value)}
style={{
background: value ? '#4ade80' : '#374151',
color: 'white',
border: 'none',
borderRadius: 4,
padding: '4px 8px',
cursor: 'pointer',
fontSize: 12,
fontFamily: 'monospace',
transition: 'background 0.2s',
}}
>
{control.label ?? (value ? 'On' : 'Off')}
</button>
)
}
if (control.kind === 'slider') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}
{control.unit ? ` ${control.unit}` : ''}
</span>
<input
max={control.max}
min={control.min}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
step={control.step}
type="range"
value={value as number}
/>
</label>
)
}
if (control.kind === 'temperature') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}°{control.unit}
</span>
<input
max={control.max}
min={control.min}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
step={1}
type="range"
value={value as number}
/>
</label>
)
}
return null
}
@@ -2,8 +2,6 @@
import { import {
type AnyNodeId, type AnyNodeId,
type Control,
type ControlValue,
type ItemNode, type ItemNode,
pointInPolygon, pointInPolygon,
sceneRegistry, sceneRegistry,
@@ -17,6 +15,7 @@ import { useEffect, useState } from 'react'
import { type Object3D, Vector3 } from 'three' import { type Object3D, Vector3 } from 'three'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
import { ControlWidget } from './control-widget'
const _tempVec = new Vector3() const _tempVec = new Vector3()
@@ -146,86 +145,3 @@ const ItemControlsOverlay = ({
itemObj, 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 (
<button
onClick={() => onChange(!value)}
style={{
background: value ? '#4ade80' : '#374151',
color: 'white',
border: 'none',
borderRadius: 4,
padding: '4px 8px',
cursor: 'pointer',
fontSize: 12,
fontFamily: 'monospace',
transition: 'background 0.2s',
}}
>
{control.label ?? (value ? 'On' : 'Off')}
</button>
)
}
if (control.kind === 'slider') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}
{control.unit ? ` ${control.unit}` : ''}
</span>
<input
max={control.max}
min={control.min}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
step={control.step}
type="range"
value={value as number}
/>
</label>
)
}
if (control.kind === 'temperature') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}°{control.unit}
</span>
<input
max={control.max}
min={control.min}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
step={1}
type="range"
value={value as number}
/>
</label>
)
}
return null
}
@@ -7,6 +7,7 @@ import {
type WindowNode, type WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import type { Object3D } from 'three'
import { import {
AWNING_WINDOW_SASH_NAME, AWNING_WINDOW_SASH_NAME,
CASEMENT_WINDOW_SASH_NAME, CASEMENT_WINDOW_SASH_NAME,
@@ -28,12 +29,20 @@ function markWindowDirty(windowId: AnyNodeId) {
scene.dirtyNodes.add(windowId) scene.dirtyNodes.add(windowId)
} }
function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) { /**
const node = useScene.getState().nodes[windowId] * Pose a window's moving parts (sash/panel/slats) at `value` (0 = closed,
if (node?.type !== 'window') return false * 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.
const mesh = sceneRegistry.nodes.get(windowId) *
* 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') { if (node.windowType === 'sliding') {
const activePanel = mesh?.getObjectByName(SLIDING_WINDOW_ACTIVE_PANEL_NAME) const activePanel = mesh?.getObjectByName(SLIDING_WINDOW_ACTIVE_PANEL_NAME)
if (!activePanel) return false if (!activePanel) return false
@@ -120,6 +129,12 @@ function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) {
return false 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 = () => { export const WindowAnimationSystem = () => {
useFrame(({ clock }) => { useFrame(({ clock }) => {
const interactive = useInteractive.getState() const interactive = useInteractive.getState()