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,
|
ZoneEvent,
|
||||||
} from './events/bus'
|
} from './events/bus'
|
||||||
export { emitter, eventSuffixes } from './events/bus'
|
export { emitter, eventSuffixes } from './events/bus'
|
||||||
|
export { type ItemClipEntry, itemClipRegistry } from './hooks/scene-registry/item-clip-registry'
|
||||||
export {
|
export {
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useRegistry,
|
useRegistry,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useScene } from '@pascal-app/core'
|
||||||
|
import { useThree } from '@react-three/fiber'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { exportSceneToGlb } from '../../lib/glb-export'
|
||||||
|
|
||||||
|
export function BakeExporter({
|
||||||
|
active,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onComplete: (buffer: ArrayBuffer) => void
|
||||||
|
onError: (message: string) => void
|
||||||
|
}) {
|
||||||
|
const scene = useThree((s) => s.scene)
|
||||||
|
const doneRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(active && !doneRef.current)) return
|
||||||
|
doneRef.current = true
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const sceneGroup = scene.getObjectByName('scene-renderer')
|
||||||
|
if (!sceneGroup) throw new Error('scene-renderer group not found')
|
||||||
|
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
|
||||||
|
onComplete(buffer)
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void run()
|
||||||
|
}, [active, scene, onComplete, onError])
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { emitter, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import type { Mesh, Object3D } from 'three'
|
|
||||||
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
|
||||||
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
||||||
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
||||||
import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
|
import { exportSceneToGlb, prepareSceneForExport } from '../../lib/glb-export'
|
||||||
|
|
||||||
export function ExportManager() {
|
export function ExportManager() {
|
||||||
const scene = useThree((state) => state.scene)
|
const scene = useThree((state) => state.scene)
|
||||||
@@ -23,7 +22,26 @@ export function ExportManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date().toISOString().split('T')[0]
|
const date = new Date().toISOString().split('T')[0]
|
||||||
const exportScene = prepareSceneForExport(sceneGroup)
|
|
||||||
|
if (format === 'glb') {
|
||||||
|
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
|
||||||
|
const blob = new Blob([buffer], { type: 'model/gltf-binary' })
|
||||||
|
downloadBlob(blob, `model_${date}.glb`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide editor affordances that live on the scene layer (selection handles,
|
||||||
|
// ceiling/site brackets) and let wall-cutout reveal all walls — the same
|
||||||
|
// synchronous capture path thumbnails use. We clone the scene inside the
|
||||||
|
// window, so the export snapshots the clean building, then restore.
|
||||||
|
emitter.emit('thumbnail:before-capture', undefined)
|
||||||
|
let prepared: ReturnType<typeof prepareSceneForExport>
|
||||||
|
try {
|
||||||
|
prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
|
||||||
|
} finally {
|
||||||
|
emitter.emit('thumbnail:after-capture', undefined)
|
||||||
|
}
|
||||||
|
const { scene: exportScene, animations } = prepared
|
||||||
|
|
||||||
if (format === 'stl') {
|
if (format === 'stl') {
|
||||||
const exporter = new STLExporter()
|
const exporter = new STLExporter()
|
||||||
@@ -40,37 +58,6 @@ export function ExportManager() {
|
|||||||
downloadBlob(blob, `model_${date}.obj`)
|
downloadBlob(blob, `model_${date}.obj`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: GLB export (existing behavior)
|
|
||||||
const exporter = new GLTFExporter()
|
|
||||||
|
|
||||||
// Compressed (KTX2/basis) textures must be decompressed during export or
|
|
||||||
// three r184's GLTFExporter throws "setTextureUtils() must be called".
|
|
||||||
// The app renders with WebGPURenderer, so use the WebGPU texture utils.
|
|
||||||
// We intentionally do NOT pass the live renderer: decompress() resizes
|
|
||||||
// whatever renderer it's given (and never restores it), which would
|
|
||||||
// corrupt the visible canvas. Omitting it lets three spin up and dispose
|
|
||||||
// its own throwaway renderer for the blit instead.
|
|
||||||
exporter.setTextureUtils({
|
|
||||||
decompress: (texture, maxTextureSize) =>
|
|
||||||
WebGPUTextureUtils.decompress(texture, maxTextureSize),
|
|
||||||
})
|
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
exporter.parse(
|
|
||||||
exportScene,
|
|
||||||
(gltf) => {
|
|
||||||
const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
|
|
||||||
downloadBlob(blob, `model_${date}.glb`)
|
|
||||||
resolve()
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
console.error('Export error:', error)
|
|
||||||
reject(error)
|
|
||||||
},
|
|
||||||
{ binary: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setExportScene(exportFn)
|
setExportScene(exportFn)
|
||||||
@@ -83,33 +70,6 @@ export function ExportManager() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareSceneForExport(source: Object3D) {
|
|
||||||
const clone = source.clone(true)
|
|
||||||
const meshesToRemove: Mesh[] = []
|
|
||||||
|
|
||||||
clone.traverse((object) => {
|
|
||||||
if (isMeshWithInvalidGeometry(object)) meshesToRemove.push(object)
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const mesh of meshesToRemove) {
|
|
||||||
mesh.removeFromParent()
|
|
||||||
}
|
|
||||||
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMeshWithInvalidGeometry(object: Object3D): object is Mesh {
|
|
||||||
if (!isMesh(object)) return false
|
|
||||||
|
|
||||||
// Three exporters can crash when a Mesh has no readable position attribute.
|
|
||||||
const position = object.geometry?.getAttribute('position')
|
|
||||||
return !position || position.count === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMesh(object: Object3D): object is Mesh {
|
|
||||||
return (object as Mesh).isMesh === true
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadBlob(blob: Blob, filename: string) {
|
function downloadBlob(blob: Blob, filename: string) {
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
} from 'three'
|
} from 'three'
|
||||||
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
||||||
import '../../three-types'
|
import '../../three-types'
|
||||||
|
import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
|
||||||
import {
|
import {
|
||||||
closeDoorOpenState,
|
closeDoorOpenState,
|
||||||
DOOR_SWING_OPEN_ANGLE,
|
DOOR_SWING_OPEN_ANGLE,
|
||||||
@@ -64,8 +65,6 @@ import {
|
|||||||
type FirstPersonColliderWorld,
|
type FirstPersonColliderWorld,
|
||||||
type FirstPersonSpawn,
|
type FirstPersonSpawn,
|
||||||
} from './first-person/build-collider-world'
|
} from './first-person/build-collider-world'
|
||||||
import type { BVHEcctrlApi, MovementInput } from './first-person/bvh-ecctrl'
|
|
||||||
import BVHEcctrl from './first-person/bvh-ecctrl'
|
|
||||||
|
|
||||||
const CAMERA_EYE_OFFSET = 0.45
|
const CAMERA_EYE_OFFSET = 0.45
|
||||||
const LOOK_SENSITIVITY = 0.002
|
const LOOK_SENSITIVITY = 0.002
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export { default as Editor } from './components/editor'
|
|||||||
// they're referenced throughout the editor's own internals; the public
|
// they're referenced throughout the editor's own internals; the public
|
||||||
// surface uses the shorter, shell-friendly names from the unified
|
// surface uses the shorter, shell-friendly names from the unified
|
||||||
// preset-system spec.
|
// preset-system spec.
|
||||||
|
export { BakeExporter } from './components/editor/bake-exporter'
|
||||||
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
|
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
|
||||||
// Embed surface — the editor's real in-canvas affordances, so a host can mount
|
// Embed surface — the editor's real in-canvas affordances, so a host can mount
|
||||||
// authentic selection handles, interactive build tools, and the mover on top
|
// authentic selection handles, interactive build tools, and the mover on top
|
||||||
@@ -265,6 +266,7 @@ export {
|
|||||||
getFloorplanWallThickness,
|
getFloorplanWallThickness,
|
||||||
} from './lib/floorplan'
|
} from './lib/floorplan'
|
||||||
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
|
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
|
||||||
|
export { exportSceneToGlb } from './lib/glb-export'
|
||||||
export {
|
export {
|
||||||
buildResetSurfaceMaterialUpdates,
|
buildResetSurfaceMaterialUpdates,
|
||||||
buildRoofSurfaceMaterialPatch,
|
buildRoofSurfaceMaterialPatch,
|
||||||
|
|||||||
@@ -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 Interactive,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
isSlotMaterialName,
|
isSlotMaterialName,
|
||||||
|
itemClipRegistry,
|
||||||
LIBRARY_MATERIAL_REF_PREFIX,
|
LIBRARY_MATERIAL_REF_PREFIX,
|
||||||
type LightEffect,
|
type LightEffect,
|
||||||
SCENE_MATERIAL_REF_PREFIX,
|
SCENE_MATERIAL_REF_PREFIX,
|
||||||
@@ -387,6 +388,20 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
const lightEffects =
|
const lightEffects =
|
||||||
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
|
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
|
||||||
|
|
||||||
|
// Expose this item's ambient clip (e.g. a fan's spin) to the GLB bake. The
|
||||||
|
// catalog GLB owns the clip; it isn't in the scene graph, so the export can't
|
||||||
|
// find it without this registry. The bake retargets it onto the baked subtree.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!animEffect) return
|
||||||
|
const clipName = animEffect.clips.on ?? animEffect.clips.loop
|
||||||
|
const clip = clipName ? animations.find((c) => c.name === clipName) : undefined
|
||||||
|
if (!clip) return
|
||||||
|
itemClipRegistry.set(node.id, { clip, loop: true })
|
||||||
|
return () => {
|
||||||
|
itemClipRegistry.delete(node.id)
|
||||||
|
}
|
||||||
|
}, [node.id, animEffect, animations])
|
||||||
|
|
||||||
// useGLTF caches scenes, and Clone shares child geometry/material references.
|
// useGLTF caches scenes, and Clone shares child geometry/material references.
|
||||||
// Undo can unmount one item while another clone of the same asset still needs them.
|
// Undo can unmount one item while another clone of the same asset still needs them.
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ParametricNodeRenderer } from './parametric-node-renderer'
|
|||||||
// on every render — that would create a new Suspense boundary each time.
|
// on every render — that would create a new Suspense boundary each time.
|
||||||
const lazyCache = new WeakMap<RendererSource<AnyNode>, ComponentType<{ node: AnyNode }>>()
|
const lazyCache = new WeakMap<RendererSource<AnyNode>, ComponentType<{ node: AnyNode }>>()
|
||||||
|
|
||||||
function getRegistryRenderer(
|
export function getRegistryRenderer(
|
||||||
source: RendererSource<AnyNode>,
|
source: RendererSource<AnyNode>,
|
||||||
): ComponentType<{ node: AnyNode }> | null {
|
): ComponentType<{ node: AnyNode }> | null {
|
||||||
const cached = lazyCache.get(source)
|
const cached = lazyCache.get(source)
|
||||||
|
|||||||
+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 { TransformControls, useKeyboardControls } from '@react-three/drei'
|
||||||
import { type ThreeElements, useFrame, useThree } from '@react-three/fiber'
|
import { type ThreeElements, useFrame, useThree } from '@react-three/fiber'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type Interactive,
|
||||||
|
type LightEffect,
|
||||||
|
pointInPolygon,
|
||||||
|
type SceneGraph,
|
||||||
|
type SliderControl,
|
||||||
|
useInteractive,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
|
import { createPortal, useFrame } from '@react-three/fiber'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
type AnimationAction,
|
||||||
|
LoopRepeat,
|
||||||
|
MathUtils,
|
||||||
|
type Object3D,
|
||||||
|
type PointLight,
|
||||||
|
Vector3,
|
||||||
|
} from 'three'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import useViewer from '../../store/use-viewer'
|
||||||
|
import { ControlWidget } from '../../systems/interactive/control-widget'
|
||||||
|
|
||||||
|
/** An interactive item recovered from the scene graph so the baked GLB can be
|
||||||
|
* re-lit / re-animated by joining on `pascalId`. The GLB carries the geometry
|
||||||
|
* + identity; the effects + controls live in the DB scene graph (no sidecar). */
|
||||||
|
export type GlbInteractiveItem = {
|
||||||
|
pascalId: AnyNodeId
|
||||||
|
label: string
|
||||||
|
/** Item height (world units) for placing the controls overlay above it. */
|
||||||
|
height: number
|
||||||
|
interactive: Interactive
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A baked zone's identity node + its local floor polygon (from `extras`). */
|
||||||
|
export type GlbZoneRef = {
|
||||||
|
id: string
|
||||||
|
node: Object3D
|
||||||
|
polygon: [number, number][]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the interactive items out of a scene graph. Only items that actually
|
||||||
|
* carry effects (light / animation) are returned — everything else baked
|
||||||
|
* faithfully and needs no runtime help. */
|
||||||
|
export function buildGlbInteractiveItems(
|
||||||
|
sceneGraph: SceneGraph | null | undefined,
|
||||||
|
): GlbInteractiveItem[] {
|
||||||
|
const nodes = sceneGraph?.nodes
|
||||||
|
if (!nodes) return []
|
||||||
|
const items: GlbInteractiveItem[] = []
|
||||||
|
for (const [id, raw] of Object.entries(nodes)) {
|
||||||
|
const node = raw as {
|
||||||
|
type?: string
|
||||||
|
scale?: [number, number, number]
|
||||||
|
asset?: { name?: string; dimensions?: [number, number, number]; interactive?: Interactive }
|
||||||
|
}
|
||||||
|
if (node?.type !== 'item') continue
|
||||||
|
const interactive = node.asset?.interactive
|
||||||
|
if (!interactive?.effects?.length) continue
|
||||||
|
const dims = node.asset?.dimensions ?? [1, 1, 1]
|
||||||
|
const scaleY = node.scale?.[1] ?? 1
|
||||||
|
items.push({
|
||||||
|
pascalId: id as AnyNodeId,
|
||||||
|
label: node.asset?.name ?? id,
|
||||||
|
height: (dims[1] ?? 1) * scaleY,
|
||||||
|
interactive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
const _itemPos = new Vector3()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-creates the item-driven interactivity the parametric viewer has — pooled
|
||||||
|
* lights, ambient animation, and the controls overlay — on top of a baked GLB.
|
||||||
|
* Effects come from the DB scene graph (`items`); world transforms come from the
|
||||||
|
* baked Object3Ds (`identity`), joined on `pascalId`. Nothing is stamped into
|
||||||
|
* the GLB itself, so the artifact stays integrator-clean.
|
||||||
|
*/
|
||||||
|
export function GlbInteractive({
|
||||||
|
items,
|
||||||
|
identity,
|
||||||
|
zones,
|
||||||
|
actions,
|
||||||
|
levelOrder,
|
||||||
|
}: {
|
||||||
|
items: GlbInteractiveItem[]
|
||||||
|
identity: Map<string, Object3D>
|
||||||
|
zones: GlbZoneRef[]
|
||||||
|
/** Baked animation actions keyed by clip name — ambient item loops play from
|
||||||
|
* `<pascalId>: loop`. */
|
||||||
|
actions: Record<string, AnimationAction | null>
|
||||||
|
/** Level pascalIds bottom-to-top, so the light pool can prefer ground-floor
|
||||||
|
* lights when nothing is focused (mirrors the parametric level factor). */
|
||||||
|
levelOrder: string[]
|
||||||
|
}) {
|
||||||
|
// Seed control state for every interactive item. The viewer shows a baked
|
||||||
|
// scene "lit": toggles default ON (the editor defaults them off) and sliders
|
||||||
|
// to their authored default, so lamps glow and fans spin on load. Explicit
|
||||||
|
// overlay toggles then win. Cleared on unmount so the global store never
|
||||||
|
// carries state across scenes.
|
||||||
|
useEffect(() => {
|
||||||
|
const store = useInteractive.getState()
|
||||||
|
for (const item of items) {
|
||||||
|
store.initItem(item.pascalId, item.interactive)
|
||||||
|
item.interactive.controls.forEach((control, i) => {
|
||||||
|
if (control.kind === 'toggle') store.setControlValue(item.pascalId, i, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
const store = useInteractive.getState()
|
||||||
|
for (const item of items) store.removeItem(item.pascalId)
|
||||||
|
}
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const animationItems = useMemo(
|
||||||
|
() => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'animation')),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Light registrations: one per item with a light effect, joined to its baked
|
||||||
|
// node. Fed to a fixed pool (below) rather than mounting a light per item.
|
||||||
|
const lightRegs = useMemo<GlbLightReg[]>(() => {
|
||||||
|
const regs: GlbLightReg[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
const effect = item.interactive.effects.find((e) => e.kind === 'light') as
|
||||||
|
| LightEffect
|
||||||
|
| undefined
|
||||||
|
if (!effect) continue
|
||||||
|
const object = identity.get(item.pascalId)
|
||||||
|
if (!object) continue
|
||||||
|
const controls = item.interactive.controls
|
||||||
|
const toggleIndex = controls.findIndex((c) => c.kind === 'toggle')
|
||||||
|
const sliderIndex = controls.findIndex((c) => c.kind === 'slider')
|
||||||
|
const slider = sliderIndex >= 0 ? (controls[sliderIndex] as SliderControl) : null
|
||||||
|
regs.push({
|
||||||
|
key: item.pascalId,
|
||||||
|
object,
|
||||||
|
effect,
|
||||||
|
toggleIndex,
|
||||||
|
sliderIndex,
|
||||||
|
hasSlider: !!slider,
|
||||||
|
sliderMin: slider?.min ?? 0,
|
||||||
|
sliderMax: slider?.max ?? 1,
|
||||||
|
levelId: findLevelId(object),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return regs
|
||||||
|
}, [items, identity])
|
||||||
|
const levelIndexById = useMemo(
|
||||||
|
() => new Map(levelOrder.map((id, i) => [id, i] as const)),
|
||||||
|
[levelOrder],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Controls overlay is scoped to the focused zone (matches the parametric
|
||||||
|
// viewer). Project the zone's baked-local polygon into world space once so an
|
||||||
|
// item's world position can be point-tested regardless of level stacking.
|
||||||
|
const focusedZoneId = useViewer((s) => s.selection.zoneId)
|
||||||
|
const worldPolygon = useMemo<[number, number][] | null>(() => {
|
||||||
|
if (!focusedZoneId) return null
|
||||||
|
const zone = zones.find((z) => z.id === focusedZoneId)
|
||||||
|
if (!zone) return null
|
||||||
|
zone.node.updateWorldMatrix(true, false)
|
||||||
|
return zone.polygon.map(([x, z]) => {
|
||||||
|
const v = new Vector3(x, 0, z).applyMatrix4(zone.node.matrixWorld)
|
||||||
|
return [v.x, v.z]
|
||||||
|
})
|
||||||
|
}, [focusedZoneId, zones])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GlbItemLights levelIndexById={levelIndexById} regs={lightRegs} />
|
||||||
|
{animationItems.map((item) => (
|
||||||
|
<GlbItemAnimation actions={actions} item={item} key={item.pascalId} />
|
||||||
|
))}
|
||||||
|
{items.map((item) => {
|
||||||
|
const object = identity.get(item.pascalId)
|
||||||
|
return object ? (
|
||||||
|
<GlbItemControls
|
||||||
|
item={item}
|
||||||
|
key={item.pascalId}
|
||||||
|
object={object}
|
||||||
|
worldPolygon={worldPolygon}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pooled item lights ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Mirrors the parametric `ItemLightSystem`: a fixed pool of point lights is
|
||||||
|
// assigned to the nearest/most-visible lit items each tick (camera-proximity
|
||||||
|
// scored, with hysteresis), snapped to the item's world position + offset, and
|
||||||
|
// faded in/out on reassignment. Mounting a light per item instead would blow
|
||||||
|
// the renderer's light budget on a large house.
|
||||||
|
|
||||||
|
const POOL_SIZE = 12
|
||||||
|
const REASSIGN_INTERVAL = 0.2
|
||||||
|
const HYSTERESIS = 0.15
|
||||||
|
const CAM_MOVE_DIST = 0.5
|
||||||
|
const CAM_ROT_DOT = 0.995
|
||||||
|
|
||||||
|
type GlbLightReg = {
|
||||||
|
key: AnyNodeId
|
||||||
|
object: Object3D
|
||||||
|
effect: LightEffect
|
||||||
|
toggleIndex: number
|
||||||
|
sliderIndex: number
|
||||||
|
hasSlider: boolean
|
||||||
|
sliderMin: number
|
||||||
|
sliderMax: number
|
||||||
|
levelId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlotRuntime = { key: string | null; pendingKey: string | null; isFadingOut: boolean }
|
||||||
|
|
||||||
|
const _camPos = new Vector3()
|
||||||
|
const _camFwd = new Vector3()
|
||||||
|
const _dir = new Vector3()
|
||||||
|
const _lightWorld = new Vector3()
|
||||||
|
|
||||||
|
/** The nearest level-identity ancestor's pascalId, for the level factor. */
|
||||||
|
function findLevelId(object: Object3D): string | null {
|
||||||
|
let cur: Object3D | null = object
|
||||||
|
while (cur) {
|
||||||
|
const ud = cur.userData as { kind?: string; pascalId?: string }
|
||||||
|
if (ud.kind === 'level' && ud.pascalId) return ud.pascalId
|
||||||
|
cur = cur.parent
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreReg(
|
||||||
|
reg: GlbLightReg,
|
||||||
|
selectedLevelId: string | null,
|
||||||
|
levelMode: string,
|
||||||
|
levelIndexById: Map<string, number>,
|
||||||
|
interactiveState: ReturnType<typeof useInteractive.getState>,
|
||||||
|
): number {
|
||||||
|
// Toggled-off lights contribute no illumination — drop them from the pool.
|
||||||
|
if (reg.toggleIndex >= 0 && !interactiveState.items[reg.key]?.controlValues?.[reg.toggleIndex]) {
|
||||||
|
return Number.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
reg.object.getWorldPosition(_lightWorld)
|
||||||
|
_lightWorld.x += reg.effect.offset[0]
|
||||||
|
_lightWorld.y += reg.effect.offset[1]
|
||||||
|
_lightWorld.z += reg.effect.offset[2]
|
||||||
|
_dir.copy(_lightWorld).sub(_camPos).normalize()
|
||||||
|
const angular = 1 - _camFwd.dot(_dir)
|
||||||
|
const dist = _camPos.distanceTo(_lightWorld) / 200
|
||||||
|
let levelPenalty = 0
|
||||||
|
if (selectedLevelId) {
|
||||||
|
if (reg.levelId !== selectedLevelId) levelPenalty = levelMode === 'solo' ? 100 : 0.8
|
||||||
|
} else if (reg.levelId && (levelIndexById.get(reg.levelId) ?? 0) !== 0) {
|
||||||
|
levelPenalty = 0.3
|
||||||
|
}
|
||||||
|
return angular * 0.7 + dist * 0.3 + levelPenalty
|
||||||
|
}
|
||||||
|
|
||||||
|
function GlbItemLights({
|
||||||
|
regs,
|
||||||
|
levelIndexById,
|
||||||
|
}: {
|
||||||
|
regs: GlbLightReg[]
|
||||||
|
levelIndexById: Map<string, number>
|
||||||
|
}) {
|
||||||
|
const lightRefs = useRef<Array<PointLight | null>>(Array.from({ length: POOL_SIZE }, () => null))
|
||||||
|
const slots = useRef<SlotRuntime[]>(
|
||||||
|
Array.from({ length: POOL_SIZE }, () => ({ key: null, pendingKey: null, isFadingOut: false })),
|
||||||
|
)
|
||||||
|
const reassignTimer = useRef(0)
|
||||||
|
const prevCamPos = useRef(new Vector3())
|
||||||
|
const prevCamFwd = useRef(new Vector3(0, 0, -1))
|
||||||
|
const regByKey = useMemo(() => new Map(regs.map((r) => [r.key as string, r])), [regs])
|
||||||
|
|
||||||
|
useFrame(({ camera }, delta) => {
|
||||||
|
const dt = Math.min(delta, 0.1)
|
||||||
|
const interactiveState = useInteractive.getState()
|
||||||
|
camera.getWorldPosition(_camPos)
|
||||||
|
camera.getWorldDirection(_camFwd)
|
||||||
|
|
||||||
|
const camMoved =
|
||||||
|
_camPos.distanceTo(prevCamPos.current) > CAM_MOVE_DIST ||
|
||||||
|
_camFwd.dot(prevCamFwd.current) < CAM_ROT_DOT
|
||||||
|
reassignTimer.current -= delta
|
||||||
|
|
||||||
|
if (reassignTimer.current <= 0 || camMoved) {
|
||||||
|
reassignTimer.current = REASSIGN_INTERVAL
|
||||||
|
prevCamPos.current.copy(_camPos)
|
||||||
|
prevCamFwd.current.copy(_camFwd)
|
||||||
|
const viewer = useViewer.getState()
|
||||||
|
const selectedLevelId = viewer.selection.levelId
|
||||||
|
const levelMode = viewer.levelMode
|
||||||
|
|
||||||
|
const scored = regs.map((reg) => ({
|
||||||
|
key: reg.key as string,
|
||||||
|
score: scoreReg(reg, selectedLevelId, levelMode, levelIndexById, interactiveState),
|
||||||
|
}))
|
||||||
|
scored.sort((a, b) => a.score - b.score)
|
||||||
|
const scoreByKey = new Map(scored.map((s) => [s.key, s.score] as const))
|
||||||
|
const desired = scored
|
||||||
|
.filter((s) => Number.isFinite(s.score))
|
||||||
|
.slice(0, POOL_SIZE)
|
||||||
|
.map((s) => s.key)
|
||||||
|
|
||||||
|
const currentlyAssigned = new Map<string, number>()
|
||||||
|
for (let i = 0; i < POOL_SIZE; i++) {
|
||||||
|
const s = slots.current[i]
|
||||||
|
const k = s?.key ?? s?.pendingKey
|
||||||
|
if (k) currentlyAssigned.set(k, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedSlots = new Set<number>()
|
||||||
|
const assignedKeys = new Set<string>()
|
||||||
|
// Pass 1: keep existing slots whose key is still wanted.
|
||||||
|
for (const key of desired) {
|
||||||
|
const existingSlot = currentlyAssigned.get(key)
|
||||||
|
if (existingSlot !== undefined && !usedSlots.has(existingSlot)) {
|
||||||
|
usedSlots.add(existingSlot)
|
||||||
|
assignedKeys.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pass 2: assign the rest to free slots, evicting only on a clear win.
|
||||||
|
let freeSlot = 0
|
||||||
|
for (const key of desired) {
|
||||||
|
if (assignedKeys.has(key)) continue
|
||||||
|
while (freeSlot < POOL_SIZE && usedSlots.has(freeSlot)) freeSlot++
|
||||||
|
if (freeSlot >= POOL_SIZE) break
|
||||||
|
|
||||||
|
const freeSlotData = slots.current[freeSlot]
|
||||||
|
const currentKey = freeSlotData ? (freeSlotData.key ?? freeSlotData.pendingKey) : null
|
||||||
|
if (currentKey && !desired.includes(currentKey)) {
|
||||||
|
const currentScore = scoreByKey.get(currentKey) ?? Number.POSITIVE_INFINITY
|
||||||
|
const newScore = scoreByKey.get(key) ?? 0
|
||||||
|
if (currentScore - newScore < HYSTERESIS) {
|
||||||
|
freeSlot++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usedSlots.add(freeSlot)
|
||||||
|
assignedKeys.add(key)
|
||||||
|
const slot = slots.current[freeSlot]
|
||||||
|
if (slot && slot.key !== key) {
|
||||||
|
slot.pendingKey = key
|
||||||
|
slot.isFadingOut = slot.key !== null
|
||||||
|
if (!slot.isFadingOut) {
|
||||||
|
slot.key = key
|
||||||
|
slot.pendingKey = null
|
||||||
|
const light = lightRefs.current[freeSlot]
|
||||||
|
const reg = regByKey.get(key)
|
||||||
|
if (light && reg) {
|
||||||
|
light.color.set(reg.effect.color)
|
||||||
|
light.distance = reg.effect.distance ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
freeSlot++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retire slots whose key is no longer wanted.
|
||||||
|
for (let i = 0; i < POOL_SIZE; i++) {
|
||||||
|
if (!usedSlots.has(i)) {
|
||||||
|
const slot = slots.current[i]
|
||||||
|
if (slot?.key && !desired.includes(slot.key)) {
|
||||||
|
slot.pendingKey = null
|
||||||
|
slot.isFadingOut = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-frame: fade, snap position, and track intensity from control state.
|
||||||
|
// The pool lights stay permanently `visible` — only `intensity` is animated
|
||||||
|
// (an idle light just lerps to 0). Toggling `visible` would change the
|
||||||
|
// active-light count, which forces the WebGPU renderer to recompile every
|
||||||
|
// material's lighting node — a hard frame-time spike on every reassignment
|
||||||
|
// (i.e. on every camera move). Keeping the count fixed avoids that entirely.
|
||||||
|
for (let i = 0; i < POOL_SIZE; i++) {
|
||||||
|
const light = lightRefs.current[i]
|
||||||
|
const slot = slots.current[i]
|
||||||
|
if (!(light && slot)) continue
|
||||||
|
|
||||||
|
if (slot.isFadingOut) {
|
||||||
|
light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12)
|
||||||
|
if (light.intensity < 0.01) {
|
||||||
|
light.intensity = 0
|
||||||
|
slot.isFadingOut = false
|
||||||
|
slot.key = slot.pendingKey
|
||||||
|
slot.pendingKey = null
|
||||||
|
if (slot.key) {
|
||||||
|
const reg = regByKey.get(slot.key)
|
||||||
|
if (reg) {
|
||||||
|
light.color.set(reg.effect.color)
|
||||||
|
light.distance = reg.effect.distance ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slot.key) {
|
||||||
|
light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const reg = regByKey.get(slot.key)
|
||||||
|
if (!reg) {
|
||||||
|
slot.key = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.object.getWorldPosition(_lightWorld)
|
||||||
|
light.position.set(
|
||||||
|
_lightWorld.x + reg.effect.offset[0],
|
||||||
|
_lightWorld.y + reg.effect.offset[1],
|
||||||
|
_lightWorld.z + reg.effect.offset[2],
|
||||||
|
)
|
||||||
|
|
||||||
|
const values = interactiveState.items[reg.key]?.controlValues
|
||||||
|
const isOn = reg.toggleIndex >= 0 ? Boolean(values?.[reg.toggleIndex]) : true
|
||||||
|
let t = 1
|
||||||
|
if (reg.hasSlider) {
|
||||||
|
const raw = (values?.[reg.sliderIndex] as number) ?? reg.sliderMin
|
||||||
|
t =
|
||||||
|
reg.sliderMax > reg.sliderMin
|
||||||
|
? (raw - reg.sliderMin) / (reg.sliderMax - reg.sliderMin)
|
||||||
|
: 1
|
||||||
|
}
|
||||||
|
const targetIntensity = isOn
|
||||||
|
? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t)
|
||||||
|
: reg.effect.intensityRange[0]
|
||||||
|
light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: POOL_SIZE }, (_, i) => (
|
||||||
|
<pointLight
|
||||||
|
castShadow={false}
|
||||||
|
intensity={0}
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
lightRefs.current[i] = el
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plays an item's baked ambient loop (a fan's spin), gated on its toggle.
|
||||||
|
* The clip and its targets are already in the GLB; we only start/stop it. */
|
||||||
|
function GlbItemAnimation({
|
||||||
|
item,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
item: GlbInteractiveItem
|
||||||
|
actions: Record<string, AnimationAction | null>
|
||||||
|
}) {
|
||||||
|
const values = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues))
|
||||||
|
const toggleIndex = item.interactive.controls.findIndex((c) => c.kind === 'toggle')
|
||||||
|
const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex] ?? true) : true
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const action = actions[`${item.pascalId}: loop`]
|
||||||
|
if (!action) return
|
||||||
|
action.loop = LoopRepeat
|
||||||
|
action.clampWhenFinished = false
|
||||||
|
if (isOn) {
|
||||||
|
action.enabled = true
|
||||||
|
action.paused = false
|
||||||
|
if (!action.isRunning()) action.play()
|
||||||
|
} else {
|
||||||
|
action.stop()
|
||||||
|
}
|
||||||
|
}, [actions, item.pascalId, isOn])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const FADE_MS = 300
|
||||||
|
|
||||||
|
/** Controls overlay for one item — fades in while the item sits inside the
|
||||||
|
* focused zone, portaled above the baked node. */
|
||||||
|
function GlbItemControls({
|
||||||
|
item,
|
||||||
|
object,
|
||||||
|
worldPolygon,
|
||||||
|
}: {
|
||||||
|
item: GlbInteractiveItem
|
||||||
|
object: Object3D
|
||||||
|
worldPolygon: [number, number][] | null
|
||||||
|
}) {
|
||||||
|
const controlValues = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues))
|
||||||
|
const setControlValue = useInteractive((s) => s.setControlValue)
|
||||||
|
|
||||||
|
let visible = false
|
||||||
|
if (worldPolygon?.length) {
|
||||||
|
object.getWorldPosition(_itemPos)
|
||||||
|
visible = pointInPolygon(_itemPos.x, _itemPos.z, worldPolygon)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade in on mount and fade out before unmounting the <Html>.
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const [shown, setShown] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setMounted(true)
|
||||||
|
let raf2 = 0
|
||||||
|
const raf1 = requestAnimationFrame(() => {
|
||||||
|
raf2 = requestAnimationFrame(() => setShown(true))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf1)
|
||||||
|
cancelAnimationFrame(raf2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShown(false)
|
||||||
|
const timeout = setTimeout(() => setMounted(false), FADE_MS)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
if (!(mounted && controlValues)) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<Html
|
||||||
|
center
|
||||||
|
distanceFactor={8}
|
||||||
|
eps={-1}
|
||||||
|
position={[0, item.height + 0.3, 0]}
|
||||||
|
zIndexRange={[20, 0]}
|
||||||
|
>
|
||||||
|
{/* Stop pointer/click events from reaching the canvas — otherwise R3F's
|
||||||
|
pointer-missed fires and deselects the zone the moment you toggle. */}
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerUp={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
background: 'rgba(0,0,0,0.75)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
minWidth: 120,
|
||||||
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
opacity: shown ? 1 : 0,
|
||||||
|
transition: `opacity ${FADE_MS}ms ease`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.interactive.controls.map((control, i) => (
|
||||||
|
<ControlWidget
|
||||||
|
control={control}
|
||||||
|
key={i}
|
||||||
|
onChange={(v) => setControlValue(item.pascalId, i, v)}
|
||||||
|
value={controlValues[i] ?? false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Html>,
|
||||||
|
object,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNode, nodeRegistry, type RendererSource, type SceneGraph } from '@pascal-app/core'
|
||||||
|
import { createPortal } from '@react-three/fiber'
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
import type { Object3D } from 'three'
|
||||||
|
import { getRegistryRenderer } from '../renderers/node-renderer'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans (LiDAR meshes) and guides (floorplan images) are stripped from the
|
||||||
|
* baked GLB — they're heavy reference assets stored elsewhere. The GLB viewer
|
||||||
|
* re-adds them at runtime from the scene graph, portaled into their parent
|
||||||
|
* level's baked node so they ride level stacking, using the same registry
|
||||||
|
* renderers as the parametric viewer. Privacy is enforced upstream: the page
|
||||||
|
* only includes nodes whose `show_*_public` flag (or owner/admin) allows it, so
|
||||||
|
* a disallowed asset is never even fetched.
|
||||||
|
*/
|
||||||
|
export function buildGlbReferenceNodes(
|
||||||
|
sceneGraph: SceneGraph | null | undefined,
|
||||||
|
allow: { scans: boolean; guides: boolean },
|
||||||
|
): AnyNode[] {
|
||||||
|
const nodes = sceneGraph?.nodes
|
||||||
|
if (!nodes) return []
|
||||||
|
const out: AnyNode[] = []
|
||||||
|
for (const raw of Object.values(nodes)) {
|
||||||
|
const node = raw as AnyNode
|
||||||
|
if (node.type === 'scan' && allow.scans) out.push(node)
|
||||||
|
else if (node.type === 'guide' && allow.guides) out.push(node)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlbReferenceNodes({
|
||||||
|
nodes,
|
||||||
|
identity,
|
||||||
|
}: {
|
||||||
|
nodes: AnyNode[]
|
||||||
|
identity: Map<string, Object3D>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const anchor = node.parentId ? identity.get(node.parentId) : undefined
|
||||||
|
return anchor ? <GlbReferenceNode anchor={anchor} key={node.id} node={node} /> : null
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render one scan/guide via its registry renderer, portaled into its parent
|
||||||
|
* level's baked Object3D (so the node's level-local transform resolves to the
|
||||||
|
* same world pose as the parametric scene). */
|
||||||
|
function GlbReferenceNode({ node, anchor }: { node: AnyNode; anchor: Object3D }) {
|
||||||
|
const source = nodeRegistry.get(node.type)?.renderer
|
||||||
|
const Renderer = source ? getRegistryRenderer(source as RendererSource<AnyNode>) : null
|
||||||
|
if (!Renderer) return null
|
||||||
|
return createPortal(
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Renderer node={node} />
|
||||||
|
</Suspense>,
|
||||||
|
anchor,
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { KeyboardControls } from '@react-three/drei'
|
||||||
|
import { useFrame, useThree } from '@react-three/fiber'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box3,
|
||||||
|
BoxGeometry,
|
||||||
|
type BufferAttribute,
|
||||||
|
BufferGeometry,
|
||||||
|
Euler,
|
||||||
|
Float32BufferAttribute,
|
||||||
|
type InterleavedBufferAttribute,
|
||||||
|
Matrix4,
|
||||||
|
Mesh,
|
||||||
|
MeshBasicMaterial,
|
||||||
|
type Object3D,
|
||||||
|
Quaternion,
|
||||||
|
Vector3,
|
||||||
|
} from 'three'
|
||||||
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||||
|
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
||||||
|
import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2'
|
||||||
|
import { SCENE_LAYER } from '../../lib/layers'
|
||||||
|
import useViewer from '../../store/use-viewer'
|
||||||
|
import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl'
|
||||||
|
|
||||||
|
// Eye/capsule geometry mirrors the editor's first-person controller so the
|
||||||
|
// baked walkthrough feels identical. The capsule centre sits below the eye; the
|
||||||
|
// camera rides the capsule with a small offset and the controller floats it to
|
||||||
|
// the ground.
|
||||||
|
const CAMERA_EYE_OFFSET = 0.45
|
||||||
|
const CONTROLLER_CENTER_FROM_EYE = 0.85
|
||||||
|
const SPAWN_EYE_HEIGHT = 1.65
|
||||||
|
const LOOK_SENSITIVITY = 0.002
|
||||||
|
const VOID_FALL_RESPAWN_DEPTH = 12
|
||||||
|
|
||||||
|
// Kinds that must not block the player: room helpers, the spawn marker, the
|
||||||
|
// ceiling/roof shell (you walk under them), and door/window leaves — excluding
|
||||||
|
// the latter lets you pass any doorway whether the leaf is open or shut (the
|
||||||
|
// wall already has the opening cut into its baked geometry).
|
||||||
|
const COLLIDER_EXCLUDED_KINDS = new Set(['zone', 'spawn', 'ceiling', 'roof', 'door', 'window'])
|
||||||
|
|
||||||
|
const colliderMaterial = new MeshBasicMaterial({ visible: false })
|
||||||
|
|
||||||
|
const keyboardMap: Array<{ name: Exclude<keyof MovementInput, 'joystick'>; keys: string[] }> = [
|
||||||
|
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
||||||
|
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
||||||
|
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
|
||||||
|
{ name: 'rightward', keys: ['ArrowRight', 'KeyD'] },
|
||||||
|
{ name: 'jump', keys: ['Space'] },
|
||||||
|
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
|
||||||
|
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
|
||||||
|
const spawnQuat = new Quaternion()
|
||||||
|
const spawnEuler = new Euler(0, 0, 0, 'YXZ')
|
||||||
|
const spawnPos = new Vector3()
|
||||||
|
|
||||||
|
type GlbColliderWorld = { mesh: Mesh; minY: number; dispose: () => void }
|
||||||
|
|
||||||
|
/** Effective visibility — an invisible ancestor hides the whole subtree. */
|
||||||
|
function isEffectivelyVisible(object: Object3D) {
|
||||||
|
let current: Object3D | null = object
|
||||||
|
while (current) {
|
||||||
|
if (!current.visible) return false
|
||||||
|
current = current.parent
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindOf(object: Object3D): string | undefined {
|
||||||
|
let current: Object3D | null = object
|
||||||
|
while (current) {
|
||||||
|
const kind = (current.userData as { kind?: string }).kind
|
||||||
|
if (kind) return kind
|
||||||
|
current = current.parent
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coerce any position attribute (quantized/interleaved) to a plain Float32 one
|
||||||
|
// so mergeGeometries can combine geometries that don't share an array type.
|
||||||
|
function toFloat32Position(source: BufferAttribute | InterleavedBufferAttribute) {
|
||||||
|
const array = new Float32Array(source.count * 3)
|
||||||
|
for (let i = 0; i < source.count; i++) {
|
||||||
|
array[i * 3] = source.getX(i)
|
||||||
|
array[i * 3 + 1] = source.getY(i)
|
||||||
|
array[i * 3 + 2] = source.getZ(i)
|
||||||
|
}
|
||||||
|
return new Float32BufferAttribute(array, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_THICKNESS = 0.08
|
||||||
|
const GROUND_MIN = 2000
|
||||||
|
|
||||||
|
/** A thin position-only floor box whose top face sits at `topY`. */
|
||||||
|
function boxFloorGeometry(
|
||||||
|
cx: number,
|
||||||
|
topY: number,
|
||||||
|
cz: number,
|
||||||
|
width: number,
|
||||||
|
depth: number,
|
||||||
|
): BufferGeometry {
|
||||||
|
const box = new BoxGeometry(width, FALLBACK_THICKNESS, depth).toNonIndexed()
|
||||||
|
const geometry = new BufferGeometry()
|
||||||
|
geometry.setAttribute('position', (box.getAttribute('position') as BufferAttribute).clone())
|
||||||
|
box.dispose()
|
||||||
|
geometry.applyMatrix4(new Matrix4().makeTranslation(cx, topY - FALLBACK_THICKNESS / 2, cz))
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback ground so the player never falls into the void: a single large
|
||||||
|
// ground plane at the lowest level (level 0). Upper levels rely on their own
|
||||||
|
// baked slabs — a slab-less upper floor lets you fall down to the ground, which
|
||||||
|
// the ground plane catches. (Mirrors the editor walkthrough's site ground.)
|
||||||
|
function addFallbackFloors(scene: Object3D, geometries: BufferGeometry[]) {
|
||||||
|
const sceneBounds = new Box3()
|
||||||
|
for (const geometry of geometries) {
|
||||||
|
geometry.computeBoundingBox()
|
||||||
|
if (geometry.boundingBox) sceneBounds.union(geometry.boundingBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = new Vector3()
|
||||||
|
const levelPos = new Vector3()
|
||||||
|
let lowestLevelY = Number.POSITIVE_INFINITY
|
||||||
|
|
||||||
|
scene.traverse((object) => {
|
||||||
|
if ((object.userData as { kind?: string }).kind !== 'level') return
|
||||||
|
object.updateWorldMatrix(true, false)
|
||||||
|
object.getWorldPosition(levelPos)
|
||||||
|
lowestLevelY = Math.min(lowestLevelY, levelPos.y)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (sceneBounds.isEmpty()) return
|
||||||
|
sceneBounds.getCenter(center)
|
||||||
|
const groundY = Number.isFinite(lowestLevelY) ? lowestLevelY : sceneBounds.min.y
|
||||||
|
geometries.push(boxFloorGeometry(center.x, groundY, center.z, GROUND_MIN, GROUND_MIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge the baked GLB's walkable/blocking meshes into one BVH collider. */
|
||||||
|
function buildGlbColliderWorld(scene: Object3D): GlbColliderWorld | null {
|
||||||
|
scene.updateWorldMatrix(true, true)
|
||||||
|
const geometries: BufferGeometry[] = []
|
||||||
|
|
||||||
|
scene.traverse((object) => {
|
||||||
|
const mesh = object as Mesh
|
||||||
|
if (!mesh.isMesh) return
|
||||||
|
// Zone fills live on a separate layer (and never collide).
|
||||||
|
if (!mesh.layers.isEnabled(SCENE_LAYER)) return
|
||||||
|
if (!isEffectivelyVisible(mesh)) return
|
||||||
|
const kind = kindOf(mesh)
|
||||||
|
if (kind && COLLIDER_EXCLUDED_KINDS.has(kind)) return
|
||||||
|
const position = mesh.geometry?.getAttribute('position')
|
||||||
|
if (!position || position.count < 3) return
|
||||||
|
|
||||||
|
const geometry = new BufferGeometry()
|
||||||
|
const source = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry
|
||||||
|
geometry.setAttribute('position', toFloat32Position(source.getAttribute('position')))
|
||||||
|
if (mesh.geometry.index) source.dispose()
|
||||||
|
geometry.applyMatrix4(mesh.matrixWorld)
|
||||||
|
geometries.push(geometry)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (geometries.length === 0) return null
|
||||||
|
|
||||||
|
addFallbackFloors(scene, geometries)
|
||||||
|
|
||||||
|
const merged = mergeGeometries(geometries, false)
|
||||||
|
for (const geometry of geometries) geometry.dispose()
|
||||||
|
if (!merged || merged.getAttribute('position') == null) {
|
||||||
|
merged?.dispose()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype
|
||||||
|
;(merged as any).computeBoundsTree = computeBoundsTree
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype
|
||||||
|
;(merged as any).disposeBoundsTree = disposeBoundsTree
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension
|
||||||
|
;(merged as any).computeBoundsTree({ maxLeafSize: 12, strategy: 0 })
|
||||||
|
merged.computeBoundingBox()
|
||||||
|
|
||||||
|
const mesh = new Mesh(merged, colliderMaterial)
|
||||||
|
mesh.raycast = acceleratedRaycast
|
||||||
|
mesh.visible = true
|
||||||
|
mesh.userData = {
|
||||||
|
type: 'STATIC',
|
||||||
|
friction: 0.8,
|
||||||
|
restitution: 0.05,
|
||||||
|
excludeFloatHit: false,
|
||||||
|
excludeCollisionCheck: false,
|
||||||
|
}
|
||||||
|
mesh.updateMatrixWorld(true)
|
||||||
|
|
||||||
|
return {
|
||||||
|
mesh,
|
||||||
|
minY: merged.boundingBox?.min.y ?? 0,
|
||||||
|
dispose: () => {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension
|
||||||
|
;(merged as any).disposeBoundsTree?.()
|
||||||
|
merged.dispose()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The baked spawn marker's eye position + yaw, if the artifact carries one. */
|
||||||
|
function resolveGlbSpawn(
|
||||||
|
scene: Object3D,
|
||||||
|
): { position: [number, number, number]; yaw: number } | null {
|
||||||
|
let spawn: Object3D | null = null
|
||||||
|
scene.traverse((object) => {
|
||||||
|
if ((object.userData as { kind?: string }).kind === 'spawn') spawn = object
|
||||||
|
})
|
||||||
|
if (!spawn) return null
|
||||||
|
const node = spawn as Object3D
|
||||||
|
node.updateWorldMatrix(true, false)
|
||||||
|
node.getWorldPosition(spawnPos)
|
||||||
|
node.getWorldQuaternion(spawnQuat)
|
||||||
|
spawnEuler.setFromQuaternion(spawnQuat, 'YXZ')
|
||||||
|
return {
|
||||||
|
position: [spawnPos.x, spawnPos.y + SPAWN_EYE_HEIGHT, spawnPos.z],
|
||||||
|
yaw: spawnEuler.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-person walkthrough controller for the baked GLB. Reuses the editor's
|
||||||
|
* `BVHEcctrl` capsule character controller (gravity, jump, sprint, ground-float,
|
||||||
|
* mesh collision) fed a collider built from the artifact's own geometry — so the
|
||||||
|
* baked viewer walks the building with the same physics as the editor, without
|
||||||
|
* the parametric scene. Pointer-lock drives look; WASD moves; Space jumps; Shift
|
||||||
|
* sprints. Door/window interaction stays in `GlbScene` (its centre-ray HUD).
|
||||||
|
*/
|
||||||
|
export function GlbWalkthroughController({ url }: { url: string }) {
|
||||||
|
const { camera, gl } = useThree()
|
||||||
|
const gltf = useGLTFKTX2(url) as unknown as { scene: Object3D }
|
||||||
|
|
||||||
|
const worldRef = useRef<GlbColliderWorld | null>(null)
|
||||||
|
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
||||||
|
const yawRef = useRef(0)
|
||||||
|
const pitchRef = useRef(0)
|
||||||
|
const [start, setStart] = useState<{ position: [number, number, number] } | null>(null)
|
||||||
|
const [world, setWorld] = useState<GlbColliderWorld | null>(null)
|
||||||
|
|
||||||
|
// Build the collider on the first frame (priority after GlbScene's level loop,
|
||||||
|
// which snaps the floors to their stacked world positions in walkthrough) so it
|
||||||
|
// matches the rendered building rather than a mid-lerp / exploded layout.
|
||||||
|
const builtRef = useRef(false)
|
||||||
|
useFrame(() => {
|
||||||
|
if (builtRef.current) return
|
||||||
|
builtRef.current = true
|
||||||
|
setWorld(buildGlbColliderWorld(gltf.scene))
|
||||||
|
}, 6)
|
||||||
|
|
||||||
|
// First-person needs a perspective camera — an orthographic projection has no
|
||||||
|
// foreshortening and makes the walkthrough unusable. Force perspective while
|
||||||
|
// walking and restore the prior projection on exit.
|
||||||
|
useEffect(() => {
|
||||||
|
const prevMode = useViewer.getState().cameraMode
|
||||||
|
if (prevMode === 'orthographic') useViewer.getState().setCameraMode('perspective')
|
||||||
|
return () => {
|
||||||
|
if (prevMode === 'orthographic') useViewer.getState().setCameraMode('orthographic')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
worldRef.current = world
|
||||||
|
if (world) {
|
||||||
|
const triangles = world.mesh.geometry.getAttribute('position').count / 3
|
||||||
|
console.warn('[glb-walkthrough] collider built', {
|
||||||
|
triangles,
|
||||||
|
minY: world.minY,
|
||||||
|
hasBoundsTree: !!(world.mesh.geometry as { boundsTree?: unknown }).boundsTree,
|
||||||
|
spawn: resolveGlbSpawn(gltf.scene),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.warn('[glb-walkthrough] NO collider world (no eligible meshes)')
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
world?.dispose()
|
||||||
|
worldRef.current = null
|
||||||
|
}
|
||||||
|
}, [world, gltf.scene])
|
||||||
|
|
||||||
|
// Resolve the spawn once the collider exists; the capsule centre sits below
|
||||||
|
// the eye, then the controller floats it onto the ground.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!world || start) return
|
||||||
|
const spawn = resolveGlbSpawn(gltf.scene)
|
||||||
|
const eye = spawn?.position ?? [0, SPAWN_EYE_HEIGHT, 0]
|
||||||
|
yawRef.current = spawn?.yaw ?? 0
|
||||||
|
pitchRef.current = 0
|
||||||
|
setStart({ position: [eye[0], eye[1] - CONTROLLER_CENTER_FROM_EYE, eye[2]] })
|
||||||
|
}, [world, start, gltf.scene])
|
||||||
|
|
||||||
|
// Pointer-lock look + click-to-lock fallback + Esc/unlock to exit. Once the
|
||||||
|
// pointer has been locked, releasing it (Esc — the browser swallows that
|
||||||
|
// keydown, so we can't rely on it; or any other unlock) leaves the walkthrough
|
||||||
|
// in a single press rather than just freeing the cursor.
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = gl.domElement
|
||||||
|
let wasLocked = false
|
||||||
|
const onMouseMove = (event: MouseEvent) => {
|
||||||
|
if (document.pointerLockElement !== canvas) return
|
||||||
|
yawRef.current -= event.movementX * LOOK_SENSITIVITY
|
||||||
|
pitchRef.current = Math.max(
|
||||||
|
-(Math.PI / 2 - 0.05),
|
||||||
|
Math.min(Math.PI / 2 - 0.05, pitchRef.current - event.movementY * LOOK_SENSITIVITY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const onClick = () => {
|
||||||
|
if (document.pointerLockElement !== canvas) canvas.requestPointerLock?.()
|
||||||
|
}
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
// When locked, the browser intercepts Esc to release the pointer and the
|
||||||
|
// pointerlockchange handler below exits; this only covers Esc while the
|
||||||
|
// pointer is already free (e.g. lock never engaged).
|
||||||
|
if (event.code === 'Escape' && document.pointerLockElement !== canvas) {
|
||||||
|
useViewer.getState().setWalkthroughMode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onPointerLockChange = () => {
|
||||||
|
if (document.pointerLockElement === canvas) wasLocked = true
|
||||||
|
else if (wasLocked) useViewer.getState().setWalkthroughMode(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
canvas.addEventListener('click', onClick)
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
document.addEventListener('pointerlockchange', onPointerLockChange)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
canvas.removeEventListener('click', onClick)
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.removeEventListener('pointerlockchange', onPointerLockChange)
|
||||||
|
if (document.pointerLockElement === canvas) document.exitPointerLock()
|
||||||
|
}
|
||||||
|
}, [gl])
|
||||||
|
|
||||||
|
// Lock the pointer the moment the walkthrough is ready, so the user doesn't
|
||||||
|
// have to click the canvas first. The walkthrough toggle is itself a user
|
||||||
|
// gesture; if the browser still rejects the request (no transient activation
|
||||||
|
// left), the click-to-lock fallback above covers it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(world && start)) return
|
||||||
|
const canvas = gl.domElement
|
||||||
|
if (document.pointerLockElement === canvas) return
|
||||||
|
const result = canvas.requestPointerLock?.() as Promise<void> | undefined
|
||||||
|
if (result && typeof result.catch === 'function') result.catch(() => {})
|
||||||
|
}, [gl, world, start])
|
||||||
|
|
||||||
|
const setControllerApi = useCallback((api: BVHEcctrlApi | null) => {
|
||||||
|
controllerRef.current = api
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Drive the camera from the capsule each frame + respawn if it falls into void.
|
||||||
|
useFrame(() => {
|
||||||
|
const group = controllerRef.current?.group
|
||||||
|
if (!group) return
|
||||||
|
|
||||||
|
if (start && world && group.position.y < world.minY - VOID_FALL_RESPAWN_DEPTH) {
|
||||||
|
group.position.set(start.position[0], start.position[1], start.position[2])
|
||||||
|
controllerRef.current?.resetLinVel()
|
||||||
|
}
|
||||||
|
|
||||||
|
group.rotation.y = 0
|
||||||
|
camera.position.copy(group.position).add(cameraOffset)
|
||||||
|
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
||||||
|
camera.quaternion.setFromEuler(cameraEuler)
|
||||||
|
camera.updateMatrixWorld(true)
|
||||||
|
}, 2.5)
|
||||||
|
|
||||||
|
if (!(world && start)) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardControls map={keyboardMap}>
|
||||||
|
<BVHEcctrl
|
||||||
|
acceleration={26}
|
||||||
|
airDragFactor={0.3}
|
||||||
|
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
|
||||||
|
colliderMeshes={[world.mesh]}
|
||||||
|
collisionCheckIteration={3}
|
||||||
|
collisionPushBackDamping={0.1}
|
||||||
|
collisionPushBackThreshold={0.001}
|
||||||
|
deceleration={30}
|
||||||
|
delay={0}
|
||||||
|
fallGravityFactor={4}
|
||||||
|
floatCheckType="BOTH"
|
||||||
|
floatDampingC={36}
|
||||||
|
floatHeight={0.5}
|
||||||
|
floatPullBackHeight={0.35}
|
||||||
|
floatSensorRadius={0.15}
|
||||||
|
floatSpringK={1200}
|
||||||
|
gravity={9.81}
|
||||||
|
jumpVel={6}
|
||||||
|
maxRunSpeed={5.5}
|
||||||
|
maxSlope={1.2}
|
||||||
|
maxWalkSpeed={4}
|
||||||
|
position={start.position}
|
||||||
|
ref={setControllerApi}
|
||||||
|
/>
|
||||||
|
</KeyboardControls>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -338,7 +338,13 @@ const PostProcessingPasses = ({
|
|||||||
const hasGeometry = scenePassColor.a
|
const hasGeometry = scenePassColor.a
|
||||||
const contentAlpha = hasGeometry.max(zonePass.a)
|
const contentAlpha = hasGeometry.max(zonePass.a)
|
||||||
|
|
||||||
let sceneColor = scenePassColor as unknown as ReturnType<typeof vec4>
|
// Composite the zone-pass tint into the base scene so rooms show whether or
|
||||||
|
// not SSGI is enabled. When SSGI is on, the branch below overwrites this
|
||||||
|
// with its own zone-inclusive composite (no double-add).
|
||||||
|
let sceneColor = vec4(
|
||||||
|
add(scenePassColor.rgb, zonePass.rgb),
|
||||||
|
contentAlpha,
|
||||||
|
) as unknown as ReturnType<typeof vec4>
|
||||||
|
|
||||||
// Depth + normal MRT — shared by SSGI (diffuse/normal) and the ink pass
|
// Depth + normal MRT — shared by SSGI (diffuse/normal) and the ink pass
|
||||||
// (depth/normal). Built whenever either is active.
|
// (depth/normal). Built whenever either is active.
|
||||||
|
|||||||
@@ -13,6 +13,25 @@ export { ErrorBoundary } from './components/error-boundary'
|
|||||||
// — no per-kind re-exports needed.
|
// — no per-kind re-exports needed.
|
||||||
export { NodeRenderer } from './components/renderers/node-renderer'
|
export { NodeRenderer } from './components/renderers/node-renderer'
|
||||||
export { default as Viewer, type ViewerHandle } from './components/viewer'
|
export { default as Viewer, type ViewerHandle } from './components/viewer'
|
||||||
|
export {
|
||||||
|
type BVHEcctrlApi,
|
||||||
|
default as BVHEcctrl,
|
||||||
|
type MovementInput,
|
||||||
|
} from './components/viewer/bvh-ecctrl'
|
||||||
|
export {
|
||||||
|
buildGlbInteractiveItems,
|
||||||
|
GlbInteractive,
|
||||||
|
type GlbInteractiveItem,
|
||||||
|
} from './components/viewer/glb-interactive'
|
||||||
|
export { buildGlbReferenceNodes } from './components/viewer/glb-reference-nodes'
|
||||||
|
export {
|
||||||
|
type GlbHover,
|
||||||
|
type GlbIdentity,
|
||||||
|
type GlbLevel,
|
||||||
|
GlbScene,
|
||||||
|
type GlbWalkthrough,
|
||||||
|
} from './components/viewer/glb-scene'
|
||||||
|
export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-controller'
|
||||||
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
|
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
|
||||||
export {
|
export {
|
||||||
DEFAULT_HOVER_STYLES,
|
DEFAULT_HOVER_STYLES,
|
||||||
@@ -157,6 +176,9 @@ export { getVisibleWallMaterials } from './systems/wall/wall-materials'
|
|||||||
// 800+ lines of CSG / mitering logic during Phase 3. These exports are
|
// 800+ lines of CSG / mitering logic during Phase 3. These exports are
|
||||||
// removed in Phase 6 when the legacy mount points are deleted.
|
// removed in Phase 6 when the legacy mount points are deleted.
|
||||||
export { WallSystem } from './systems/wall/wall-system'
|
export { WallSystem } from './systems/wall/wall-system'
|
||||||
export { WindowAnimationSystem } from './systems/window/window-animation-system'
|
export {
|
||||||
|
poseWindowMovingParts,
|
||||||
|
WindowAnimationSystem,
|
||||||
|
} from './systems/window/window-animation-system'
|
||||||
export { buildWindowPreviewMesh, WindowSystem } from './systems/window/window-system'
|
export { buildWindowPreviewMesh, WindowSystem } from './systems/window/window-system'
|
||||||
export { ZoneSystem } from './systems/zone/zone-system'
|
export { ZoneSystem } from './systems/zone/zone-system'
|
||||||
|
|||||||
@@ -1093,6 +1093,7 @@ function addDoorLeaf(
|
|||||||
hingeX,
|
hingeX,
|
||||||
hingeSide,
|
hingeSide,
|
||||||
swingRotation,
|
swingRotation,
|
||||||
|
openRotationY,
|
||||||
segments,
|
segments,
|
||||||
contentPadding,
|
contentPadding,
|
||||||
handle,
|
handle,
|
||||||
@@ -1118,6 +1119,10 @@ function addDoorLeaf(
|
|||||||
hingeX: number
|
hingeX: number
|
||||||
hingeSide: 'left' | 'right'
|
hingeSide: 'left' | 'right'
|
||||||
swingRotation: number
|
swingRotation: number
|
||||||
|
// Leaf rotation (radians, about the hinge Y axis) at fully-open. The GLB
|
||||||
|
// exporter reads this off the leaf group to bake an open/close clip; it is
|
||||||
|
// the kinematic endpoint, independent of the current `swingRotation`.
|
||||||
|
openRotationY: number
|
||||||
segments: DoorNode['segments']
|
segments: DoorNode['segments']
|
||||||
contentPadding: DoorNode['contentPadding']
|
contentPadding: DoorNode['contentPadding']
|
||||||
handle: boolean
|
handle: boolean
|
||||||
@@ -1148,6 +1153,10 @@ function addDoorLeaf(
|
|||||||
const leafGroup = new THREE.Group()
|
const leafGroup = new THREE.Group()
|
||||||
leafGroup.position.set(hingeX, 0, 0)
|
leafGroup.position.set(hingeX, 0, 0)
|
||||||
leafGroup.rotation.y = swingRotation
|
leafGroup.rotation.y = swingRotation
|
||||||
|
// Marks this group as the swing leaf and records its fully-open angle so the
|
||||||
|
// GLB exporter can bake an open/close animation clip from a single pose. The
|
||||||
|
// exporter strips this marker before writing the file.
|
||||||
|
leafGroup.userData.pascalSwingLeaf = { axis: 'y', openRotationY }
|
||||||
mesh.add(leafGroup)
|
mesh.add(leafGroup)
|
||||||
|
|
||||||
const addLeafBox = (
|
const addLeafBox = (
|
||||||
@@ -2462,6 +2471,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
|||||||
hingeX: -insideWidth / 2,
|
hingeX: -insideWidth / 2,
|
||||||
hingeSide: 'left',
|
hingeSide: 'left',
|
||||||
swingRotation: -clampedSwingAngle * swingDirectionSign,
|
swingRotation: -clampedSwingAngle * swingDirectionSign,
|
||||||
|
openRotationY: (-Math.PI / 2) * swingDirectionSign,
|
||||||
segments,
|
segments,
|
||||||
contentPadding,
|
contentPadding,
|
||||||
handle,
|
handle,
|
||||||
@@ -2490,6 +2500,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
|||||||
hingeX: insideWidth / 2,
|
hingeX: insideWidth / 2,
|
||||||
hingeSide: 'right',
|
hingeSide: 'right',
|
||||||
swingRotation: clampedSwingAngle * swingDirectionSign,
|
swingRotation: clampedSwingAngle * swingDirectionSign,
|
||||||
|
openRotationY: (Math.PI / 2) * swingDirectionSign,
|
||||||
segments,
|
segments,
|
||||||
contentPadding,
|
contentPadding,
|
||||||
handle,
|
handle,
|
||||||
@@ -2521,6 +2532,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
|||||||
hingeX,
|
hingeX,
|
||||||
hingeSide: hingesSide,
|
hingeSide: hingesSide,
|
||||||
swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign,
|
swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign,
|
||||||
|
openRotationY: (Math.PI / 2) * swingDirectionSign * hingeDirectionSign,
|
||||||
segments,
|
segments,
|
||||||
contentPadding,
|
contentPadding,
|
||||||
handle,
|
handle,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { Control, ControlValue } from '@pascal-app/core'
|
||||||
|
|
||||||
|
/** One interactive control (toggle / slider / temperature) rendered inside the
|
||||||
|
* item controls overlay. Shared by the parametric `InteractiveSystem` and the
|
||||||
|
* baked-GLB `GlbInteractive` overlay so both look and behave identically. */
|
||||||
|
export const ControlWidget = ({
|
||||||
|
control,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
control: Control
|
||||||
|
value: ControlValue
|
||||||
|
onChange: (v: ControlValue) => void
|
||||||
|
}) => {
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.kind === 'toggle') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(!value)}
|
||||||
|
style={{
|
||||||
|
background: value ? '#4ade80' : '#374151',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '4px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{control.label ?? (value ? 'On' : 'Off')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.kind === 'slider') {
|
||||||
|
return (
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span>
|
||||||
|
{control.label}: {value}
|
||||||
|
{control.unit ? ` ${control.unit}` : ''}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
max={control.max}
|
||||||
|
min={control.min}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
step={control.step}
|
||||||
|
type="range"
|
||||||
|
value={value as number}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.kind === 'temperature') {
|
||||||
|
return (
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span>
|
||||||
|
{control.label}: {value}°{control.unit}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
max={control.max}
|
||||||
|
min={control.min}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
step={1}
|
||||||
|
type="range"
|
||||||
|
value={value as number}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type Control,
|
|
||||||
type ControlValue,
|
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
pointInPolygon,
|
pointInPolygon,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
@@ -17,6 +15,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { type Object3D, Vector3 } from 'three'
|
import { type Object3D, Vector3 } from 'three'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
import { ControlWidget } from './control-widget'
|
||||||
|
|
||||||
const _tempVec = new Vector3()
|
const _tempVec = new Vector3()
|
||||||
|
|
||||||
@@ -146,86 +145,3 @@ const ItemControlsOverlay = ({
|
|||||||
itemObj,
|
itemObj,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Control widgets ----
|
|
||||||
|
|
||||||
const ControlWidget = ({
|
|
||||||
control,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
control: Control
|
|
||||||
value: ControlValue
|
|
||||||
onChange: (v: ControlValue) => void
|
|
||||||
}) => {
|
|
||||||
const labelStyle: React.CSSProperties = {
|
|
||||||
color: 'white',
|
|
||||||
fontSize: 11,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (control.kind === 'toggle') {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => onChange(!value)}
|
|
||||||
style={{
|
|
||||||
background: value ? '#4ade80' : '#374151',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: '4px 8px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
transition: 'background 0.2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{control.label ?? (value ? 'On' : 'Off')}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (control.kind === 'slider') {
|
|
||||||
return (
|
|
||||||
<label style={labelStyle}>
|
|
||||||
<span>
|
|
||||||
{control.label}: {value}
|
|
||||||
{control.unit ? ` ${control.unit}` : ''}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
max={control.max}
|
|
||||||
min={control.min}
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
step={control.step}
|
|
||||||
type="range"
|
|
||||||
value={value as number}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (control.kind === 'temperature') {
|
|
||||||
return (
|
|
||||||
<label style={labelStyle}>
|
|
||||||
<span>
|
|
||||||
{control.label}: {value}°{control.unit}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
max={control.max}
|
|
||||||
min={control.min}
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
step={1}
|
|
||||||
type="range"
|
|
||||||
value={value as number}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type WindowNode,
|
type WindowNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
import type { Object3D } from 'three'
|
||||||
import {
|
import {
|
||||||
AWNING_WINDOW_SASH_NAME,
|
AWNING_WINDOW_SASH_NAME,
|
||||||
CASEMENT_WINDOW_SASH_NAME,
|
CASEMENT_WINDOW_SASH_NAME,
|
||||||
@@ -28,12 +29,20 @@ function markWindowDirty(windowId: AnyNodeId) {
|
|||||||
scene.dirtyNodes.add(windowId)
|
scene.dirtyNodes.add(windowId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) {
|
/**
|
||||||
const node = useScene.getState().nodes[windowId]
|
* Pose a window's moving parts (sash/panel/slats) at `value` (0 = closed,
|
||||||
if (node?.type !== 'window') return false
|
* 1 = open) by mutating the named child groups under `mesh`. Returns true when
|
||||||
|
* the window type has a direct pose path and the named parts were found.
|
||||||
const mesh = sceneRegistry.nodes.get(windowId)
|
*
|
||||||
|
* This is the single source of truth for window kinematics: the live animation
|
||||||
|
* system poses the registered scene mesh, and the GLB exporter poses an export
|
||||||
|
* clone to sample the open/close keyframes for a baked animation clip.
|
||||||
|
*/
|
||||||
|
export function poseWindowMovingParts(
|
||||||
|
node: WindowNode,
|
||||||
|
mesh: Object3D | undefined,
|
||||||
|
value: number,
|
||||||
|
): boolean {
|
||||||
if (node.windowType === 'sliding') {
|
if (node.windowType === 'sliding') {
|
||||||
const activePanel = mesh?.getObjectByName(SLIDING_WINDOW_ACTIVE_PANEL_NAME)
|
const activePanel = mesh?.getObjectByName(SLIDING_WINDOW_ACTIVE_PANEL_NAME)
|
||||||
if (!activePanel) return false
|
if (!activePanel) return false
|
||||||
@@ -120,6 +129,12 @@ function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) {
|
||||||
|
const node = useScene.getState().nodes[windowId]
|
||||||
|
if (node?.type !== 'window') return false
|
||||||
|
return poseWindowMovingParts(node, sceneRegistry.nodes.get(windowId), value)
|
||||||
|
}
|
||||||
|
|
||||||
export const WindowAnimationSystem = () => {
|
export const WindowAnimationSystem = () => {
|
||||||
useFrame(({ clock }) => {
|
useFrame(({ clock }) => {
|
||||||
const interactive = useInteractive.getState()
|
const interactive = useInteractive.getState()
|
||||||
|
|||||||
Reference in New Issue
Block a user