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:
@@ -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>()
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
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<typeof prepareSceneForExport>
|
||||
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<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)
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ParametricNodeRenderer } from './parametric-node-renderer'
|
||||
// on every render — that would create a new Suspense boundary each time.
|
||||
const lazyCache = new WeakMap<RendererSource<AnyNode>, ComponentType<{ node: AnyNode }>>()
|
||||
|
||||
function getRegistryRenderer(
|
||||
export function getRegistryRenderer(
|
||||
source: RendererSource<AnyNode>,
|
||||
): ComponentType<{ node: AnyNode }> | null {
|
||||
const cached = lazyCache.get(source)
|
||||
|
||||
+2
-1
@@ -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'
|
||||
@@ -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 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). Built whenever either is active.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
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 (
|
||||
<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,
|
||||
} 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()
|
||||
|
||||
Reference in New Issue
Block a user