feat(export): baked GLB export with identity, clips, cutout fix (phases 0-1)
Promote the client GLB export into the baked-artifact format from
plans/editor-baked-glb-export.md (phases 0 and 1).
- NodeMaterial -> classic MeshStandardMaterial conversion at export. The
viewer's MeshStandard/LambertNodeMaterial set isNodeMaterial, not
isMeshStandardMaterial, so GLTFExporter would otherwise drop every
surface to a blank default. KTX2 (compressed) maps are decompressed via
WebGPUTextureUtils so the exporter can embed them (PNG for now; KTX2
re-encode is deferred to the phase-3 bake worker).
- Identity stamping from sceneRegistry: node.name = pascalId and
extras = { pascalId, kind, label?, openable?, clips? }; all other
userData stripped so editor/runtime ephemera never reach glTF extras.
- Door/window open clips baked from the build-once + pose-at-t primitives
(door pascalSwingLeaf marker, window poseWindowMovingParts). Clips named
by label ("Door 1: open"), carry extras.loop = false (consumers play
once and hold; dumb glTF players still loop).
- Cutout fix: door/window selection hitboxes hide via material.visible,
which onlyVisible misses, so the hitbox box plugged the wall opening.
Non-renderable container meshes now keep their node but lose geometry;
childless ones are removed.
- Editor-overlay stripping mirrors the thumbnail capture: emit
thumbnail:before/after-capture so scene-layer affordances (handles,
ceiling/site brackets) self-hide, and drop anything off SCENE_LAYER
(gizmos, grid, zone fills).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b2f1a8432e
commit
06e4cd7748
@@ -1,12 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useEffect } from 'react'
|
||||
import type { Mesh, Object3D } from 'three'
|
||||
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
||||
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
||||
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
||||
import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
|
||||
import { prepareSceneForExport } from '../../lib/glb-export'
|
||||
|
||||
export function ExportManager() {
|
||||
const scene = useThree((state) => state.scene)
|
||||
@@ -22,7 +24,18 @@ export function ExportManager() {
|
||||
}
|
||||
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
const exportScene = prepareSceneForExport(sceneGroup)
|
||||
// Hide editor affordances that live on the scene layer (selection handles,
|
||||
// ceiling/site brackets) and let wall-cutout reveal all walls — the same
|
||||
// synchronous capture path thumbnails use. We clone the scene inside the
|
||||
// window, so the export snapshots the clean building, then restore.
|
||||
emitter.emit('thumbnail:before-capture', undefined)
|
||||
let prepared: ReturnType<typeof prepareSceneForExport>
|
||||
try {
|
||||
prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
|
||||
} finally {
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
}
|
||||
const { scene: exportScene, animations } = prepared
|
||||
|
||||
if (format === 'stl') {
|
||||
const exporter = new STLExporter()
|
||||
@@ -40,8 +53,13 @@ export function ExportManager() {
|
||||
return
|
||||
}
|
||||
|
||||
// Default: GLB export (existing behavior)
|
||||
// Default: GLB export with baked identity + door/window animation clips.
|
||||
const exporter = new GLTFExporter()
|
||||
// Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read
|
||||
// those directly. WebGPUTextureUtils blits each one to RGBA on its own
|
||||
// offscreen renderer (passing the live renderer would resize/draw over the
|
||||
// editor canvas), letting the exporter embed standard textures.
|
||||
exporter.setTextureUtils(WebGPUTextureUtils)
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
exporter.parse(
|
||||
@@ -55,7 +73,7 @@ export function ExportManager() {
|
||||
console.error('Export error:', error)
|
||||
reject(error)
|
||||
},
|
||||
{ binary: true },
|
||||
{ binary: true, animations },
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -70,33 +88,6 @@ export function ExportManager() {
|
||||
return null
|
||||
}
|
||||
|
||||
function prepareSceneForExport(source: Object3D) {
|
||||
const clone = source.clone(true)
|
||||
const meshesToRemove: Mesh[] = []
|
||||
|
||||
clone.traverse((object) => {
|
||||
if (isMeshWithInvalidGeometry(object)) meshesToRemove.push(object)
|
||||
})
|
||||
|
||||
for (const mesh of meshesToRemove) {
|
||||
mesh.removeFromParent()
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
function isMeshWithInvalidGeometry(object: Object3D): object is Mesh {
|
||||
if (!isMesh(object)) return false
|
||||
|
||||
// Three exporters can crash when a Mesh has no readable position attribute.
|
||||
const position = object.geometry?.getAttribute('position')
|
||||
return !position || position.count === 0
|
||||
}
|
||||
|
||||
function isMesh(object: Object3D): object is Mesh {
|
||||
return (object as Mesh).isMesh === true
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, sceneRegistry } from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { prepareSceneForExport } from './glb-export'
|
||||
|
||||
afterEach(() => {
|
||||
sceneRegistry.clear()
|
||||
})
|
||||
|
||||
function nodeMaterial(overrides: Record<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('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,378 @@
|
||||
import { type AnyNode, sceneRegistry, type WindowNode } from '@pascal-app/core'
|
||||
import { poseWindowMovingParts, SCENE_LAYER } from '@pascal-app/viewer'
|
||||
import * as THREE from 'three'
|
||||
|
||||
/**
|
||||
* Two TRS samples (closed vs open) differing by less than this are treated as
|
||||
* stationary, so only genuinely moving parts get an animation track.
|
||||
*/
|
||||
const POSE_EPSILON = 1e-5
|
||||
|
||||
/**
|
||||
* Marker stamped on a door's swing-leaf group by the door system. `axis` is the
|
||||
* hinge axis and `openRotationY` is the fully-open angle (radians). The export
|
||||
* reads it to bake an open clip from a single closed pose; see `door-system`.
|
||||
*/
|
||||
type SwingLeafMarker = { axis: 'y'; openRotationY: number }
|
||||
|
||||
export type GlbExport = {
|
||||
scene: THREE.Object3D
|
||||
animations: THREE.AnimationClip[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an engine-agnostic export tree from the live scene graph. The result is
|
||||
* a standalone three.js scene plus glTF animation clips, ready for
|
||||
* `GLTFExporter` — it carries no Pascal runtime dependency.
|
||||
*
|
||||
* - Clones the source so live objects are never mutated.
|
||||
* - Converts WebGPU NodeMaterials to classic glTF-standard materials.
|
||||
* `GLTFExporter` only recognises `isMeshStandardMaterial` /
|
||||
* `isMeshBasicMaterial`; the viewer's `MeshStandard/LambertNodeMaterial` set
|
||||
* `isNodeMaterial` instead, so without this every surface exports as a blank
|
||||
* default material.
|
||||
* - Bakes each openable door/window's open motion into a glTF animation clip
|
||||
* via the build-once + pose-at-t primitives (`pascalSwingLeaf` for doors,
|
||||
* `poseWindowMovingParts` for windows).
|
||||
* - Stamps `name` + `extras` identity from `sceneRegistry` so selection/hover
|
||||
* survive the bake with no in-memory registry, and strips all other userData
|
||||
* so editor/runtime ephemera never leak into glTF extras.
|
||||
*/
|
||||
export function prepareSceneForExport(
|
||||
source: THREE.Object3D,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): GlbExport {
|
||||
const scene = source.clone(true)
|
||||
const cloneByOriginal = pairClones(source, scene)
|
||||
|
||||
pruneNonRenderableMeshes(scene)
|
||||
convertMaterials(scene)
|
||||
|
||||
const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
|
||||
|
||||
stampIdentity(scene, cloneByOriginal, nodes, clipNamesByNode)
|
||||
|
||||
return { scene, animations: clips }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair each original Object3D with its clone. `clone(true)` builds children in
|
||||
* source order, so parallel pre-order traversals line up 1:1 — this is how we
|
||||
* map `sceneRegistry`'s live refs onto the export tree without mutating either.
|
||||
*/
|
||||
function pairClones(
|
||||
source: THREE.Object3D,
|
||||
clone: THREE.Object3D,
|
||||
): Map<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) {
|
||||
const toRemove: THREE.Object3D[] = []
|
||||
root.traverse((object) => {
|
||||
// Editor-only overlays (gizmos, selection handles, ground grid, zone fills)
|
||||
// live off the scene layer; the editor camera renders them via extra layers
|
||||
// but a thumbnail/bake only wants layer 0. Drop the whole overlay subtree.
|
||||
if (!object.layers.isEnabled(SCENE_LAYER)) {
|
||||
toRemove.push(object)
|
||||
return
|
||||
}
|
||||
const mesh = object as THREE.Mesh
|
||||
if (!mesh.isMesh || isRenderableMesh(mesh)) return
|
||||
if (mesh.children.length > 0) {
|
||||
mesh.geometry = EMPTY_GEOMETRY
|
||||
} else {
|
||||
toRemove.push(mesh)
|
||||
}
|
||||
})
|
||||
for (const object of toRemove) {
|
||||
object.removeFromParent()
|
||||
}
|
||||
}
|
||||
|
||||
function isRenderableMesh(mesh: THREE.Mesh): boolean {
|
||||
const position = mesh.geometry?.getAttribute('position')
|
||||
if (!position || position.count === 0) return false
|
||||
const material = mesh.material
|
||||
return Array.isArray(material)
|
||||
? material.some((m) => m?.visible !== false)
|
||||
: material?.visible !== false
|
||||
}
|
||||
|
||||
// --- Material conversion -------------------------------------------------
|
||||
|
||||
const STANDARD_MAP_SLOTS = [
|
||||
'map',
|
||||
'normalMap',
|
||||
'roughnessMap',
|
||||
'metalnessMap',
|
||||
'aoMap',
|
||||
'emissiveMap',
|
||||
'alphaMap',
|
||||
'lightMap',
|
||||
'displacementMap',
|
||||
'bumpMap',
|
||||
] as const
|
||||
|
||||
function convertMaterials(root: THREE.Object3D) {
|
||||
const cache = new Map<THREE.Material, THREE.Material>()
|
||||
root.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh
|
||||
if (!mesh.isMesh) return
|
||||
const material = mesh.material
|
||||
mesh.material = Array.isArray(material)
|
||||
? material.map((m) => convertMaterial(m, cache))
|
||||
: convertMaterial(material, cache)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a viewer NodeMaterial into the classic `MeshStandardMaterial` the
|
||||
* glTF exporter understands. Classic materials pass through untouched, and the
|
||||
* cache preserves material sharing (one source instance -> one target), so the
|
||||
* exporter still dedups shared surfaces.
|
||||
*/
|
||||
function convertMaterial(
|
||||
material: THREE.Material,
|
||||
cache: Map<THREE.Material, 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
|
||||
target.transparent = material.transparent
|
||||
target.opacity = material.opacity
|
||||
target.side = material.side
|
||||
target.alphaTest = material.alphaTest
|
||||
target.depthWrite = material.depthWrite
|
||||
target.depthTest = material.depthTest
|
||||
target.vertexColors = material.vertexColors
|
||||
target.toneMapped = material.toneMapped
|
||||
if (src.normalScale instanceof THREE.Vector2) target.normalScale.copy(src.normalScale)
|
||||
if (typeof src.aoMapIntensity === 'number') target.aoMapIntensity = src.aoMapIntensity
|
||||
if (typeof src.displacementScale === 'number') target.displacementScale = src.displacementScale
|
||||
|
||||
for (const slot of STANDARD_MAP_SLOTS) {
|
||||
const texture = src[slot]
|
||||
if (texture instanceof THREE.Texture) {
|
||||
;(target as unknown as Record<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)
|
||||
: null
|
||||
|
||||
if (clip) {
|
||||
clips.push(clip)
|
||||
clipNamesByNode.set(id, [clip.name])
|
||||
}
|
||||
}
|
||||
|
||||
return { clips, clipNamesByNode }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bake a swing door's open motion. Each marked leaf is rotated from closed
|
||||
* (rest pose) to its fully-open angle and emitted as a 1-second quaternion
|
||||
* track; the leaf is left at the closed pose so the GLB's rest state is shut.
|
||||
*/
|
||||
function bakeDoorClip(
|
||||
id: string,
|
||||
node: AnyNode,
|
||||
doorObject: THREE.Object3D,
|
||||
): THREE.AnimationClip | null {
|
||||
const tracks: THREE.KeyframeTrack[] = []
|
||||
|
||||
doorObject.traverse((object) => {
|
||||
const marker = object.userData.pascalSwingLeaf as SwingLeafMarker | undefined
|
||||
if (!marker || marker.axis !== 'y') return
|
||||
|
||||
object.rotation.y = 0
|
||||
const closed = object.quaternion.clone()
|
||||
object.rotation.y = marker.openRotationY
|
||||
const open = object.quaternion.clone()
|
||||
object.rotation.y = 0
|
||||
|
||||
tracks.push(
|
||||
new THREE.QuaternionKeyframeTrack(
|
||||
`${object.uuid}.quaternion`,
|
||||
[0, 1],
|
||||
[...closed.toArray(), ...open.toArray()],
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
if (tracks.length === 0) return null
|
||||
return openClip(id, node, tracks)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an open motion in a named 1-second clip. The name uses the node's label
|
||||
* when set (e.g. "Door 1: open") so a glTF player lists readable clips, falling
|
||||
* back to the id. glTF has no core loop flag — the player decides — so we stamp
|
||||
* `extras.loop = false` (via the clip's userData, which `GLTFExporter`
|
||||
* serialises onto the animation): Pascal's `/viewer` and any extras-aware
|
||||
* consumer play it once and hold the open pose; a dumb glTF player still loops.
|
||||
* Consumers map a clip back to its node by walking up from a channel's target to
|
||||
* the nearest ancestor carrying `extras.pascalId`, so the name stays cosmetic.
|
||||
*/
|
||||
function openClip(id: string, node: AnyNode, tracks: THREE.KeyframeTrack[]): THREE.AnimationClip {
|
||||
const clip = new THREE.AnimationClip(`${node.name ?? id}: open`, 1, tracks)
|
||||
clip.userData = { loop: false }
|
||||
return clip
|
||||
}
|
||||
|
||||
/**
|
||||
* Bake a window's open motion generically: snapshot every part's pose closed,
|
||||
* pose the subtree open, and emit a track for whichever parts actually moved
|
||||
* (translation for sliding/hung sashes, rotation for casement/awning/louvre).
|
||||
* Reusing the live `poseWindowMovingParts` keeps one source of truth for window
|
||||
* kinematics. The subtree is left posed closed as the GLB's rest state.
|
||||
*/
|
||||
function bakeWindowClip(
|
||||
id: string,
|
||||
node: WindowNode,
|
||||
windowObject: THREE.Object3D,
|
||||
): THREE.AnimationClip | null {
|
||||
poseWindowMovingParts(node, windowObject, 0)
|
||||
|
||||
const closedPoses = new Map<
|
||||
THREE.Object3D,
|
||||
{ position: THREE.Vector3; quaternion: THREE.Quaternion }
|
||||
>()
|
||||
windowObject.traverse((object) => {
|
||||
closedPoses.set(object, {
|
||||
position: object.position.clone(),
|
||||
quaternion: object.quaternion.clone(),
|
||||
})
|
||||
})
|
||||
|
||||
if (!poseWindowMovingParts(node, windowObject, 1)) return null
|
||||
|
||||
const tracks: THREE.KeyframeTrack[] = []
|
||||
windowObject.traverse((object) => {
|
||||
const closed = closedPoses.get(object)
|
||||
if (!closed) return
|
||||
|
||||
if (object.position.distanceToSquared(closed.position) > POSE_EPSILON) {
|
||||
tracks.push(
|
||||
new THREE.VectorKeyframeTrack(
|
||||
`${object.uuid}.position`,
|
||||
[0, 1],
|
||||
[...closed.position.toArray(), ...object.position.toArray()],
|
||||
),
|
||||
)
|
||||
}
|
||||
if (closed.quaternion.angleTo(object.quaternion) > POSE_EPSILON) {
|
||||
tracks.push(
|
||||
new THREE.QuaternionKeyframeTrack(
|
||||
`${object.uuid}.quaternion`,
|
||||
[0, 1],
|
||||
[...closed.quaternion.toArray(), ...object.quaternion.toArray()],
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
poseWindowMovingParts(node, windowObject, 0)
|
||||
|
||||
if (tracks.length === 0) return null
|
||||
return openClip(id, node, tracks)
|
||||
}
|
||||
|
||||
// --- Identity stamping ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Replace every clone's userData with `{}`, then stamp identity onto the nodes
|
||||
* that `sceneRegistry` tracks. Wiping first guarantees no editor/runtime marker
|
||||
* (e.g. `pascalSwingLeaf`, cached-material flags) leaks into glTF extras — the
|
||||
* file describes itself with exactly the fields a consumer needs.
|
||||
*/
|
||||
function stampIdentity(
|
||||
scene: THREE.Object3D,
|
||||
cloneByOriginal: Map<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 }
|
||||
if (node.name) extras.label = node.name
|
||||
if (node.type === 'door' || node.type === 'window') {
|
||||
extras.openable = true
|
||||
const clipNames = clipNamesByNode.get(id)
|
||||
if (clipNames) extras.clips = clipNames
|
||||
}
|
||||
target.userData = extras
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,9 @@ export { getVisibleWallMaterials } from './systems/wall/wall-materials'
|
||||
// 800+ lines of CSG / mitering logic during Phase 3. These exports are
|
||||
// removed in Phase 6 when the legacy mount points are deleted.
|
||||
export { WallSystem } from './systems/wall/wall-system'
|
||||
export { WindowAnimationSystem } from './systems/window/window-animation-system'
|
||||
export {
|
||||
poseWindowMovingParts,
|
||||
WindowAnimationSystem,
|
||||
} from './systems/window/window-animation-system'
|
||||
export { buildWindowPreviewMesh, WindowSystem } from './systems/window/window-system'
|
||||
export { ZoneSystem } from './systems/zone/zone-system'
|
||||
|
||||
@@ -1092,6 +1092,7 @@ function addDoorLeaf(
|
||||
hingeX,
|
||||
hingeSide,
|
||||
swingRotation,
|
||||
openRotationY,
|
||||
segments,
|
||||
contentPadding,
|
||||
handle,
|
||||
@@ -1117,6 +1118,10 @@ function addDoorLeaf(
|
||||
hingeX: number
|
||||
hingeSide: 'left' | 'right'
|
||||
swingRotation: number
|
||||
// Leaf rotation (radians, about the hinge Y axis) at fully-open. The GLB
|
||||
// exporter reads this off the leaf group to bake an open/close clip; it is
|
||||
// the kinematic endpoint, independent of the current `swingRotation`.
|
||||
openRotationY: number
|
||||
segments: DoorNode['segments']
|
||||
contentPadding: DoorNode['contentPadding']
|
||||
handle: boolean
|
||||
@@ -1147,6 +1152,10 @@ function addDoorLeaf(
|
||||
const leafGroup = new THREE.Group()
|
||||
leafGroup.position.set(hingeX, 0, 0)
|
||||
leafGroup.rotation.y = swingRotation
|
||||
// Marks this group as the swing leaf and records its fully-open angle so the
|
||||
// GLB exporter can bake an open/close animation clip from a single pose. The
|
||||
// exporter strips this marker before writing the file.
|
||||
leafGroup.userData.pascalSwingLeaf = { axis: 'y', openRotationY }
|
||||
mesh.add(leafGroup)
|
||||
|
||||
const addLeafBox = (
|
||||
@@ -2461,6 +2470,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
||||
hingeX: -insideWidth / 2,
|
||||
hingeSide: 'left',
|
||||
swingRotation: -clampedSwingAngle * swingDirectionSign,
|
||||
openRotationY: (-Math.PI / 2) * swingDirectionSign,
|
||||
segments,
|
||||
contentPadding,
|
||||
handle,
|
||||
@@ -2489,6 +2499,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
||||
hingeX: insideWidth / 2,
|
||||
hingeSide: 'right',
|
||||
swingRotation: clampedSwingAngle * swingDirectionSign,
|
||||
openRotationY: (Math.PI / 2) * swingDirectionSign,
|
||||
segments,
|
||||
contentPadding,
|
||||
handle,
|
||||
@@ -2520,6 +2531,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
||||
hingeX,
|
||||
hingeSide: hingesSide,
|
||||
swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign,
|
||||
openRotationY: (Math.PI / 2) * swingDirectionSign * hingeDirectionSign,
|
||||
segments,
|
||||
contentPadding,
|
||||
handle,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import type { Object3D } from 'three'
|
||||
import {
|
||||
AWNING_WINDOW_SASH_NAME,
|
||||
CASEMENT_WINDOW_SASH_NAME,
|
||||
@@ -28,12 +29,20 @@ function markWindowDirty(windowId: AnyNodeId) {
|
||||
scene.dirtyNodes.add(windowId)
|
||||
}
|
||||
|
||||
function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) {
|
||||
const node = useScene.getState().nodes[windowId]
|
||||
if (node?.type !== 'window') return false
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(windowId)
|
||||
|
||||
/**
|
||||
* Pose a window's moving parts (sash/panel/slats) at `value` (0 = closed,
|
||||
* 1 = open) by mutating the named child groups under `mesh`. Returns true when
|
||||
* the window type has a direct pose path and the named parts were found.
|
||||
*
|
||||
* This is the single source of truth for window kinematics: the live animation
|
||||
* system poses the registered scene mesh, and the GLB exporter poses an export
|
||||
* clone to sample the open/close keyframes for a baked animation clip.
|
||||
*/
|
||||
export function poseWindowMovingParts(
|
||||
node: WindowNode,
|
||||
mesh: Object3D | undefined,
|
||||
value: number,
|
||||
): boolean {
|
||||
if (node.windowType === 'sliding') {
|
||||
const activePanel = mesh?.getObjectByName(SLIDING_WINDOW_ACTIVE_PANEL_NAME)
|
||||
if (!activePanel) return false
|
||||
@@ -120,6 +129,12 @@ function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) {
|
||||
return false
|
||||
}
|
||||
|
||||
function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) {
|
||||
const node = useScene.getState().nodes[windowId]
|
||||
if (node?.type !== 'window') return false
|
||||
return poseWindowMovingParts(node, sceneRegistry.nodes.get(windowId), value)
|
||||
}
|
||||
|
||||
export const WindowAnimationSystem = () => {
|
||||
useFrame(({ clock }) => {
|
||||
const interactive = useInteractive.getState()
|
||||
|
||||
Reference in New Issue
Block a user