Skip to content

Commit a602335

Browse files
authored
Merge pull request #444 from pascalorg/fix/bake-door-window-anims
fix(doors): bake open-animation clips for every operation door + un-flipped rest pose
2 parents 2dbe467 + b0313a1 commit a602335

8 files changed

Lines changed: 653 additions & 220 deletions

File tree

9.4 KB
Binary file not shown.

packages/editor/src/lib/glb-export.test.ts

Lines changed: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterEach, describe, expect, test } from 'bun:test'
2-
import { type AnyNode, sceneRegistry } from '@pascal-app/core'
2+
import { type AnyNode, DoorNode, sceneRegistry } from '@pascal-app/core'
3+
import { buildDoorPreviewMesh } from '@pascal-app/viewer'
34
import * as THREE from 'three'
45
import { prepareSceneForExport } from './glb-export'
56

@@ -135,7 +136,7 @@ describe('prepareSceneForExport', () => {
135136
kind: 'door',
136137
label: 'Front door',
137138
openable: true,
138-
clips: ['Front door: open'],
139+
clips: ['door_test: open'],
139140
})
140141

141142
// The swing-leaf marker must not survive into glTF extras.
@@ -245,7 +246,7 @@ describe('prepareSceneForExport', () => {
245246

246247
expect(animations).toHaveLength(1)
247248
const clip = animations[0]!
248-
expect(clip.name).toBe('Door: open')
249+
expect(clip.name).toBe('door_swing: open')
249250
expect(clip.duration).toBe(1)
250251
// Playback intent carried in extras so consumers can play once and hold.
251252
expect(clip.userData).toEqual({ loop: false })
@@ -264,4 +265,129 @@ describe('prepareSceneForExport', () => {
264265
const closed = new THREE.Quaternion().fromArray(Array.from(track.values).slice(0, 4))
265266
expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0)
266267
})
268+
269+
test('bakes a sliding door into a sampled position clip', () => {
270+
// Operation doors build their moving parts in a named group posed by
271+
// `poseDoorMovingParts`; the exporter samples it into keyframes. The active
272+
// panel group slides along x.
273+
const root = new THREE.Group()
274+
const doorGroup = new THREE.Group()
275+
const activePanel = new THREE.Group()
276+
activePanel.name = 'door-sliding-active'
277+
activePanel.add(meshWithNodeMaterial(nodeMaterial()))
278+
doorGroup.add(activePanel)
279+
root.add(doorGroup)
280+
281+
const doorId = 'door_sliding'
282+
sceneRegistry.nodes.set(doorId, doorGroup)
283+
const nodes: Record<string, AnyNode> = {
284+
[doorId]: {
285+
object: 'node',
286+
id: doorId,
287+
type: 'door',
288+
name: 'Slider',
289+
doorType: 'sliding',
290+
slideDirection: 'left',
291+
width: 1,
292+
height: 2.1,
293+
frameThickness: 0.05,
294+
} as unknown as AnyNode,
295+
}
296+
297+
const { scene, animations } = prepareSceneForExport(root, nodes)
298+
299+
expect(animations).toHaveLength(1)
300+
const clip = animations[0]!
301+
expect(clip.name).toBe('door_sliding: open')
302+
expect(clip.duration).toBe(1)
303+
expect(clip.userData).toEqual({ loop: false })
304+
305+
const track = clip.tracks[0]!
306+
expect(track).toBeInstanceOf(THREE.VectorKeyframeTrack)
307+
expect(track.name.endsWith('.position')).toBe(true)
308+
// 16 segments -> 17 keyframes, evenly spaced over the 1s clip.
309+
expect(track.times.length).toBe(17)
310+
expect(track.times[0]).toBeCloseTo(0)
311+
expect(track.times[track.times.length - 1]!).toBeCloseTo(1)
312+
313+
// Rest pose is closed (first keyframe centred); the panel slides off-centre.
314+
expect(track.values[0]!).toBeCloseTo(0)
315+
expect(track.values[1]!).toBeCloseTo(0)
316+
expect(track.values[2]!).toBeCloseTo(0)
317+
const lastX = track.values[track.values.length - 3]!
318+
expect(Math.abs(lastX)).toBeGreaterThan(0.1)
319+
320+
const target = scene.getObjectByProperty('uuid', track.name.replace('.position', ''))
321+
expect(target).toBeDefined()
322+
323+
const exported = scene.getObjectByProperty('name', doorId)
324+
expect(exported?.userData.openable).toBe(true)
325+
expect(exported?.userData.clips).toEqual(['door_sliding: open'])
326+
})
327+
328+
test('bakes a roll-up curtain into a sampled scale clip', () => {
329+
// Roll-up geometry can't vanish in a glTF clip, so the bake scales the
330+
// curtain group up into the lintel instead.
331+
const root = new THREE.Group()
332+
const doorGroup = new THREE.Group()
333+
const curtain = new THREE.Group()
334+
curtain.name = 'door-rollup-curtain'
335+
curtain.add(meshWithNodeMaterial(nodeMaterial()))
336+
doorGroup.add(curtain)
337+
root.add(doorGroup)
338+
339+
const doorId = 'door_rollup'
340+
sceneRegistry.nodes.set(doorId, doorGroup)
341+
const nodes: Record<string, AnyNode> = {
342+
[doorId]: {
343+
object: 'node',
344+
id: doorId,
345+
type: 'door',
346+
name: 'Roll-up',
347+
doorType: 'garage-rollup',
348+
width: 2.4,
349+
height: 2.2,
350+
frameThickness: 0.05,
351+
} as unknown as AnyNode,
352+
}
353+
354+
const { animations } = prepareSceneForExport(root, nodes)
355+
356+
expect(animations).toHaveLength(1)
357+
const scaleTrack = animations[0]!.tracks.find((t) => t.name.endsWith('.scale'))
358+
expect(scaleTrack).toBeInstanceOf(THREE.VectorKeyframeTrack)
359+
// Rest pose is closed (full curtain, scale 1); it shrinks toward the header.
360+
expect(Array.from(scaleTrack!.values).slice(0, 3)).toEqual([1, 1, 1])
361+
const lastScaleY = scaleTrack!.values[scaleTrack!.values.length - 2]!
362+
expect(lastScaleY).toBeLessThan(0.1)
363+
})
364+
365+
// Regression: a folding door saved in an open state (|fold angle| > π/2) used
366+
// to bake a 180°-flipped rest pose. The export clones + decomposes the door
367+
// matrix, which re-derives a gimbal-flipped euler (x=z=π) for the wide Y
368+
// rotation; the pose reset must zero the full euler triple, not just `.y`.
369+
test('bakes an identity rest pose for an open folding door', () => {
370+
const node = DoorNode.parse({
371+
id: 'door_folding',
372+
doorType: 'folding',
373+
leafCount: 4,
374+
operationState: 0.65,
375+
})
376+
const mesh = buildDoorPreviewMesh(node)
377+
const root = new THREE.Group()
378+
root.add(mesh)
379+
sceneRegistry.nodes.set(node.id, mesh)
380+
381+
const { scene, animations } = prepareSceneForExport(root, {
382+
[node.id]: node as unknown as AnyNode,
383+
})
384+
385+
expect(animations).toHaveLength(1)
386+
for (let index = 0; index < 4; index++) {
387+
const panel = scene.getObjectByName(`door-fold-${index}`)
388+
expect(panel).toBeDefined()
389+
// Rest quaternion must be identity — no residual π on any axis.
390+
expect(panel!.quaternion.angleTo(new THREE.Quaternion())).toBeLessThan(1e-4)
391+
}
392+
})
267393
})

