11import {
22 type AnyNode ,
3+ type DoorNode ,
34 emitter ,
45 getLevelDisplayName ,
6+ isOperationDoorType ,
57 itemClipRegistry ,
68 type LevelNode ,
79 sceneRegistry ,
810 type WindowNode ,
911 type ZoneNode ,
1012} from '@pascal-app/core'
11- import { poseWindowMovingParts , SCENE_LAYER , snapLevelsToTruePositions } from '@pascal-app/viewer'
13+ import {
14+ poseDoorMovingParts ,
15+ poseWindowMovingParts ,
16+ SCENE_LAYER ,
17+ snapLevelsToTruePositions ,
18+ } from '@pascal-app/viewer'
1219import type { Object3D } from 'three'
1320import * as THREE from 'three'
1421import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
@@ -433,12 +440,128 @@ function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationCl
433440 return clip
434441}
435442
443+ /**
444+ * Bake a door's open motion. Swing doors (hinged/double/french) carry a
445+ * `pascalSwingLeaf` marker and bake a single quaternion track per leaf;
446+ * operation doors (sliding/pocket/barn/folding/garage-*) build their moving
447+ * parts in named groups posed by `poseDoorMovingParts`, sampled here into
448+ * keyframes (their motion is non-linear, e.g. the sectional's overhead curve).
449+ */
450+ function bakeDoorClip (
451+ id : string ,
452+ node : AnyNode ,
453+ doorObject : THREE . Object3D ,
454+ ) : THREE . AnimationClip | null {
455+ if ( node . type === 'door' && isOperationDoorType ( ( node as DoorNode ) . doorType ) ) {
456+ return bakeOperationDoorClip ( id , node as DoorNode , doorObject )
457+ }
458+ return bakeSwingDoorClip ( id , node , doorObject )
459+ }
460+
461+ /** Number of keyframes sampled across an operation door's 0→1 open motion. */
462+ const OPERATION_DOOR_SAMPLES = 16
463+
464+ /**
465+ * Sample an operation door's open motion into keyframe tracks by posing the
466+ * export clone with `poseDoorMovingParts` at evenly-spaced fractions. Only the
467+ * named moving groups change (their children are rigid), so a track is emitted
468+ * per group whose position / rotation / scale actually moves. The clone is left
469+ * posed closed so the GLB's rest state is shut.
470+ */
471+ function bakeOperationDoorClip (
472+ id : string ,
473+ node : DoorNode ,
474+ doorObject : THREE . Object3D ,
475+ ) : THREE . AnimationClip | null {
476+ if ( ! poseDoorMovingParts ( node , doorObject , 0 ) ) return null
477+
478+ const objects : THREE . Object3D [ ] = [ ]
479+ doorObject . traverse ( ( object ) => objects . push ( object ) )
480+ const basePoses = objects . map ( ( object ) => ( {
481+ position : object . position . clone ( ) ,
482+ quaternion : object . quaternion . clone ( ) ,
483+ scale : object . scale . clone ( ) ,
484+ } ) )
485+
486+ const times : number [ ] = [ ]
487+ const positionSamples = objects . map ( ( ) => [ ] as number [ ] )
488+ const quaternionSamples = objects . map ( ( ) => [ ] as number [ ] )
489+ const scaleSamples = objects . map ( ( ) => [ ] as number [ ] )
490+
491+ for ( let step = 0 ; step <= OPERATION_DOOR_SAMPLES ; step ++ ) {
492+ const t = step / OPERATION_DOOR_SAMPLES
493+ times . push ( t )
494+ poseDoorMovingParts ( node , doorObject , t )
495+ for ( let i = 0 ; i < objects . length ; i ++ ) {
496+ const object = objects [ i ] !
497+ positionSamples [ i ] ! . push ( ...object . position . toArray ( ) )
498+ quaternionSamples [ i ] ! . push ( ...object . quaternion . toArray ( ) )
499+ scaleSamples [ i ] ! . push ( ...object . scale . toArray ( ) )
500+ }
501+ }
502+
503+ const tracks : THREE . KeyframeTrack [ ] = [ ]
504+ for ( let i = 0 ; i < objects . length ; i ++ ) {
505+ const object = objects [ i ] !
506+ const base = basePoses [ i ] !
507+ if ( samplesMovePosition ( positionSamples [ i ] ! , base . position ) ) {
508+ tracks . push (
509+ new THREE . VectorKeyframeTrack ( `${ object . uuid } .position` , times , positionSamples [ i ] ! ) ,
510+ )
511+ }
512+ if ( samplesMoveQuaternion ( quaternionSamples [ i ] ! , base . quaternion ) ) {
513+ tracks . push (
514+ new THREE . QuaternionKeyframeTrack (
515+ `${ object . uuid } .quaternion` ,
516+ times ,
517+ quaternionSamples [ i ] ! ,
518+ ) ,
519+ )
520+ }
521+ if ( samplesMoveScale ( scaleSamples [ i ] ! , base . scale ) ) {
522+ tracks . push ( new THREE . VectorKeyframeTrack ( `${ object . uuid } .scale` , times , scaleSamples [ i ] ! ) )
523+ }
524+ }
525+
526+ poseDoorMovingParts ( node , doorObject , 0 )
527+
528+ if ( tracks . length === 0 ) return null
529+ return openClip ( id , tracks )
530+ }
531+
532+ function samplesMovePosition ( flat : number [ ] , base : THREE . Vector3 ) : boolean {
533+ const point = new THREE . Vector3 ( )
534+ for ( let i = 0 ; i < flat . length ; i += 3 ) {
535+ point . set ( flat [ i ] ! , flat [ i + 1 ] ! , flat [ i + 2 ] ! )
536+ if ( point . distanceToSquared ( base ) > POSE_EPSILON ) return true
537+ }
538+ return false
539+ }
540+
541+ function samplesMoveQuaternion ( flat : number [ ] , base : THREE . Quaternion ) : boolean {
542+ const quaternion = new THREE . Quaternion ( )
543+ for ( let i = 0 ; i < flat . length ; i += 4 ) {
544+ quaternion . set ( flat [ i ] ! , flat [ i + 1 ] ! , flat [ i + 2 ] ! , flat [ i + 3 ] ! )
545+ if ( base . angleTo ( quaternion ) > POSE_EPSILON ) return true
546+ }
547+ return false
548+ }
549+
550+ function samplesMoveScale ( flat : number [ ] , base : THREE . Vector3 ) : boolean {
551+ const point = new THREE . Vector3 ( )
552+ for ( let i = 0 ; i < flat . length ; i += 3 ) {
553+ point . set ( flat [ i ] ! , flat [ i + 1 ] ! , flat [ i + 2 ] ! )
554+ if ( point . distanceToSquared ( base ) > POSE_EPSILON ) return true
555+ }
556+ return false
557+ }
558+
436559/**
437560 * Bake a swing door's open motion. Each marked leaf is rotated from closed
438561 * (rest pose) to its fully-open angle and emitted as a 1-second quaternion
439562 * track; the leaf is left at the closed pose so the GLB's rest state is shut.
440563 */
441- function bakeDoorClip (
564+ function bakeSwingDoorClip (
442565 id : string ,
443566 node : AnyNode ,
444567 doorObject : THREE . Object3D ,
@@ -465,21 +588,24 @@ function bakeDoorClip(
465588 } )
466589
467590 if ( tracks . length === 0 ) return null
468- return openClip ( id , node , tracks )
591+ return openClip ( id , tracks )
469592}
470593
471594/**
472- * Wrap an open motion in a named 1-second clip. The name uses the node's label
473- * when set (e.g. "Door 1: open") so a glTF player lists readable clips, falling
474- * back to the id. glTF has no core loop flag — the player decides — so we stamp
475- * `extras.loop = false` (via the clip's userData, which `GLTFExporter`
476- * serialises onto the animation): Pascal's `/viewer` and any extras-aware
477- * consumer play it once and hold the open pose; a dumb glTF player still loops.
478- * Consumers map a clip back to its node by walking up from a channel's target to
479- * the nearest ancestor carrying `extras.pascalId`, so the name stays cosmetic.
595+ * Wrap an open motion in a named 1-second clip. The name is keyed by the node id
596+ * (`<id>: open`), NOT the node's display name: clip names must be unique because
597+ * the baked viewer drives playback by clip name (`useAnimations` maps name →
598+ * action), so two same-named openables (e.g. several "Window 1"s) would collapse
599+ * to a single action and a trigger on one would animate another. The
600+ * human-readable name lives in `extras.label` instead. glTF has no core loop
601+ * flag — the player decides — so we stamp `extras.loop = false` (via the clip's
602+ * userData, which `GLTFExporter` serialises onto the animation): Pascal's
603+ * `/viewer` and any extras-aware consumer play it once and hold the open pose; a
604+ * dumb glTF player still loops. Consumers map a clip back to its node by walking
605+ * up from a channel's target to the nearest ancestor carrying `extras.pascalId`.
480606 */
481- function openClip ( id : string , node : AnyNode , tracks : THREE . KeyframeTrack [ ] ) : THREE . AnimationClip {
482- const clip = new THREE . AnimationClip ( `${ node . name ?? id } : open` , 1 , tracks )
607+ function openClip ( id : string , tracks : THREE . KeyframeTrack [ ] ) : THREE . AnimationClip {
608+ const clip = new THREE . AnimationClip ( `${ id } : open` , 1 , tracks )
483609 clip . userData = { loop : false }
484610 return clip
485611}
@@ -539,7 +665,7 @@ function bakeWindowClip(
539665 poseWindowMovingParts ( node , windowObject , 0 )
540666
541667 if ( tracks . length === 0 ) return null
542- return openClip ( id , node , tracks )
668+ return openClip ( id , tracks )
543669}
544670
545671// --- Identity stamping ---------------------------------------------------
0 commit comments