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:
co-authored by
Claude Opus 4.8
parent
a3aecf1907
commit
c629171607
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user