packages/editor/src/lib/glb-export.ts

Lines changed: 140 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import {
22
type AnyNode,
3+
type DoorNode,
34
emitter,
45
getLevelDisplayName,
6+
isOperationDoorType,
57
itemClipRegistry,
68
type LevelNode,
79
sceneRegistry,
810
type WindowNode,
911
type ZoneNode,
1012
} from '@pascal-app/core'
11-
import { poseWindowMovingParts, SCENE_LAYER, snapLevelsToTruePositions } from '@pascal-app/viewer'
13+
import {
14+
poseDoorMovingParts,
15+
poseWindowMovingParts,
16+
SCENE_LAYER,
17+
snapLevelsToTruePositions,
18+
} from '@pascal-app/viewer'
1219
import type { Object3D } from 'three'
1320
import * as THREE from 'three'
1421
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
@@ -433,12 +440,128 @@ function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationCl
433440
return clip
434441
}
435442

443+
/**
444+
* Bake a door's open motion. Swing doors (hinged/double/french) carry a
445+
* `pascalSwingLeaf` marker and bake a single quaternion track per leaf;
446+
* operation doors (sliding/pocket/barn/folding/garage-*) build their moving
447+
* parts in named groups posed by `poseDoorMovingParts`, sampled here into
448+
* keyframes (their motion is non-linear, e.g. the sectional's overhead curve).
449+
*/
450+
function bakeDoorClip(
451+
id: string,
452+
node: AnyNode,
453+
doorObject: THREE.Object3D,
454+
): THREE.AnimationClip | null {
455+
if (node.type === 'door' && isOperationDoorType((node as DoorNode).doorType)) {
456+
return bakeOperationDoorClip(id, node as DoorNode, doorObject)
457+
}
458+
return bakeSwingDoorClip(id, node, doorObject)
459+
}
460+
461+
/** Number of keyframes sampled across an operation door's 0→1 open motion. */
462+
const OPERATION_DOOR_SAMPLES = 16
463+
464+
/**
465+
* Sample an operation door's open motion into keyframe tracks by posing the
466+
* export clone with `poseDoorMovingParts` at evenly-spaced fractions. Only the
467+
* named moving groups change (their children are rigid), so a track is emitted
468+
* per group whose position / rotation / scale actually moves. The clone is left
469+
* posed closed so the GLB's rest state is shut.
470+
*/
471+
function bakeOperationDoorClip(
472+
id: string,
473+
node: DoorNode,
474+
doorObject: THREE.Object3D,
475+
): THREE.AnimationClip | null {
476+
if (!poseDoorMovingParts(node, doorObject, 0)) return null
477+
478+
const objects: THREE.Object3D[] = []
479+
doorObject.traverse((object) => objects.push(object))
480+
const basePoses = objects.map((object) => ({
481+
position: object.position.clone(),
482+
quaternion: object.quaternion.clone(),
483+
scale: object.scale.clone(),
484+
}))
485+
486+
const times: number[] = []
487+
const positionSamples = objects.map(() => [] as number[])
488+
const quaternionSamples = objects.map(() => [] as number[])
489+
const scaleSamples = objects.map(() => [] as number[])
490+
491+
for (let step = 0; step <= OPERATION_DOOR_SAMPLES; step++) {
492+
const t = step / OPERATION_DOOR_SAMPLES
493+
times.push(t)
494+
poseDoorMovingParts(node, doorObject, t)
495+
for (let i = 0; i < objects.length; i++) {
496+
const object = objects[i]!
497+
positionSamples[i]!.push(...object.position.toArray())
498+
quaternionSamples[i]!.push(...object.quaternion.toArray())
499+
scaleSamples[i]!.push(...object.scale.toArray())
500+
}
501+
}
502+
503+
const tracks: THREE.KeyframeTrack[] = []
504+
for (let i = 0; i < objects.length; i++) {
505+
const object = objects[i]!
506+
const base = basePoses[i]!
507+
if (samplesMovePosition(positionSamples[i]!, base.position)) {
508+
tracks.push(
509+
new THREE.VectorKeyframeTrack(`${object.uuid}.position`, times, positionSamples[i]!),
510+
)
511+
}
512+
if (samplesMoveQuaternion(quaternionSamples[i]!, base.quaternion)) {
513+
tracks.push(
514+
new THREE.QuaternionKeyframeTrack(
515+
`${object.uuid}.quaternion`,
516+
times,
517+
quaternionSamples[i]!,
518+
),
519+
)
520+
}
521+
if (samplesMoveScale(scaleSamples[i]!, base.scale)) {
522+
tracks.push(new THREE.VectorKeyframeTrack(`${object.uuid}.scale`, times, scaleSamples[i]!))
523+
}
524+
}
525+
526+
poseDoorMovingParts(node, doorObject, 0)
527+
528+
if (tracks.length === 0) return null
529+
return openClip(id, tracks)
530+
}
531+
532+
function samplesMovePosition(flat: number[], base: THREE.Vector3): boolean {
533+
const point = new THREE.Vector3()
534+
for (let i = 0; i < flat.length; i += 3) {
535+
point.set(flat[i]!, flat[i + 1]!, flat[i + 2]!)
536+
if (point.distanceToSquared(base) > POSE_EPSILON) return true
537+
}
538+
return false
539+
}
540+
541+
function samplesMoveQuaternion(flat: number[], base: THREE.Quaternion): boolean {
542+
const quaternion = new THREE.Quaternion()
543+
for (let i = 0; i < flat.length; i += 4) {
544+
quaternion.set(flat[i]!, flat[i + 1]!, flat[i + 2]!, flat[i + 3]!)
545+
if (base.angleTo(quaternion) > POSE_EPSILON) return true
546+
}
547+
return false
548+
}
549+
550+
function samplesMoveScale(flat: number[], base: THREE.Vector3): boolean {
551+
const point = new THREE.Vector3()
552+
for (let i = 0; i < flat.length; i += 3) {
553+
point.set(flat[i]!, flat[i + 1]!, flat[i + 2]!)
554+
if (point.distanceToSquared(base) > POSE_EPSILON) return true
555+
}
556+
return false
557+
}
558+
436559
/**
437560
* Bake a swing door's open motion. Each marked leaf is rotated from closed
438561
* (rest pose) to its fully-open angle and emitted as a 1-second quaternion
439562
* track; the leaf is left at the closed pose so the GLB's rest state is shut.
440563
*/
441-
function bakeDoorClip(
564+
function bakeSwingDoorClip(
442565
id: string,
443566
node: AnyNode,
444567
doorObject: THREE.Object3D,
@@ -465,21 +588,24 @@ function bakeDoorClip(
465588
})
466589

467590
if (tracks.length === 0) return null
468-
return openClip(id, node, tracks)
591+
return openClip(id, tracks)
469592
}
470593

