Skip to content
Merged
Binary file modified apps/editor/public/items/ceiling-fan/model.glb
Binary file not shown.
132 changes: 129 additions & 3 deletions packages/editor/src/lib/glb-export.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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 { buildDoorPreviewMesh } from '@pascal-app/viewer'
import * as THREE from 'three'
import { prepareSceneForExport } from './glb-export'

Expand Down Expand Up @@ -135,7 +136,7 @@ describe('prepareSceneForExport', () => {
kind: 'door',
label: 'Front door',
openable: true,
clips: ['Front door: open'],
clips: ['door_test: open'],
})

// The swing-leaf marker must not survive into glTF extras.
Expand Down Expand Up @@ -245,7 +246,7 @@ describe('prepareSceneForExport', () => {

expect(animations).toHaveLength(1)
const clip = animations[0]!
expect(clip.name).toBe('Door: open')
expect(clip.name).toBe('door_swing: open')
expect(clip.duration).toBe(1)
// Playback intent carried in extras so consumers can play once and hold.
expect(clip.userData).toEqual({ loop: false })
Expand All @@ -264,4 +265,129 @@ 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('door_sliding: 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(['door_sliding: 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)
})

// 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)
}
})
})
154 changes: 140 additions & 14 deletions packages/editor/src/lib/glb-export.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Comment thread
cursor[bot] marked this conversation as resolved.

if (tracks.length === 0) return null
return openClip(id, 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,
Expand All @@ -465,21 +588,24 @@ function bakeDoorClip(
})

if (tracks.length === 0) return null
return openClip(id, node, tracks)
return openClip(id, tracks)
}

/**
* Wrap an open motion in a named 1-second clip. The name uses the node's label
* when set (e.g. "Door 1: open") so a glTF player lists readable clips, falling
* back to the id. glTF has no core loop flag — the player decides — so we stamp
* `extras.loop = false` (via the clip's userData, which `GLTFExporter`
* serialises onto the animation): Pascal's `/viewer` and any extras-aware
* consumer play it once and hold the open pose; a dumb glTF player still loops.
* Consumers map a clip back to its node by walking up from a channel's target to
* the nearest ancestor carrying `extras.pascalId`, so the name stays cosmetic.
* Wrap an open motion in a named 1-second clip. The name is keyed by the node id
* (`<id>: open`), NOT the node's display name: clip names must be unique because
* the baked viewer drives playback by clip name (`useAnimations` maps name →
* action), so two same-named openables (e.g. several "Window 1"s) would collapse
* to a single action and a trigger on one would animate another. The
* human-readable name lives in `extras.label` instead. glTF has no core loop
* flag — the player decides — so we stamp `extras.loop = false` (via the clip's
* userData, which `GLTFExporter` serialises onto the animation): Pascal's
* `/viewer` and any extras-aware consumer play it once and hold the open pose; a
* dumb glTF player still loops. Consumers map a clip back to its node by walking
* up from a channel's target to the nearest ancestor carrying `extras.pascalId`.
*/
function openClip(id: string, node: AnyNode, tracks: THREE.KeyframeTrack[]): THREE.AnimationClip {
const clip = new THREE.AnimationClip(`${node.name ?? id}: open`, 1, tracks)
function openClip(id: string, tracks: THREE.KeyframeTrack[]): THREE.AnimationClip {
const clip = new THREE.AnimationClip(`${id}: open`, 1, tracks)
clip.userData = { loop: false }
return clip
}
Expand Down Expand Up @@ -539,7 +665,7 @@ function bakeWindowClip(
poseWindowMovingParts(node, windowObject, 0)

if (tracks.length === 0) return null
return openClip(id, node, tracks)
return openClip(id, tracks)
}

// --- Identity stamping ---------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Mesh,
MeshBasicMaterial,
type Object3D,
type PerspectiveCamera,
Quaternion,
Vector3,
} from 'three'
Expand All @@ -24,6 +25,7 @@ import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2'
import { SCENE_LAYER } from '../../lib/layers'
import useViewer from '../../store/use-viewer'
import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl'
import { WALKTHROUGH_FOV } from './walkthrough-controls'

// Eye/capsule geometry mirrors the editor's first-person controller so the
// baked walkthrough feels identical. The capsule centre sits below the eye; the
Expand Down Expand Up @@ -264,6 +266,22 @@ export function GlbWalkthroughController({ url }: { url: string }) {
}
}, [])

// Widen FOV while walking; the baked walkthrough rides the default 50° orbit
// camera, which feels cramped on foot. Keyed on `camera` so it re-applies if
// the instance swaps (e.g. the ortho→perspective switch above), restoring the
// prior FOV on exit.
useEffect(() => {
const cam = camera as PerspectiveCamera
if (!cam.isPerspectiveCamera) return
const prevFov = cam.fov
cam.fov = WALKTHROUGH_FOV
cam.updateProjectionMatrix()
return () => {
cam.fov = prevFov
cam.updateProjectionMatrix()
}
}, [camera])

useEffect(() => {
worldRef.current = world
if (world) {
Expand Down
Loading
Loading