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:
co-authored by
Claude Opus 4.8
parent
4c81435e3b
commit
7530de5348
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, test } from 'bun:test'
|
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 * as THREE from 'three'
|
||||||
|
import { buildDoorPreviewMesh } from '@pascal-app/viewer'
|
||||||
import { prepareSceneForExport } from './glb-export'
|
import { prepareSceneForExport } from './glb-export'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -360,4 +361,33 @@ describe('prepareSceneForExport', () => {
|
|||||||
const lastScaleY = scaleTrack!.values[scaleTrack!.values.length - 2]!
|
const lastScaleY = scaleTrack!.values[scaleTrack!.values.length - 2]!
|
||||||
expect(lastScaleY).toBeLessThan(0.1)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1392,7 +1392,7 @@ export function poseDoorMovingParts(
|
|||||||
// the group rotates about its own origin (see the closed build below).
|
// the group rotates about its own origin (see the closed build below).
|
||||||
const angle = (Math.PI / 2) * t
|
const angle = (Math.PI / 2) * t
|
||||||
const hingeY = leafCenterY + leafHeight / 2
|
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))
|
group.position.set(0, hingeY * (1 - Math.cos(angle)), Math.sin(angle) * (hingeY - leafHeight))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1410,7 +1410,11 @@ export function poseDoorMovingParts(
|
|||||||
const direction = index % 2 === 0 ? -1 : 1
|
const direction = index % 2 === 0 ? -1 : 1
|
||||||
if (group) {
|
if (group) {
|
||||||
posed = true
|
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
|
prevDirection = direction
|
||||||
}
|
}
|
||||||
@@ -1446,7 +1450,7 @@ export function poseDoorMovingParts(
|
|||||||
z = -(curveRadius + pathPosition - curveLength)
|
z = -(curveRadius + pathPosition - curveLength)
|
||||||
}
|
}
|
||||||
group.position.set(0, y, z)
|
group.position.set(0, y, z)
|
||||||
group.rotation.x = rotationX
|
group.rotation.set(rotationX, 0, 0)
|
||||||
}
|
}
|
||||||
return posed
|
return posed
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user