fix(viewer): bake folding/garage doors at an un-flipped rest pose

`poseDoorMovingParts` assigned a single euler axis (`group.rotation.y` /
`.x`). The live system was fine because the group's euler stays a clean
(0, y, 0). But the GLB exporter clones the door and decomposes its matrix,
which re-derives a gimbal-flipped euler (x=z=π) for any rotation beyond
±90° — folding panels reach ~158°. The reset to t=0 then only zeroed `.y`,
leaving the π residue on x/z and baking a 180°-flipped rest pose (panels
folded out toward a wrong position even when closed).

Set the full euler triple via `.set()` in every pose branch so the other
two axes are always zeroed, clearing any decomposed residue. Add a
regression test that exports an open folding door and asserts an identity
rest pose for all panels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-26 12:12:11 -04:00
co-authored by Claude Opus 4.8
parent 4c81435e3b
commit 7530de5348
2 changed files with 38 additions and 4 deletions
+31 -1
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { type AnyNode, sceneRegistry } from '@pascal-app/core'
import { type AnyNode, DoorNode, sceneRegistry } from '@pascal-app/core'
import * as THREE from 'three'
import { buildDoorPreviewMesh } from '@pascal-app/viewer'
import { prepareSceneForExport } from './glb-export'
afterEach(() => {
@@ -360,4 +361,33 @@ describe('prepareSceneForExport', () => {
const lastScaleY = scaleTrack!.values[scaleTrack!.values.length - 2]!
expect(lastScaleY).toBeLessThan(0.1)
})
// Regression: a folding door saved in an open state (|fold angle| > π/2) used
// to bake a 180°-flipped rest pose. The export clones + decomposes the door
// matrix, which re-derives a gimbal-flipped euler (x=z=π) for the wide Y
// rotation; the pose reset must zero the full euler triple, not just `.y`.
test('bakes an identity rest pose for an open folding door', () => {
const node = DoorNode.parse({
id: 'door_folding',
doorType: 'folding',
leafCount: 4,
operationState: 0.65,
})
const mesh = buildDoorPreviewMesh(node)
const root = new THREE.Group()
root.add(mesh)
sceneRegistry.nodes.set(node.id, mesh)
const { scene, animations } = prepareSceneForExport(root, {
[node.id]: node as unknown as AnyNode,
})
expect(animations).toHaveLength(1)
for (let index = 0; index < 4; index++) {
const panel = scene.getObjectByName(`door-fold-${index}`)
expect(panel).toBeDefined()
// Rest quaternion must be identity — no residual π on any axis.
expect(panel!.quaternion.angleTo(new THREE.Quaternion())).toBeLessThan(1e-4)
}
})
})