diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index 669eddff..cc31fba4 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -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) + } + }) }) diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index ecb654dd..ce84353d 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1392,7 +1392,7 @@ export function poseDoorMovingParts( // the group rotates about its own origin (see the closed build below). const angle = (Math.PI / 2) * t const hingeY = leafCenterY + leafHeight / 2 - group.rotation.x = -angle + group.rotation.set(-angle, 0, 0) group.position.set(0, hingeY * (1 - Math.cos(angle)), Math.sin(angle) * (hingeY - leafHeight)) return true } @@ -1410,7 +1410,11 @@ export function poseDoorMovingParts( const direction = index % 2 === 0 ? -1 : 1 if (group) { posed = true - group.rotation.y = (prevDirection - direction) * foldAngle + // Set the full triple (not just `.y`): when the export clones and + // decomposes the door matrix, a |Y| > π/2 rotation re-derives into a + // gimbal-flipped euler (x=z=π). Assigning only `.y` would leave that + // π residue on x/z and bake a flipped rest pose. + group.rotation.set(0, (prevDirection - direction) * foldAngle, 0) } prevDirection = direction } @@ -1446,7 +1450,7 @@ export function poseDoorMovingParts( z = -(curveRadius + pathPosition - curveLength) } group.position.set(0, y, z) - group.rotation.x = rotationX + group.rotation.set(rotationX, 0, 0) } return posed }