From c6291716071e4b3edae3387f5bfbc79b9234aa57 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 26 Jun 2026 10:22:51 -0400 Subject: [PATCH 1/6] feat(doors): bake open-animation clips for sliding/garage/folding/pocket/barn doors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/editor/src/lib/glb-export.test.ts | 96 ++++ packages/editor/src/lib/glb-export.ts | 127 ++++- packages/viewer/src/index.ts | 2 +- .../src/systems/door/door-animation.test.ts | 59 +++ .../viewer/src/systems/door/door-system.tsx | 481 ++++++++++-------- 5 files changed, 561 insertions(+), 204 deletions(-) create mode 100644 packages/viewer/src/systems/door/door-animation.test.ts diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index 74b24f70..872c87c6 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -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 = { + [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 = { + [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) + }) }) diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index 7f614993..860daefa 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -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, diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 498ad03e..761e4ce5 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -111,7 +111,7 @@ export { createColumnTorusGeometry, } from './systems/column/column-geometry' export { DoorAnimationSystem } from './systems/door/door-animation-system' -export { buildDoorPreviewMesh, DoorSystem } from './systems/door/door-system' +export { buildDoorPreviewMesh, DoorSystem, poseDoorMovingParts } from './systems/door/door-system' export { ElevatorInteractionSystem } from './systems/elevator/elevator-interaction-system' // Fence system follows the wall re-export pattern — composed into the // registry-driven fence definition's `def.system`. Removed in Phase 6 diff --git a/packages/viewer/src/systems/door/door-animation.test.ts b/packages/viewer/src/systems/door/door-animation.test.ts new file mode 100644 index 00000000..4b1b9735 --- /dev/null +++ b/packages/viewer/src/systems/door/door-animation.test.ts @@ -0,0 +1,59 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import { DoorNode } from '@pascal-app/core' +import * as THREE from 'three' +import { buildDoorPreviewMesh, poseDoorMovingParts } from '../../index' + +// The named moving group each operation-door builder emits at the closed pose. +const MOVING_GROUP: Record = { + sliding: 'door-sliding-active', + pocket: 'door-pocket-leaf', + barn: 'door-barn-leaf', + 'garage-tiltup': 'door-tiltup-leaf', + folding: 'door-fold-0', + 'garage-sectional': 'door-sectional-0', + 'garage-rollup': 'door-rollup-curtain', +} + +function worldPosition(object: THREE.Object3D): THREE.Vector3 { + object.updateMatrixWorld(true) + return object.getWorldPosition(new THREE.Vector3()) +} + +describe('operation door open kinematics', () => { + // Each operation door builds its moving parts in a named group at the closed + // pose; `poseDoorMovingParts` then drives the open motion (the single source + // of truth shared by the live system and the GLB exporter). This guards the + // build-once + pose-at-t contract: the group exists, rests closed, and moves. + for (const doorType of Object.keys(MOVING_GROUP)) { + test(`${doorType} builds a moving group that opens`, () => { + const node = DoorNode.parse({ id: `door_${doorType}`, doorType, operationState: 0 }) + const mesh = buildDoorPreviewMesh(node) + + const group = mesh.getObjectByName(MOVING_GROUP[doorType]!) + expect(group).toBeDefined() + + // Closed snapshot. + poseDoorMovingParts(node, mesh, 0) + const closedPos = worldPosition(group!) + const closedScaleY = group!.scale.y + const closedRotX = group!.rotation.x + const closedRotY = group!.rotation.y + + // Open snapshot — at least one of translation / rotation / scale changes. + poseDoorMovingParts(node, mesh, 1) + const openPos = worldPosition(group!) + const moved = + closedPos.distanceTo(openPos) > 0.05 || + Math.abs(closedScaleY - group!.scale.y) > 0.1 || + Math.abs(closedRotX - group!.rotation.x) > 0.05 || + Math.abs(closedRotY - group!.rotation.y) > 0.05 + expect(moved).toBe(true) + + // Posing back to closed is the identity of the rest pose. + poseDoorMovingParts(node, mesh, 0) + expect(worldPosition(group!).distanceTo(closedPos)).toBeLessThan(1e-6) + }) + } +}) diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index ea3413c5..c1901611 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -273,26 +273,6 @@ function addRotatedBox( parent.add(m) } -function addBoxWithRotation( - parent: THREE.Object3D, - material: THREE.Material, - w: number, - h: number, - d: number, - x: number, - y: number, - z: number, - rotation: [number, number, number], -) { - const geometry = new THREE.BoxGeometry(w, h, d) - applyWorldScaleBoxUVs(geometry, w, h, d) - const m = new THREE.Mesh(geometry, material) - m.position.set(x, y, z) - m.rotation.set(rotation[0], rotation[1], rotation[2]) - tagDoorSlot(m) - parent.add(m) -} - function addShape( parent: THREE.Object3D, material: THREE.Material, @@ -1335,6 +1315,151 @@ function addDoorLeaf( } } +// Names of the re-poseable moving groups each operation-door builder emits. The +// builder fills them with geometry at the CLOSED pose; `poseDoorMovingParts` +// then transforms the group to a given open fraction. This is the same +// build-once + pose-at-t split windows use (`poseWindowMovingParts`), and it is +// the single source of truth for door kinematics: the live system poses the +// registered mesh, and the GLB exporter poses an export clone to sample the +// open/close keyframes for a baked animation clip. +const SLIDING_ACTIVE_PANEL_NAME = 'door-sliding-active' +const POCKET_LEAF_NAME = 'door-pocket-leaf' +const BARN_LEAF_NAME = 'door-barn-leaf' +const TILTUP_LEAF_NAME = 'door-tiltup-leaf' +// Folding panels form a hinged chain; each `` group is parented to +// the previous one so a single per-joint rotation reproduces the accordion. +const FOLDING_PANEL_NAME = 'door-fold-' +// Sectional panels each ride the overhead curve independently (non-rigid as a +// set), so each `` group is posed on its own. +const SECTIONAL_PANEL_NAME = 'door-sectional-' +const ROLLUP_CURTAIN_NAME = 'door-rollup-curtain' + +/** + * Pose an operation door's moving parts (sliding/pocket/barn leaf, tilt-up and + * sectional panels, folding chain, roll-up curtain) at `value` (0 = closed, + * 1 = open) by transforming the named groups its builder emitted at the closed + * pose. Returns true when the door type has a pose path and its groups exist. + * + * Mirrors `poseWindowMovingParts`: the live door system calls it after a + * (re)build so `operationState` is reflected, and the GLB exporter calls it on a + * clone to sample keyframes. Swing doors are not handled here — their leaf group + * carries a `pascalSwingLeaf` marker the exporter reads directly. + * + * Roll-up is the one type whose live geometry changes (slats vanish onto a + * drum), which a glTF clip can't express; the baked approximation scales the + * curtain up into the lintel. The live roll-up animation keeps its full-detail + * rebuild and is intentionally NOT routed through this scale. + */ +export function poseDoorMovingParts( + node: DoorNode, + mesh: THREE.Object3D | undefined, + value: number, +): boolean { + if (!mesh) return false + const t = clampDoorOperationState(value) + const frameThickness = node.frameThickness + const insideWidth = node.width - 2 * frameThickness + const leafHeight = node.height - frameThickness + const leafCenterY = -frameThickness / 2 + + switch (node.doorType) { + case 'sliding': { + const group = mesh.getObjectByName(SLIDING_ACTIVE_PANEL_NAME) + if (!group) return false + const activeSign = node.slideDirection === 'left' ? 1 : -1 + group.position.x = -activeSign * insideWidth * 0.44 * t + return true + } + case 'pocket': { + const group = mesh.getObjectByName(POCKET_LEAF_NAME) + if (!group) return false + const slideSign = node.slideDirection === 'right' ? 1 : -1 + group.position.x = slideSign * insideWidth * t + return true + } + case 'barn': { + const group = mesh.getObjectByName(BARN_LEAF_NAME) + if (!group) return false + const slideSign = node.slideDirection === 'right' ? 1 : -1 + group.position.x = slideSign * insideWidth * t + return true + } + case 'garage-tiltup': { + const group = mesh.getObjectByName(TILTUP_LEAF_NAME) + if (!group) return false + // Rigid hinge: the closed leaf hangs from the lintel; opening rotates it + // about the top edge. `position` keeps the top edge tracking the hinge as + // the group rotates about its own origin (see the closed build below). + const angle = (Math.PI / 2) * t + const hingeY = leafCenterY + leafHeight / 2 + group.rotation.x = -angle + group.position.set(0, hingeY * (1 - Math.cos(angle)), Math.sin(angle) * (hingeY - leafHeight)) + return true + } + case 'folding': { + const panelCount = node.leafCount === 2 ? 2 : 4 + const foldAngle = Math.PI * 0.44 * t + // Each panel group is parented to the previous, so its rotation is the + // joint angle (the change in absolute segment direction), not the absolute + // angle: alternating panels target ±foldAngle, so joints fold by ±2·angle. + let posed = false + let prevDirection = 0 + for (let index = 0; index < panelCount; index++) { + const group = mesh.getObjectByName(`${FOLDING_PANEL_NAME}${index}`) + const direction = index % 2 === 0 ? -1 : 1 + if (group) { + posed = true + group.rotation.y = (direction - prevDirection) * foldAngle + } + prevDirection = direction + } + return posed + } + case 'garage-sectional': { + const panelCount = Math.max(3, Math.min(12, Math.round(node.garagePanelCount))) + const panelHeight = leafHeight / panelCount + const curveRadius = panelHeight * 0.58 + const curveLength = (Math.PI / 2) * curveRadius + const overheadY = leafCenterY + leafHeight / 2 - panelHeight / 2 + const openAmount = getDoorRenderOpenAmount('garage-sectional', t) + const travel = + openAmount * ((panelCount - 1) * panelHeight + curveLength + panelHeight * 0.65) + let posed = false + for (let index = 0; index < panelCount; index++) { + const group = mesh.getObjectByName(`${SECTIONAL_PANEL_NAME}${index}`) + if (!group) continue + posed = true + const orderFromTop = panelCount - 1 - index + const pathPosition = travel - orderFromTop * panelHeight + let y = overheadY + pathPosition + let z = 0 + let rotationX = 0 + if (pathPosition > 0 && pathPosition <= curveLength) { + const theta = pathPosition / curveRadius + rotationX = -theta + y = overheadY + curveRadius * Math.sin(theta) + z = -curveRadius * (1 - Math.cos(theta)) + } else if (pathPosition > curveLength) { + rotationX = -Math.PI / 2 + y = overheadY + curveRadius + z = -(curveRadius + pathPosition - curveLength) + } + group.position.set(0, y, z) + group.rotation.x = rotationX + } + return posed + } + case 'garage-rollup': { + const group = mesh.getObjectByName(ROLLUP_CURTAIN_NAME) + if (!group) return false + group.scale.y = Math.max(0.02, 1 - t) + return true + } + default: + return false + } +} + function addFoldingDoor( mesh: THREE.Mesh, { @@ -1344,7 +1469,6 @@ function addFoldingDoor( leafDepth, frameThickness, frameDepth, - operationState, leafCount, doorHeight, handleHeight, @@ -1357,7 +1481,6 @@ function addFoldingDoor( leafDepth: number frameThickness: number frameDepth: number - operationState: number leafCount: DoorNode['leafCount'] doorHeight: number handleHeight: number @@ -1366,9 +1489,7 @@ function addFoldingDoor( }, ) { const panelCount = leafCount === 2 ? 2 : 4 - const foldAmount = clampDoorOperationState(operationState) const panelLength = insideWidth / panelCount - const foldAngle = Math.PI * 0.44 * foldAmount currentDoorSlot = 'hardware' addBox( @@ -1382,31 +1503,20 @@ function addFoldingDoor( 0, ) - const vertices: Array<{ x: number; z: number }> = [{ x: -insideWidth / 2, z: 0 }] + // Build the panels as a hinged chain at the CLOSED pose: each panel group is + // parented to the previous one at that panel's end (`panelLength` along local + // x), starting from the left jamb. At rest every joint is straight, so the + // panels lie flat across the opening; `poseDoorMovingParts` folds the joints. + let parent: THREE.Object3D = mesh for (let index = 0; index < panelCount; index++) { - const previous = vertices[index]! - const direction = index % 2 === 0 ? -1 : 1 - const angle = direction * foldAngle - vertices.push({ - x: previous.x + panelLength * Math.cos(angle), - z: previous.z + panelLength * Math.sin(angle), - }) - } - - currentDoorSlot = undefined - for (let index = 0; index < panelCount; index++) { - const start = vertices[index]! - const end = vertices[index + 1]! - const dx = end.x - start.x - const dz = end.z - start.z - const centerX = (start.x + end.x) / 2 - const centerZ = (start.z + end.z) / 2 - const rotationY = Math.atan2(-dz, dx) - const localX = { - x: Math.cos(rotationY), - z: -Math.sin(rotationY), - } + const group = new THREE.Group() + group.name = `${FOLDING_PANEL_NAME}${index}` + group.position.set(index === 0 ? -insideWidth / 2 : panelLength, 0, 0) + parent.add(group) + // Panel content runs from local x=0 to x=panelLength, so it is centred at + // panelLength/2 within the group (the segment helper centres on leafCenterX). + currentDoorSlot = undefined const addFoldingLeafBox = ( material: THREE.Material, w: number, @@ -1415,19 +1525,7 @@ function addFoldingDoor( x: number, y: number, z: number, - ) => { - addRotatedBox( - mesh, - material, - w, - h, - d, - centerX + localX.x * x + Math.sin(rotationY) * z, - y, - centerZ + localX.z * x + Math.cos(rotationY) * z, - rotationY, - ) - } + ) => addBox(group, material, w, h, d, panelLength / 2 + x, y, z) addLeafSegmentContent({ addLeafBox: addFoldingLeafBox, @@ -1441,43 +1539,37 @@ function addFoldingDoor( keepFrameWhenEmpty: true, }) + // Reveal posts at the panel's hinge edges (local x=0 and x=panelLength). currentDoorSlot = undefined - for (const point of [start, end]) { - addBox( - mesh, - revealMaterial, - 0.018, - leafHeight * 0.92, - leafDepth + 0.016, - point.x, - leafCenterY, - point.z, - ) + for (const px of [0, panelLength]) { + addBox(group, revealMaterial, 0.018, leafHeight * 0.92, leafDepth + 0.016, px, leafCenterY, 0) } + + parent = group } - const handlePoint = vertices[vertices.length - 1]! + // Handle on the free end of the last panel (its local x=panelLength edge). const handleY = handleHeight - doorHeight / 2 currentDoorSlot = 'hardware' addBox( - mesh, + parent, hardwareMaterial, 0.035, 0.16, leafDepth + 0.035, - handlePoint.x - 0.035, + panelLength - 0.035, handleY, - handlePoint.z + 0.045, + 0.045, ) addBox( - mesh, + parent, hardwareMaterial, 0.035, 0.16, leafDepth + 0.035, - handlePoint.x - 0.035, + panelLength - 0.035, handleY, - handlePoint.z - 0.045, + -0.045, ) } @@ -1490,7 +1582,6 @@ function addPocketDoor( leafDepth, frameThickness, frameDepth, - operationState, slideDirection, doorHeight, handleHeight, @@ -1503,7 +1594,6 @@ function addPocketDoor( leafDepth: number frameThickness: number frameDepth: number - operationState: number slideDirection: DoorNode['slideDirection'] doorHeight: number handleHeight: number @@ -1511,14 +1601,14 @@ function addPocketDoor( contentPadding: DoorNode['contentPadding'] }, ) { - const openAmount = clampDoorOperationState(operationState) const slideSign = slideDirection === 'right' ? 1 : -1 const leafWidth = insideWidth - const leafCenterX = slideSign * insideWidth * openAmount const topY = leafCenterY + leafHeight / 2 const pocketCenterX = slideSign * insideWidth const handleY = handleHeight - doorHeight / 2 - const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.055) + // Leaf built closed (centred in the opening); `poseDoorMovingParts` slides the + // group into the pocket. Handle rides inside the group at its closed offset. + const handleX = -slideSign * (leafWidth / 2 - 0.055) currentDoorSlot = 'hardware' addBox( @@ -1553,6 +1643,10 @@ function addPocketDoor( 0, ) + const leafGroup = new THREE.Group() + leafGroup.name = POCKET_LEAF_NAME + mesh.add(leafGroup) + const addPocketLeafBox = ( material: THREE.Material, w: number, @@ -1561,13 +1655,13 @@ function addPocketDoor( x: number, y: number, z: number, - ) => addBox(mesh, material, w, h, d, x, y, z) + ) => addBox(leafGroup, material, w, h, d, x, y, z) addLeafSegmentContent({ addLeafBox: addPocketLeafBox, leafWidth, leafHeight, - leafCenterX, + leafCenterX: 0, leafCenterY, leafDepth, segments, @@ -1575,7 +1669,7 @@ function addPocketDoor( }) currentDoorSlot = 'hardware' addBox( - mesh, + leafGroup, hardwareMaterial, 0.03, 0.18, @@ -1585,7 +1679,7 @@ function addPocketDoor( leafDepth / 2 + 0.02, ) addBox( - mesh, + leafGroup, hardwareMaterial, 0.03, 0.18, @@ -1605,7 +1699,6 @@ function addBarnDoor( leafDepth, frameThickness, frameDepth, - operationState, slideDirection, doorHeight, handleHeight, @@ -1618,7 +1711,6 @@ function addBarnDoor( leafDepth: number frameThickness: number frameDepth: number - operationState: number slideDirection: DoorNode['slideDirection'] doorHeight: number handleHeight: number @@ -1626,16 +1718,17 @@ function addBarnDoor( contentPadding: DoorNode['contentPadding'] }, ) { - const openAmount = clampDoorOperationState(operationState) const slideSign = slideDirection === 'right' ? 1 : -1 const leafWidth = insideWidth * 1.06 - const leafCenterX = slideSign * insideWidth * openAmount const faceZ = frameDepth / 2 + leafDepth / 2 + 0.028 const trackY = leafCenterY + leafHeight / 2 + Math.max(frameThickness * 0.55, 0.045) const railLength = insideWidth * 2.25 const railCenterX = slideSign * (insideWidth * 0.56) const handleY = handleHeight - doorHeight / 2 - const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.075) + // Leaf + wheels + handle ride the BARN_LEAF group, built closed (leaf centred + // in the opening); `poseDoorMovingParts` slides the group along the rail. The + // rail and end stops stay static on the mesh. + const handleX = -slideSign * (leafWidth / 2 - 0.075) const wheelY = trackY - 0.075 currentDoorSlot = 'hardware' @@ -1643,6 +1736,10 @@ function addBarnDoor( addBox(mesh, hardwareMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01) addBox(mesh, hardwareMaterial, 0.05, 0.13, 0.035, insideWidth / 2, trackY - 0.02, faceZ + 0.01) + const leafGroup = new THREE.Group() + leafGroup.name = BARN_LEAF_NAME + mesh.add(leafGroup) + const addBarnLeafBox = ( material: THREE.Material, w: number, @@ -1651,13 +1748,13 @@ function addBarnDoor( x: number, y: number, z: number, - ) => addBox(mesh, material, w, h, d, x, y, faceZ + z) + ) => addBox(leafGroup, material, w, h, d, x, y, faceZ + z) addLeafSegmentContent({ addLeafBox: addBarnLeafBox, leafWidth, leafHeight, - leafCenterX, + leafCenterX: 0, leafCenterY, leafDepth, segments, @@ -1667,23 +1764,23 @@ function addBarnDoor( currentDoorSlot = undefined addRotatedBox( - mesh, + leafGroup, revealMaterial, 0.018, leafHeight * 0.86, 0.012, - leafCenterX, + 0, leafCenterY, faceZ + leafDepth / 2 + 0.014, -0.52, ) addRotatedBox( - mesh, + leafGroup, revealMaterial, 0.018, leafHeight * 0.86, 0.012, - leafCenterX, + 0, leafCenterY, faceZ + leafDepth / 2 + 0.014, 0.52, @@ -1691,22 +1788,13 @@ function addBarnDoor( currentDoorSlot = 'hardware' for (const offset of [-leafWidth * 0.28, leafWidth * 0.28]) { - addBox(mesh, hardwareMaterial, 0.085, 0.085, 0.035, leafCenterX + offset, wheelY, faceZ + 0.022) - addBox( - mesh, - hardwareMaterial, - 0.026, - 0.16, - 0.026, - leafCenterX + offset, - wheelY - 0.075, - faceZ + 0.022, - ) + addBox(leafGroup, hardwareMaterial, 0.085, 0.085, 0.035, offset, wheelY, faceZ + 0.022) + addBox(leafGroup, hardwareMaterial, 0.026, 0.16, 0.026, offset, wheelY - 0.075, faceZ + 0.022) } currentDoorSlot = 'hardware' addBox( - mesh, + leafGroup, hardwareMaterial, 0.032, 0.22, @@ -1716,7 +1804,7 @@ function addBarnDoor( faceZ + leafDepth / 2 + 0.02, ) addBox( - mesh, + leafGroup, hardwareMaterial, 0.032, 0.22, @@ -1736,7 +1824,6 @@ function addSlidingDoor( leafDepth, frameThickness, frameDepth, - operationState, slideDirection, doorHeight, handleHeight, @@ -1749,7 +1836,6 @@ function addSlidingDoor( leafDepth: number frameThickness: number frameDepth: number - operationState: number slideDirection: DoorNode['slideDirection'] doorHeight: number handleHeight: number @@ -1757,7 +1843,6 @@ function addSlidingDoor( contentPadding: DoorNode['contentPadding'] }, ) { - const openAmount = clampDoorOperationState(operationState) const activeOnRight = slideDirection === 'left' const fixedSign = activeOnRight ? -1 : 1 const activeSign = activeOnRight ? 1 : -1 @@ -1765,12 +1850,14 @@ function addSlidingDoor( const panelHeight = leafHeight const closedActiveX = activeSign * insideWidth * 0.23 const fixedX = fixedSign * insideWidth * 0.23 - const activeX = closedActiveX - activeSign * insideWidth * 0.44 * openAmount const frontZ = leafDepth / 2 + 0.016 const backZ = -leafDepth / 2 - 0.006 const railY = leafCenterY + panelHeight / 2 - Math.min(frameThickness * 0.35, 0.02) const handleY = handleHeight - doorHeight / 2 - const handleX = activeX + activeSign * (panelWidth / 2 - 0.06) + // Active panel + handle ride the SLIDING_ACTIVE_PANEL group, built at the + // closed position; `poseDoorMovingParts` slides the group behind the fixed + // panel. The fixed panel and rails stay static on the mesh. + const handleX = closedActiveX + activeSign * (panelWidth / 2 - 0.06) currentDoorSlot = 'hardware' addBox( @@ -1804,6 +1891,10 @@ function addSlidingDoor( z: number, ) => addBox(mesh, material, w, h, d, x + fixedX, y, z + backZ) + const activePanelGroup = new THREE.Group() + activePanelGroup.name = SLIDING_ACTIVE_PANEL_NAME + mesh.add(activePanelGroup) + const addActivePanelBox = ( material: THREE.Material, w: number, @@ -1812,7 +1903,7 @@ function addSlidingDoor( x: number, y: number, z: number, - ) => addBox(mesh, material, w, h, d, x + activeX, y, z + frontZ) + ) => addBox(activePanelGroup, material, w, h, d, x + closedActiveX, y, z + frontZ) addLeafSegmentContent({ addLeafBox: addFixedPanelBox, @@ -1838,7 +1929,7 @@ function addSlidingDoor( }) currentDoorSlot = 'hardware' addBox( - mesh, + activePanelGroup, hardwareMaterial, 0.032, 0.24, @@ -1848,7 +1939,7 @@ function addSlidingDoor( frontZ + leafDepth / 2 + 0.01, ) addBox( - mesh, + activePanelGroup, hardwareMaterial, 0.032, 0.24, @@ -1868,7 +1959,6 @@ function addGarageSectionalDoor( leafDepth, frameThickness, frameDepth, - operationState, garagePanelCount, }: { insideWidth: number @@ -1877,19 +1967,13 @@ function addGarageSectionalDoor( leafDepth: number frameThickness: number frameDepth: number - operationState: number garagePanelCount: number }, ) { - const openAmount = getDoorRenderOpenAmount('garage-sectional', operationState) const panelCount = Math.max(3, Math.min(12, Math.round(garagePanelCount))) const panelHeight = leafHeight / panelCount const panelGap = Math.min(0.012, panelHeight * 0.08) const travelDepth = Math.max(leafHeight, 1.4) - const curveRadius = panelHeight * 0.58 - const curveLength = (Math.PI / 2) * curveRadius - const travel = openAmount * ((panelCount - 1) * panelHeight + curveLength + panelHeight * 0.65) - const overheadY = leafCenterY + leafHeight / 2 - panelHeight / 2 const railY = leafCenterY + leafHeight / 2 - 0.04 const railZ = -travelDepth / 2 @@ -1915,56 +1999,50 @@ function addGarageSectionalDoor( railZ, ) + // Each panel is built flat (centred at its group origin) and the trims sit on + // the panel's front face in local space; `poseDoorMovingParts` rides each + // group along the overhead curve. Panel order, geometry and trim placement + // match the inline curve math (which the pose function reuses). + const revealOffset = (panelHeight - panelGap) * 0.22 + const trimDepth = 0.01 + const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006 for (let index = 0; index < panelCount; index++) { - const orderFromTop = panelCount - 1 - index - const pathPosition = travel - orderFromTop * panelHeight - let y = overheadY + pathPosition - let z = 0 - let rotationX = 0 - - if (pathPosition > 0 && pathPosition <= curveLength) { - const theta = pathPosition / curveRadius - rotationX = -theta - y = overheadY + curveRadius * Math.sin(theta) - z = -curveRadius * (1 - Math.cos(theta)) - } else if (pathPosition > curveLength) { - rotationX = -Math.PI / 2 - y = overheadY + curveRadius - z = -(curveRadius + pathPosition - curveLength) - } - - const revealOffset = (panelHeight - panelGap) * 0.22 - const trimDepth = 0.01 - const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006 - const addSectionalTrim = (localY: number) => { - currentDoorSlot = undefined - addBoxWithRotation( - mesh, - revealMaterial, - insideWidth - 0.16, - 0.012, - trimDepth, - 0, - y + localY * Math.cos(rotationX) - trimFaceOffset * Math.sin(rotationX), - z + localY * Math.sin(rotationX) + trimFaceOffset * Math.cos(rotationX), - [rotationX, 0, 0], - ) - } + const group = new THREE.Group() + group.name = `${SECTIONAL_PANEL_NAME}${index}` + mesh.add(group) currentDoorSlot = 'panel' - addBoxWithRotation( - mesh, + addBox( + group, baseMaterial, insideWidth, Math.max(0.04, panelHeight - panelGap), leafDepth, 0, - y, - z, - [rotationX, 0, 0], + 0, + 0, + ) + currentDoorSlot = undefined + addBox( + group, + revealMaterial, + insideWidth - 0.16, + 0.012, + trimDepth, + 0, + revealOffset, + trimFaceOffset, + ) + addBox( + group, + revealMaterial, + insideWidth - 0.16, + 0.012, + trimDepth, + 0, + -revealOffset, + trimFaceOffset, ) - addSectionalTrim(revealOffset) - addSectionalTrim(-revealOffset) } currentDoorSlot = 'hardware' @@ -1996,7 +2074,6 @@ function addGarageRollupDoor( const visibleHeight = leafHeight * (1 - openAmount) const visibleSlatCount = Math.ceil(visibleHeight / slatHeight) const topY = leafCenterY + leafHeight / 2 - const curtainCenterY = topY - visibleHeight / 2 const drumMaxRadius = Math.max(0.12, Math.min(0.22, leafHeight * 0.075)) const drumY = topY + drumMaxRadius * 0.12 const drumZ = -frameDepth / 2 - drumMaxRadius * 0.72 @@ -2024,23 +2101,33 @@ function addGarageRollupDoor( ) if (visibleHeight > 0.01) { + // Wrap the visible curtain in a group pivoted at the lintel (topY). The live + // door keeps rebuilding the full slat detail per `operationState` (this group + // is just an organisational wrapper at scale 1 — world positions unchanged), + // but it gives the GLB exporter a single node to scale up into the header as + // an open clip, since the slats can't literally vanish in a glTF animation. + const curtain = new THREE.Group() + curtain.name = ROLLUP_CURTAIN_NAME + curtain.position.set(0, topY, 0) + mesh.add(curtain) + currentDoorSlot = 'panel' - addBox(mesh, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, curtainCenterY, 0) + addBox(curtain, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, -visibleHeight / 2, 0) currentDoorSlot = undefined for (let index = 0; index < visibleSlatCount; index++) { - const y = topY - Math.min(visibleHeight, index * slatHeight) - addBox(mesh, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012) + const y = -Math.min(visibleHeight, index * slatHeight) + addBox(curtain, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012) } addBox( - mesh, + curtain, revealMaterial, insideWidth - 0.04, 0.028, leafDepth + 0.018, 0, - topY - visibleHeight, + -visibleHeight, leafDepth / 2 + 0.004, ) } @@ -2077,7 +2164,6 @@ function addGarageTiltupDoor( leafDepth, frameThickness, frameDepth, - operationState, }: { insideWidth: number leafHeight: number @@ -2085,14 +2171,12 @@ function addGarageTiltupDoor( leafDepth: number frameThickness: number frameDepth: number - operationState: number }, ) { - const openAmount = clampDoorOperationState(operationState) - const angle = (Math.PI / 2) * openAmount const hingeY = leafCenterY + leafHeight / 2 - const panelCenterY = hingeY - Math.cos(angle) * (leafHeight / 2) - const panelCenterZ = -Math.sin(angle) * (leafHeight / 2) + // Leaf built closed (hanging from the lintel); `poseDoorMovingParts` rotates + // the group about the top hinge to open it. Rails + top bar stay static. + const panelCenterY = hingeY - leafHeight / 2 const railLength = Math.max(leafHeight * 0.72, 1.2) const railY = hingeY - frameThickness * 0.35 const railZ = -railLength / 2 @@ -2119,18 +2203,12 @@ function addGarageTiltupDoor( railZ, ) + const leafGroup = new THREE.Group() + leafGroup.name = TILTUP_LEAF_NAME + mesh.add(leafGroup) + currentDoorSlot = 'panel' - addBoxWithRotation( - mesh, - baseMaterial, - insideWidth, - leafHeight, - leafDepth, - 0, - panelCenterY, - panelCenterZ, - [-angle, 0, 0], - ) + addBox(leafGroup, baseMaterial, insideWidth, leafHeight, leafDepth, 0, panelCenterY, 0) const insetWidth = Math.max(0.1, insideWidth - 0.22) const insetHeight = Math.max(0.1, leafHeight - 0.28) @@ -2138,16 +2216,15 @@ function addGarageTiltupDoor( const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006 const addTiltupTrim = (localX: number, localY: number, trimWidth: number, trimHeight: number) => { currentDoorSlot = undefined - addBoxWithRotation( - mesh, + addBox( + leafGroup, revealMaterial, trimWidth, trimHeight, trimDepth, localX, - panelCenterY + localY * Math.cos(angle) + trimFaceOffset * Math.sin(angle), - panelCenterZ - localY * Math.sin(angle) + trimFaceOffset * Math.cos(angle), - [-angle, 0, 0], + panelCenterY + localY, + trimFaceOffset, ) } @@ -2368,7 +2445,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { leafDepth, frameThickness, frameDepth, - operationState, garagePanelCount, }) } else if (doorType === 'garage-rollup') { @@ -2389,7 +2465,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { leafDepth, frameThickness, frameDepth, - operationState, }) } else if (doorType === 'folding') { addFoldingDoor(mesh, { @@ -2399,7 +2474,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { leafDepth, frameThickness, frameDepth, - operationState, leafCount, doorHeight: height, handleHeight, @@ -2414,7 +2488,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { leafDepth, frameThickness, frameDepth, - operationState, slideDirection, doorHeight: height, handleHeight, @@ -2429,7 +2502,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { leafDepth, frameThickness, frameDepth, - operationState, slideDirection, doorHeight: height, handleHeight, @@ -2444,7 +2516,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { leafDepth, frameThickness, frameDepth, - operationState, slideDirection, doorHeight: height, handleHeight, @@ -2549,6 +2620,14 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { }) } + // Operation doors build their moving parts at the closed pose inside named + // groups; reflect the door's current `operationState` by posing them. Roll-up + // is excluded: its live rebuild already renders the open state at full detail + // (the named curtain group is only there for the GLB exporter's scale clip). + if (doorType !== 'garage-rollup') { + poseDoorMovingParts(node, mesh, operationState) + } + syncDoorCutout(node, mesh) // Guard: some degenerate door configs can leave a child mesh with an From 4c81435e3b8552c9db7d78bfbf747c618d2df3db Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 26 Jun 2026 11:12:47 -0400 Subject: [PATCH 2/6] fix(doors): correct folding fold direction + make open-clip names unique per node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues surfaced testing the baked viewer: - Folding door folded toward +z (into the room) — the joint rotation sign was inverted, so the accordion opened the wrong way ("weird position"). Flip to `(prevDirection - direction) * foldAngle` so leaves fold toward −z, matching the original inline rig. Verified panel-for-panel against the original formula at every operationState. - Openable clips were named by display name (`: open`), but the baked viewer drives playback by clip name (`useAnimations` maps name → action). Several windows share the name "Window 1", so their clips collapsed to one action and triggering any one opened the first. Key the clip name by node id (`: open`) — unique, matching the item-loop convention; the human-readable name still lives in `extras.label`. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/editor/src/lib/glb-export.test.ts | 8 ++--- packages/editor/src/lib/glb-export.ts | 29 ++++++++++--------- .../viewer/src/systems/door/door-system.tsx | 5 ++-- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index 872c87c6..669eddff 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -135,7 +135,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. @@ -245,7 +245,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 }) @@ -297,7 +297,7 @@ describe('prepareSceneForExport', () => { expect(animations).toHaveLength(1) const clip = animations[0]! - expect(clip.name).toBe('Slider: open') + expect(clip.name).toBe('door_sliding: open') expect(clip.duration).toBe(1) expect(clip.userData).toEqual({ loop: false }) @@ -321,7 +321,7 @@ describe('prepareSceneForExport', () => { const exported = scene.getObjectByProperty('name', doorId) expect(exported?.userData.openable).toBe(true) - expect(exported?.userData.clips).toEqual(['Slider: open']) + expect(exported?.userData.clips).toEqual(['door_sliding: open']) }) test('bakes a roll-up curtain into a sampled scale clip', () => { diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index 860daefa..31594338 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -526,7 +526,7 @@ function bakeOperationDoorClip( poseDoorMovingParts(node, doorObject, 0) if (tracks.length === 0) return null - return openClip(id, node, tracks) + return openClip(id, tracks) } function samplesMovePosition(flat: number[], base: THREE.Vector3): boolean { @@ -588,21 +588,24 @@ function bakeSwingDoorClip( }) 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 + * (`: 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 } @@ -662,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 --------------------------------------------------- diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index c1901611..ecb654dd 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1401,7 +1401,8 @@ export function poseDoorMovingParts( const foldAngle = Math.PI * 0.44 * t // Each panel group is parented to the previous, so its rotation is the // joint angle (the change in absolute segment direction), not the absolute - // angle: alternating panels target ±foldAngle, so joints fold by ±2·angle. + // angle: alternating panels target ∓foldAngle, so joints fold by ±2·angle. + // The leading sign folds the leaves toward −z (matching the original rig). let posed = false let prevDirection = 0 for (let index = 0; index < panelCount; index++) { @@ -1409,7 +1410,7 @@ export function poseDoorMovingParts( const direction = index % 2 === 0 ? -1 : 1 if (group) { posed = true - group.rotation.y = (direction - prevDirection) * foldAngle + group.rotation.y = (prevDirection - direction) * foldAngle } prevDirection = direction } From 7530de534838274e3297f46ad5095b914f7ab1c7 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 26 Jun 2026 12:12:11 -0400 Subject: [PATCH 3/6] fix(viewer): bake folding/garage doors at an un-flipped rest pose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `poseDoorMovingParts` assigned a single euler axis (`group.rotation.y` / `.x`). The live system was fine because the group's euler stays a clean (0, y, 0). But the GLB exporter clones the door and decomposes its matrix, which re-derives a gimbal-flipped euler (x=z=π) for any rotation beyond ±90° — folding panels reach ~158°. The reset to t=0 then only zeroed `.y`, leaving the π residue on x/z and baking a 180°-flipped rest pose (panels folded out toward a wrong position even when closed). Set the full euler triple via `.set()` in every pose branch so the other two axes are always zeroed, clearing any decomposed residue. Add a regression test that exports an open folding door and asserts an identity rest pose for all panels. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/editor/src/lib/glb-export.test.ts | 32 ++++++++++++++++++- .../viewer/src/systems/door/door-system.tsx | 10 ++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index 669eddff..cc31fba4 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -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(() => { @@ -360,4 +361,33 @@ describe('prepareSceneForExport', () => { 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) + } + }) }) diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index ecb654dd..ce84353d 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1392,7 +1392,7 @@ export function poseDoorMovingParts( // the group rotates about its own origin (see the closed build below). const angle = (Math.PI / 2) * t const hingeY = leafCenterY + leafHeight / 2 - group.rotation.x = -angle + group.rotation.set(-angle, 0, 0) group.position.set(0, hingeY * (1 - Math.cos(angle)), Math.sin(angle) * (hingeY - leafHeight)) return true } @@ -1410,7 +1410,11 @@ export function poseDoorMovingParts( const direction = index % 2 === 0 ? -1 : 1 if (group) { posed = true - group.rotation.y = (prevDirection - direction) * foldAngle + // Set the full triple (not just `.y`): when the export clones and + // decomposes the door matrix, a |Y| > π/2 rotation re-derives into a + // gimbal-flipped euler (x=z=π). Assigning only `.y` would leave that + // π residue on x/z and bake a flipped rest pose. + group.rotation.set(0, (prevDirection - direction) * foldAngle, 0) } prevDirection = direction } @@ -1446,7 +1450,7 @@ export function poseDoorMovingParts( z = -(curveRadius + pathPosition - curveLength) } group.position.set(0, y, z) - group.rotation.x = rotationX + group.rotation.set(rotationX, 0, 0) } return posed } From 69c483b5f26b12dc98bab1634d963b9fcc3efb04 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 26 Jun 2026 15:51:05 -0400 Subject: [PATCH 4/6] chore(items): refresh ceiling-fan model (On clip + base paint slot) The bundled ceiling-fan model.glb was stale (no animation clip, two slots). Replace it with the variant matching production storage: an `On` animation clip and a third `slot_base` paint slot. Same dimensions/offset, so no catalog metadata change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/public/items/ceiling-fan/model.glb | Bin 3844 -> 13472 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/editor/public/items/ceiling-fan/model.glb b/apps/editor/public/items/ceiling-fan/model.glb index 093d54dad649ea518f0c9db1dae4df6b02e07672..b8092f740b0913d1208e596eb7daa6c15cd8c133 100644 GIT binary patch literal 13472 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 literal 3844 zcmbtXdt6g>8-LCjo7=ceMNl-E6MPjmJZIN4LKuPw3WD4+5n;f9)h@IRm54YBm6;b5 zU%Z#5SVqRB7t;h=VSPj`O3hGFDZ?}~Nqf_Hk@Y)Ml=|eWzVG>CXXp7n&-Zzr?{nE% zz9A(V2LKmOfS-l{%t%g{RU*<_tvZ`1q(qdjGwCc^o7o}?5#=LQNs2{lvgVmBM&SZ^ zusoO$iA4)^7OUQDLK;%^Tq+i6b8>W6tJz`|g`}5=Y{i8-be0?$5f_n!)N{2SUR3VlS z!7@Tg$W)|4rBcb%=&lqaEmBD;O$S zQYDoVa>N`Wf(%xx)fB^}S}7+G!!n(OLVS`WMX42(f+SQ5rCcpl4knaPDJe>hSduHG zN|l^aISG}^DVdazN!28!M&)vr$_YcsPz8{Xq6qopQmT{=VhFX)Ln%cDDAeqtygZ#H zO|M&cr(N$ni$Vx=RBY2F;qGIc&XjK}5QV5zPHzuTMyGeI2h>S|RNi-X*AYU$ zcdnu+=G>^Y=`4D!;f{t$Yh)(JYB1X}vklr@ofUP7ZjsHRMdu}=LajANYseh1L7E1A zwnb|x4k@(gP|y;awg`=0saTY2F3L9OlJ&W|Tv3S4Qlt}$3bQToI-AyD(B~wXi}DLh zXkr=AY^_xnX*QTG(b^p6*rU0k2O_;|6j;oNtN%Y$Qi+;aEmu%-N{*UY$t;XVlRZ|#OsRF&0#s0DL*C6j z9!Y|gXeXi63oUx1-lku0S3_&FS@hXOHs-NJl$ekln-ZHa3++p>XjVc}d_ zv^kLp2}x0zgeXLYxGFFr#c$6#==a3q)-_d9uZrNnl%Ugl>SLCnUNr+0RX}Y=hXX4 zaZ;_!;HHcIOM#Gw`}rs7o-5K@9&S@A6!vidrpC^KsHBL<1RTdO^e*5ZimO@RS@z8C zq_yEqX_F4WF}K!>jbTKd`ES3F)kNfPB%??Sma@@VyK)S9)hpezyGb4Pnz2W8ID& z#)iw|!Jof+`DCcPHWa>C;h{RTe~t3MzvFyIRUE@Lc|T0SHE+HOpy6<6I?6xsx1mr8 zg%^W9j_M!&e0eV27EyH@*1AJgsf$~&TCgu6Zja#7h&7`oxEh-`^@ZXZelvPq)%!xD zDB{3$&;;{fUc>C237uU(pA6E^6HMh!>d!|$vUh;}*KcN&cgsFr{PWg-1)19Y#g{%j zhk|of6k*usR5U|IMRR8Q%5b;AGoVVuXJXh$9yY?>yU%S^6o@c-Ncc8K0V(sCDG?@jsR71;x{Au+~$3t8&|lF8GxmjeMk;xXtm~<#L-- z<2ufB%dp{1H>9tf)0T0nuez6~&pYEoa__hOk*nw^7FQ7O zRevSCr_2X^YP@KT7eH5dj~{I>1Kfde`_TWKrRWRn?`K6ihWym)d8yY=n7}PG_0pSi zAnG%<@YJ`$7dtd(A*;qCZi-@Sqh#$A?phA~idKk*Nc4q5<3{^`+VHjmo|+9D+E_-1 z$KbHppDr^bZmNl7?Y$EIml0$03w?&V|C9HsKygbh2~duxa_THXAG*(;YtT6DHR{M;1Px4MDyQpZUS{kNBrMxa-pP1YB6P z&3lZa7VOK)PglLUx_Wsy?we0Ph1r+QhM3|J@b0BoN6f_SGv>2{epiYo1qAWnU4LoA z_o-m#(<{ZJ7(rakmMBJ*##|Pm+tN?@Wzg&hj59XH&4`{mtbgig?h!{I)LU8|>%#T~ zbg#meoM2+9E%&~7YONyk$>$-5HFku6v(lZ*TD!qES8l!tB#u$)DT^ zhZ3HZcBD79;B;LX1{~o?`*Rh)eNZqyuyw)g4&RqhL!--q=DypnHEexzUf7q5+rr%b E00ImHLjV8( From 70acf894d167dd8ea543fb2831de96abfeca39cd Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 26 Jun 2026 15:51:12 -0400 Subject: [PATCH 5/6] =?UTF-8?q?feat(viewer):=20widen=20first-person=20walk?= =?UTF-8?q?through=20FOV=20to=2060=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walkthrough rode the default 50° orbit camera, which feels cramped on foot. Both walkthrough controllers (baked GlbWalkthroughController and the parametric WalkthroughControls fallback) now set a shared WALKTHROUGH_FOV = 60 on enter and restore the prior FOV on exit, leaving orbit framing untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../viewer/glb-walkthrough-controller.tsx | 18 +++++++++++++++ .../viewer/walkthrough-controls.tsx | 22 ++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx index f235f773..50c83542 100644 --- a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -15,6 +15,7 @@ import { Mesh, MeshBasicMaterial, type Object3D, + type PerspectiveCamera, Quaternion, Vector3, } from 'three' @@ -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 @@ -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) { diff --git a/packages/viewer/src/components/viewer/walkthrough-controls.tsx b/packages/viewer/src/components/viewer/walkthrough-controls.tsx index b3d55f28..65c6b69f 100644 --- a/packages/viewer/src/components/viewer/walkthrough-controls.tsx +++ b/packages/viewer/src/components/viewer/walkthrough-controls.tsx @@ -3,12 +3,18 @@ import { PointerLockControls } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useRef } from 'react' -import { Vector3 } from 'three' +import { type PerspectiveCamera, Vector3 } from 'three' import useViewer from '../../store/use-viewer' const MOVE_SPEED = 5 const EYE_HEIGHT = 1.6 +// First-person FOV. The orbit camera is 50° (set on the Canvas), which feels +// cramped on foot; ~60° vertical (~90° horizontal at 16:9) restores peripheral +// awareness without wide-angle distortion. Applied only while walking — both +// walkthrough controllers read this and restore the orbit FOV on exit. +export const WALKTHROUGH_FOV = 60 + const _direction = new Vector3() const _forward = new Vector3() const _right = new Vector3() @@ -26,6 +32,20 @@ export const WalkthroughControls = () => { } }, [walkthroughMode, camera]) + // Widen FOV while walking; restore the orbit FOV on exit. + useEffect(() => { + if (!walkthroughMode) return + 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() + } + }, [walkthroughMode, camera]) + // Keyboard handlers useEffect(() => { if (!walkthroughMode) return From b0313a13dcfafc1818e1e190696bd65a803a1ddd Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Sun, 28 Jun 2026 16:36:35 -0400 Subject: [PATCH 6/6] chore(editor): sort imports in glb-export.test.ts after main merge Fixes the lone biome organizeImports error so the quality gate passes on the post-#448 base. Type-check (9/9), biome, and all suites green. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/editor/src/lib/glb-export.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index cc31fba4..11b4df7e 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from 'bun:test' import { type AnyNode, DoorNode, sceneRegistry } from '@pascal-app/core' -import * as THREE from 'three' import { buildDoorPreviewMesh } from '@pascal-app/viewer' +import * as THREE from 'three' import { prepareSceneForExport } from './glb-export' afterEach(() => {