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:
Wassim SAMAD
2026-06-22 11:55:27 -04:00
co-authored by Claude Opus 4.8
parent b2f1a8432e
commit 06e4cd7748
6 changed files with 624 additions and 38 deletions
@@ -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')
+187
View File
@@ -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)
})
})
+378
View File
@@ -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
}
}
+4 -1
View File
@@ -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()