Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,6 +1,7 @@
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 { buildDoorPreviewMesh } from '@pascal-app/viewer'
import { prepareSceneForExport } from './glb-export'

afterEach(() => {
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Roll-up export rest not closed

Medium Severity

For roll-up garage doors, bakeOperationDoorClip doesn't fully reset the door to a closed state for GLB export. The poseDoorMovingParts(..., 0) call only adjusts scale, leaving the slat geometry's height partially open. This results in a mismatched GLB rest pose and an incorrect animation clip baseline.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7530de5. Configure here.


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