471594
/**
472-
* Wrap an open motion in a named 1-second clip. The name uses the node's label
473-
* when set (e.g. "Door 1: open") so a glTF player lists readable clips, falling
474-
* back to the id. glTF has no core loop flag — the player decides — so we stamp
475-
* `extras.loop = false` (via the clip's userData, which `GLTFExporter`
476-
* serialises onto the animation): Pascal's `/viewer` and any extras-aware
477-
* consumer play it once and hold the open pose; a dumb glTF player still loops.
478-
* Consumers map a clip back to its node by walking up from a channel's target to
479-
* the nearest ancestor carrying `extras.pascalId`, so the name stays cosmetic.
595+
* Wrap an open motion in a named 1-second clip. The name is keyed by the node id
596+
* (`<id>: open`), NOT the node's display name: clip names must be unique because
597+
* the baked viewer drives playback by clip name (`useAnimations` maps name →
598+
* action), so two same-named openables (e.g. several "Window 1"s) would collapse
599+
* to a single action and a trigger on one would animate another. The
600+
* human-readable name lives in `extras.label` instead. glTF has no core loop
601+
* flag — the player decides — so we stamp `extras.loop = false` (via the clip's
602+
* userData, which `GLTFExporter` serialises onto the animation): Pascal's
603+
* `/viewer` and any extras-aware consumer play it once and hold the open pose; a
604+
* dumb glTF player still loops. Consumers map a clip back to its node by walking
605+
* up from a channel's target to the nearest ancestor carrying `extras.pascalId`.
480606
*/
481-
function openClip(id: string, node: AnyNode, tracks: THREE.KeyframeTrack[]): THREE.AnimationClip {
482-
const clip = new THREE.AnimationClip(`${node.name ?? id}: open`, 1, tracks)
607+
function openClip(id: string, tracks: THREE.KeyframeTrack[]): THREE.AnimationClip {
608+
const clip = new THREE.AnimationClip(`${id}: open`, 1, tracks)
483609
clip.userData = { loop: false }
484610
return clip
485611
}
@@ -539,7 +665,7 @@ function bakeWindowClip(
539665
poseWindowMovingParts(node, windowObject, 0)
540666

541667
if (tracks.length === 0) return null
542-
return openClip(id, node, tracks)
668+
return openClip(id, tracks)
543669
}
544670

545671
// --- Identity stamping ---------------------------------------------------

packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
Mesh,
1616
MeshBasicMaterial,
1717
type Object3D,
18+
type PerspectiveCamera,
1819
Quaternion,
1920
Vector3,
2021
} from 'three'
@@ -24,6 +25,7 @@ import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2'
2425
import { SCENE_LAYER } from '../../lib/layers'
2526
import useViewer from '../../store/use-viewer'
2627
import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl'
28+
import { WALKTHROUGH_FOV } from './walkthrough-controls'
2729

2830
// Eye/capsule geometry mirrors the editor's first-person controller so the
2931
// baked walkthrough feels identical. The capsule centre sits below the eye; the
@@ -264,6 +266,22 @@ export function GlbWalkthroughController({ url }: { url: string }) {
264266
}
265267
}, [])
266268

269+
// Widen FOV while walking; the baked walkthrough rides the default 50° orbit
270+
// camera, which feels cramped on foot. Keyed on `camera` so it re-applies if
271+
// the instance swaps (e.g. the ortho→perspective switch above), restoring the
272+
// prior FOV on exit.
273+
useEffect(() => {
274+
const cam = camera as PerspectiveCamera
275+
if (!cam.isPerspectiveCamera) return
276+
const prevFov = cam.fov
277+
cam.fov = WALKTHROUGH_FOV
278+
cam.updateProjectionMatrix()
279+
return () => {
280+
cam.fov = prevFov
281+
cam.updateProjectionMatrix()
282+
}
283+
}, [camera])
284+
267285
useEffect(() => {
268286
worldRef.current = world
269287
if (world) {

0 commit comments

Comments
 (0)