From 06e4cd7748201578975477b6e96299921e221502 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 22 Jun 2026 11:55:27 -0400 Subject: [PATCH] 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) --- .../src/components/editor/export-manager.tsx | 53 +-- packages/editor/src/lib/glb-export.test.ts | 187 +++++++++ packages/editor/src/lib/glb-export.ts | 378 ++++++++++++++++++ packages/viewer/src/index.ts | 5 +- .../viewer/src/systems/door/door-system.tsx | 12 + .../window/window-animation-system.tsx | 27 +- 6 files changed, 624 insertions(+), 38 deletions(-) create mode 100644 packages/editor/src/lib/glb-export.test.ts create mode 100644 packages/editor/src/lib/glb-export.ts diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index abbfebb3..7f31f7ad 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -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 + 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((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') diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts new file mode 100644 index 00000000..b6f616bd --- /dev/null +++ b/packages/editor/src/lib/glb-export.test.ts @@ -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 = {}) { + // Duck-typed stand-in for the viewer's MeshStandard/LambertNodeMaterial: + // the exporter keys off `isNodeMaterial` and reads plain PBR props. + return { + isNodeMaterial: true, + name: 'painted', + color: new THREE.Color('#cc3300'), + roughness: 0.3, + metalness: 0.7, + transparent: false, + opacity: 1, + side: THREE.FrontSide, + alphaTest: 0, + depthWrite: true, + depthTest: true, + vertexColors: false, + toneMapped: true, + ...overrides, + } as unknown as THREE.Material +} + +function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh { + const geometry = new THREE.BoxGeometry(1, 1, 1) + return new THREE.Mesh(geometry, material) +} + +describe('prepareSceneForExport', () => { + test('converts NodeMaterials to classic glTF-standard materials', () => { + const root = new THREE.Group() + root.name = 'scene-renderer' + const mesh = meshWithNodeMaterial(nodeMaterial()) + root.add(mesh) + + const { scene } = prepareSceneForExport(root, {}) + + const exported = scene.children[0] as THREE.Mesh + const material = exported.material as THREE.MeshStandardMaterial + expect(material.isMeshStandardMaterial).toBe(true) + expect(material.roughness).toBeCloseTo(0.3) + expect(material.metalness).toBeCloseTo(0.7) + expect(material.color.getHexString()).toBe('cc3300') + }) + + test('shared NodeMaterial instances convert to a single shared material', () => { + const root = new THREE.Group() + const shared = nodeMaterial() + root.add(meshWithNodeMaterial(shared), meshWithNodeMaterial(shared)) + + const { scene } = prepareSceneForExport(root, {}) + + const meshes = scene.children as THREE.Mesh[] + expect(meshes[0]!.material).toBe(meshes[1]!.material) + }) + + test('strips editor overlays that live off the scene layer', () => { + const root = new THREE.Group() + const realMesh = meshWithNodeMaterial(nodeMaterial()) + const overlay = meshWithNodeMaterial(nodeMaterial()) + overlay.layers.set(1) // OVERLAY_LAYER / EDITOR_LAYER — off scene layer 0 + root.add(realMesh, overlay) + + const { scene } = prepareSceneForExport(root, {}) + + const meshes: THREE.Mesh[] = [] + scene.traverse((o) => { + if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh) + }) + expect(meshes).toHaveLength(1) + }) + + test('neutralises an invisible hitbox root but keeps its visible children', () => { + // Door/window roots are selection hitboxes: a box geometry with an invisible + // material (object stays visible). Left intact it would plug the wall opening. + const root = new THREE.Group() + const hitbox = new THREE.Mesh( + new THREE.BoxGeometry(1, 2, 0.2), + new THREE.MeshBasicMaterial({ visible: false }), + ) + const leaf = meshWithNodeMaterial(nodeMaterial()) + hitbox.add(leaf) + root.add(hitbox) + + const doorId = 'door_hitbox' + sceneRegistry.nodes.set(doorId, hitbox) + const nodes: Record = { + [doorId]: { object: 'node', id: doorId, type: 'door' } as unknown as AnyNode, + } + + const { scene } = prepareSceneForExport(root, nodes) + + const exported = scene.getObjectByProperty('name', doorId) as THREE.Mesh + expect(exported).toBeDefined() + // Geometry emptied -> GLTFExporter emits a plain node, no solid block. + expect(exported.geometry.getAttribute('position')).toBeUndefined() + // The visible leaf survives as a child. + const visibleChildren = exported.children.filter((c) => (c as THREE.Mesh).isMesh) + expect(visibleChildren).toHaveLength(1) + }) + + test('stamps identity from the scene registry and strips other userData', () => { + const root = new THREE.Group() + const doorGroup = new THREE.Group() + const leaf = new THREE.Group() + leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 } + leaf.add(meshWithNodeMaterial(nodeMaterial())) + doorGroup.add(leaf) + root.add(doorGroup) + + const doorId = 'door_test' + sceneRegistry.nodes.set(doorId, doorGroup) + const nodes: Record = { + [doorId]: { + object: 'node', + id: doorId, + type: 'door', + name: 'Front door', + } as unknown as AnyNode, + } + + const { scene } = prepareSceneForExport(root, nodes) + + const exportedDoor = scene.getObjectByProperty('name', doorId) + expect(exportedDoor).toBeDefined() + expect(exportedDoor?.userData).toEqual({ + pascalId: doorId, + kind: 'door', + label: 'Front door', + openable: true, + clips: ['Front door: open'], + }) + + // The swing-leaf marker must not survive into glTF extras. + let leafMarkerSurvived = false + scene.traverse((object) => { + if (object.userData.pascalSwingLeaf) leafMarkerSurvived = true + }) + expect(leafMarkerSurvived).toBe(false) + }) + + test('bakes a swing door into an open quaternion clip', () => { + const root = new THREE.Group() + const doorGroup = new THREE.Group() + const leaf = new THREE.Group() + leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 } + leaf.add(meshWithNodeMaterial(nodeMaterial())) + doorGroup.add(leaf) + root.add(doorGroup) + + const doorId = 'door_swing' + sceneRegistry.nodes.set(doorId, doorGroup) + const nodes: Record = { + [doorId]: { object: 'node', id: doorId, type: 'door', name: 'Door' } as unknown as AnyNode, + } + + const { scene, animations } = prepareSceneForExport(root, nodes) + + expect(animations).toHaveLength(1) + const clip = animations[0]! + expect(clip.name).toBe('Door: open') + expect(clip.duration).toBe(1) + // Playback intent carried in extras so consumers can play once and hold. + expect(clip.userData).toEqual({ loop: false }) + + const track = clip.tracks[0]! + expect(track).toBeInstanceOf(THREE.QuaternionKeyframeTrack) + expect(track.name.endsWith('.quaternion')).toBe(true) + expect(Array.from(track.times)).toEqual([0, 1]) + + // The track must target an object that exists in the exported tree. + const targetUuid = track.name.replace('.quaternion', '') + const target = scene.getObjectByProperty('uuid', targetUuid) + expect(target).toBeDefined() + + // Rest pose is closed: the first keyframe is the identity rotation. + const closed = new THREE.Quaternion().fromArray(Array.from(track.values).slice(0, 4)) + expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0) + }) +}) diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts new file mode 100644 index 00000000..b312314d --- /dev/null +++ b/packages/editor/src/lib/glb-export.ts @@ -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, +): 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 { + const originals: THREE.Object3D[] = [] + const clones: THREE.Object3D[] = [] + source.traverse((object) => originals.push(object)) + clone.traverse((object) => clones.push(object)) + + const map = new Map() + for (let i = 0; i < originals.length; i++) { + const target = clones[i] + if (target) map.set(originals[i]!, target) + } + return map +} + +// A single empty geometry shared by every container mesh we neutralise below — +// it has no attributes, so GLTFExporter's processMesh returns null and emits a +// plain transform node instead of a primitive. +const EMPTY_GEOMETRY = new THREE.BufferGeometry() + +/** + * Strip everything that must not bake into the model: + * - Editor overlays on non-scene layers (gizmos, selection handles, ground + * grid, zone fills). The editor camera shows them via extra layers; a + * thumbnail/bake is layer 0 only. Scene-layer affordances that can't be + * layer-filtered (ceiling/site brackets) are hidden by the caller's + * `thumbnail:before-capture` emit before the clone instead. + * - Selection hitboxes, whose invisibility lives on `material.visible = false` + * (which GLTFExporter's `onlyVisible` does not catch). A door/window's hitbox + * root is a box spanning the wall opening — left in, it plugs the cutout. + * With children (it parents the visible frame + leaf) it keeps its node but + * loses its geometry; childless ones are removed outright. + */ +function pruneNonRenderableMeshes(root: THREE.Object3D) { + 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() + 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 { + if ((material as { isNodeMaterial?: boolean }).isNodeMaterial !== true) return material + + const cached = cache.get(material) + if (cached) return cached + + const src = material as THREE.Material & Record + const target = new THREE.MeshStandardMaterial() + + target.name = material.name + if (src.color instanceof THREE.Color) target.color.copy(src.color) + if (src.emissive instanceof THREE.Color) target.emissive.copy(src.emissive) + if (typeof src.emissiveIntensity === 'number') target.emissiveIntensity = src.emissiveIntensity + // Lambert (solid-shading / glass) node materials carry no PBR scalars; a fully + // rough, non-metallic surface is the faithful lit fallback. + target.roughness = typeof src.roughness === 'number' ? src.roughness : 1 + target.metalness = typeof src.metalness === 'number' ? src.metalness : 0 + 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)[slot] = texture + } + } + + cache.set(material, target) + return target +} + +// --- Animation clip baking ---------------------------------------------- + +function bakeAnimationClips( + cloneByOriginal: Map, + nodes: Record, +): { clips: THREE.AnimationClip[]; clipNamesByNode: Map } { + const clips: THREE.AnimationClip[] = [] + const clipNamesByNode = new Map() + + for (const [id, original] of sceneRegistry.nodes) { + const node = nodes[id] + const target = cloneByOriginal.get(original) + if (!node || !target) continue + + const clip = + node.type === 'door' + ? bakeDoorClip(id, node, target) + : node.type === 'window' + ? bakeWindowClip(id, node as WindowNode, target) + : 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, + nodes: Record, + clipNamesByNode: Map, +) { + scene.traverse((object) => { + object.userData = {} + }) + + for (const [id, original] of sceneRegistry.nodes) { + const node = nodes[id] + const target = cloneByOriginal.get(original) + if (!node || !target) continue + + target.name = id + const extras: Record = { pascalId: id, kind: node.type } + 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 + } +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 562fc73e..06aaa6dd 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -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' diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 04763991..b059989c 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -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, diff --git a/packages/viewer/src/systems/window/window-animation-system.tsx b/packages/viewer/src/systems/window/window-animation-system.tsx index bf6dbfd9..f5833c8c 100644 --- a/packages/viewer/src/systems/window/window-animation-system.tsx +++ b/packages/viewer/src/systems/window/window-animation-system.tsx @@ -7,6 +7,7 @@ import { type WindowNode, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' +import type { Object3D } from 'three' import { AWNING_WINDOW_SASH_NAME, CASEMENT_WINDOW_SASH_NAME, @@ -28,12 +29,20 @@ function markWindowDirty(windowId: AnyNodeId) { scene.dirtyNodes.add(windowId) } -function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) { - const node = useScene.getState().nodes[windowId] - if (node?.type !== 'window') return false - - const mesh = sceneRegistry.nodes.get(windowId) - +/** + * Pose a window's moving parts (sash/panel/slats) at `value` (0 = closed, + * 1 = open) by mutating the named child groups under `mesh`. Returns true when + * the window type has a direct pose path and the named parts were found. + * + * This is the single source of truth for window kinematics: the live animation + * system poses the registered scene mesh, and the GLB exporter poses an export + * clone to sample the open/close keyframes for a baked animation clip. + */ +export function poseWindowMovingParts( + node: WindowNode, + mesh: Object3D | undefined, + value: number, +): boolean { if (node.windowType === 'sliding') { const activePanel = mesh?.getObjectByName(SLIDING_WINDOW_ACTIVE_PANEL_NAME) if (!activePanel) return false @@ -120,6 +129,12 @@ function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) { return false } +function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) { + const node = useScene.getState().nodes[windowId] + if (node?.type !== 'window') return false + return poseWindowMovingParts(node, sceneRegistry.nodes.get(windowId), value) +} + export const WindowAnimationSystem = () => { useFrame(({ clock }) => { const interactive = useInteractive.getState()