feat(doors): bake open-animation clips for sliding/garage/folding/pocket/barn doors

Only swing doors (hinged/double/french) baked an open clip into the GLB —
they carry a `pascalSwingLeaf` marker the exporter reads. Every operation
door type (sliding, pocket, barn, folding, garage-sectional/rollup/tiltup)
baked its `operationState` straight into mesh vertex positions at build
time, so the exporter had no re-poseable node to sample and the artifact
never flagged them `openable`.

Give operation doors the same build-once + pose-at-t split windows already
use. Each builder now emits its moving parts in a named group at the CLOSED
pose, and `poseDoorMovingParts` (the single source of truth, shared by the
live system and the GLB exporter) drives the open motion:

- sliding/pocket/barn: rigid leaf translation
- garage-tiltup: rigid hinge about the lintel
- folding: hinged accordion chain (nested groups, per-joint fold)
- garage-sectional: per-panel groups posed along the overhead curve
- garage-rollup: the one type whose live geometry changes (slats roll onto
  a drum, which a glTF clip can't express) keeps its full-detail live
  rebuild; the curtain is wrapped in a top-pivoted group the exporter
  scales up into the lintel as the baked approximation.

The exporter samples each operation door's motion into keyframe tracks
(16 segments) so the non-linear rigs (curve, accordion) stay faithful, and
stamps `extras.openable` + `extras.clips` so any glTF consumer can play it.

Tests: per-type kinematics (groups build, rest closed, open) +
sliding/roll-up clip baking (sampled position/scale tracks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-26 10:24:08 -04:00
co-authored by Claude Opus 4.8
parent a3aecf1907
commit c629171607
5 changed files with 561 additions and 204 deletions
@@ -264,4 +264,100 @@ describe('prepareSceneForExport', () => {
const closed = new THREE.Quaternion().fromArray(Array.from(track.values).slice(0, 4))
expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0)
})
test('bakes a sliding door into a sampled position clip', () => {
// Operation doors build their moving parts in a named group posed by
// `poseDoorMovingParts`; the exporter samples it into keyframes. The active
// panel group slides along x.
const root = new THREE.Group()
const doorGroup = new THREE.Group()
const activePanel = new THREE.Group()
activePanel.name = 'door-sliding-active'
activePanel.add(meshWithNodeMaterial(nodeMaterial()))
doorGroup.add(activePanel)
root.add(doorGroup)
const doorId = 'door_sliding'
sceneRegistry.nodes.set(doorId, doorGroup)
const nodes: Record<string, AnyNode> = {
[doorId]: {
object: 'node',
id: doorId,
type: 'door',
name: 'Slider',
doorType: 'sliding',
slideDirection: 'left',
width: 1,
height: 2.1,
frameThickness: 0.05,
} as unknown as AnyNode,
}
const { scene, animations } = prepareSceneForExport(root, nodes)
expect(animations).toHaveLength(1)
const clip = animations[0]!
expect(clip.name).toBe('Slider: open')
expect(clip.duration).toBe(1)
expect(clip.userData).toEqual({ loop: false })
const track = clip.tracks[0]!
expect(track).toBeInstanceOf(THREE.VectorKeyframeTrack)
expect(track.name.endsWith('.position')).toBe(true)
// 16 segments -> 17 keyframes, evenly spaced over the 1s clip.
expect(track.times.length).toBe(17)
expect(track.times[0]).toBeCloseTo(0)
expect(track.times[track.times.length - 1]!).toBeCloseTo(1)
// Rest pose is closed (first keyframe centred); the panel slides off-centre.
expect(track.values[0]!).toBeCloseTo(0)
expect(track.values[1]!).toBeCloseTo(0)
expect(track.values[2]!).toBeCloseTo(0)
const lastX = track.values[track.values.length - 3]!
expect(Math.abs(lastX)).toBeGreaterThan(0.1)
const target = scene.getObjectByProperty('uuid', track.name.replace('.position', ''))
expect(target).toBeDefined()
const exported = scene.getObjectByProperty('name', doorId)
expect(exported?.userData.openable).toBe(true)
expect(exported?.userData.clips).toEqual(['Slider: open'])
})
test('bakes a roll-up curtain into a sampled scale clip', () => {
// Roll-up geometry can't vanish in a glTF clip, so the bake scales the
// curtain group up into the lintel instead.
const root = new THREE.Group()
const doorGroup = new THREE.Group()
const curtain = new THREE.Group()
curtain.name = 'door-rollup-curtain'
curtain.add(meshWithNodeMaterial(nodeMaterial()))
doorGroup.add(curtain)
root.add(doorGroup)
const doorId = 'door_rollup'
sceneRegistry.nodes.set(doorId, doorGroup)
const nodes: Record<string, AnyNode> = {
[doorId]: {
object: 'node',
id: doorId,
type: 'door',
name: 'Roll-up',
doorType: 'garage-rollup',
width: 2.4,
height: 2.2,
frameThickness: 0.05,
} as unknown as AnyNode,
}
const { animations } = prepareSceneForExport(root, nodes)
expect(animations).toHaveLength(1)
const scaleTrack = animations[0]!.tracks.find((t) => t.name.endsWith('.scale'))
expect(scaleTrack).toBeInstanceOf(THREE.VectorKeyframeTrack)
// Rest pose is closed (full curtain, scale 1); it shrinks toward the header.
expect(Array.from(scaleTrack!.values).slice(0, 3)).toEqual([1, 1, 1])
const lastScaleY = scaleTrack!.values[scaleTrack!.values.length - 2]!
expect(lastScaleY).toBeLessThan(0.1)
})
})
+125 -2
View File
@@ -1,14 +1,21 @@
import {
type AnyNode,
type DoorNode,
emitter,
getLevelDisplayName,
isOperationDoorType,
itemClipRegistry,
type LevelNode,
sceneRegistry,
type WindowNode,
type ZoneNode,
} from '@pascal-app/core'
import { poseWindowMovingParts, SCENE_LAYER, snapLevelsToTruePositions } from '@pascal-app/viewer'
import {
poseDoorMovingParts,
poseWindowMovingParts,
SCENE_LAYER,
snapLevelsToTruePositions,
} from '@pascal-app/viewer'
import type { Object3D } from 'three'
import * as THREE from 'three'
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
@@ -433,12 +440,128 @@ function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationCl
return clip
}
/**
* Bake a door's open motion. Swing doors (hinged/double/french) carry a
* `pascalSwingLeaf` marker and bake a single quaternion track per leaf;
* operation doors (sliding/pocket/barn/folding/garage-*) build their moving
* parts in named groups posed by `poseDoorMovingParts`, sampled here into
* keyframes (their motion is non-linear, e.g. the sectional's overhead curve).
*/
function bakeDoorClip(
id: string,
node: AnyNode,
doorObject: THREE.Object3D,
): THREE.AnimationClip | null {
if (node.type === 'door' && isOperationDoorType((node as DoorNode).doorType)) {
return bakeOperationDoorClip(id, node as DoorNode, doorObject)
}
return bakeSwingDoorClip(id, node, doorObject)
}
/** Number of keyframes sampled across an operation door's 0→1 open motion. */
const OPERATION_DOOR_SAMPLES = 16
/**
* Sample an operation door's open motion into keyframe tracks by posing the
* export clone with `poseDoorMovingParts` at evenly-spaced fractions. Only the
* named moving groups change (their children are rigid), so a track is emitted
* per group whose position / rotation / scale actually moves. The clone is left
* posed closed so the GLB's rest state is shut.
*/
function bakeOperationDoorClip(
id: string,
node: DoorNode,
doorObject: THREE.Object3D,
): THREE.AnimationClip | null {
if (!poseDoorMovingParts(node, doorObject, 0)) return null
const objects: THREE.Object3D[] = []
doorObject.traverse((object) => objects.push(object))
const basePoses = objects.map((object) => ({
position: object.position.clone(),
quaternion: object.quaternion.clone(),
scale: object.scale.clone(),
}))
const times: number[] = []
const positionSamples = objects.map(() => [] as number[])
const quaternionSamples = objects.map(() => [] as number[])
const scaleSamples = objects.map(() => [] as number[])
for (let step = 0; step <= OPERATION_DOOR_SAMPLES; step++) {
const t = step / OPERATION_DOOR_SAMPLES
times.push(t)
poseDoorMovingParts(node, doorObject, t)
for (let i = 0; i < objects.length; i++) {
const object = objects[i]!
positionSamples[i]!.push(...object.position.toArray())
quaternionSamples[i]!.push(...object.quaternion.toArray())
scaleSamples[i]!.push(...object.scale.toArray())
}
}
const tracks: THREE.KeyframeTrack[] = []
for (let i = 0; i < objects.length; i++) {
const object = objects[i]!
const base = basePoses[i]!
if (samplesMovePosition(positionSamples[i]!, base.position)) {
tracks.push(
new THREE.VectorKeyframeTrack(`${object.uuid}.position`, times, positionSamples[i]!),
)
}
if (samplesMoveQuaternion(quaternionSamples[i]!, base.quaternion)) {
tracks.push(
new THREE.QuaternionKeyframeTrack(
`${object.uuid}.quaternion`,
times,
quaternionSamples[i]!,
),
)
}
if (samplesMoveScale(scaleSamples[i]!, base.scale)) {
tracks.push(new THREE.VectorKeyframeTrack(`${object.uuid}.scale`, times, scaleSamples[i]!))
}
}
poseDoorMovingParts(node, doorObject, 0)
if (tracks.length === 0) return null
return openClip(id, node, tracks)
}
function samplesMovePosition(flat: number[], base: THREE.Vector3): boolean {
const point = new THREE.Vector3()
for (let i = 0; i < flat.length; i += 3) {
point.set(flat[i]!, flat[i + 1]!, flat[i + 2]!)
if (point.distanceToSquared(base) > POSE_EPSILON) return true
}
return false
}
function samplesMoveQuaternion(flat: number[], base: THREE.Quaternion): boolean {
const quaternion = new THREE.Quaternion()
for (let i = 0; i < flat.length; i += 4) {
quaternion.set(flat[i]!, flat[i + 1]!, flat[i + 2]!, flat[i + 3]!)
if (base.angleTo(quaternion) > POSE_EPSILON) return true
}
return false
}
function samplesMoveScale(flat: number[], base: THREE.Vector3): boolean {
const point = new THREE.Vector3()
for (let i = 0; i < flat.length; i += 3) {
point.set(flat[i]!, flat[i + 1]!, flat[i + 2]!)
if (point.distanceToSquared(base) > POSE_EPSILON) return true
}
return false
}
/**
* 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(
function bakeSwingDoorClip(
id: string,
node: AnyNode,
doorObject: THREE.Object3D,