Merge pull request #444 from pascalorg/fix/bake-door-window-anims
fix(doors): bake open-animation clips for every operation door + un-flipped rest pose
This commit is contained in:
Binary file not shown.
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, test } from 'bun:test'
|
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 * as THREE from 'three'
|
||||||
import { prepareSceneForExport } from './glb-export'
|
import { prepareSceneForExport } from './glb-export'
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ describe('prepareSceneForExport', () => {
|
|||||||
kind: 'door',
|
kind: 'door',
|
||||||
label: 'Front door',
|
label: 'Front door',
|
||||||
openable: true,
|
openable: true,
|
||||||
clips: ['Front door: open'],
|
clips: ['door_test: open'],
|
||||||
})
|
})
|
||||||
|
|
||||||
// The swing-leaf marker must not survive into glTF extras.
|
// The swing-leaf marker must not survive into glTF extras.
|
||||||
@@ -245,7 +246,7 @@ describe('prepareSceneForExport', () => {
|
|||||||
|
|
||||||
expect(animations).toHaveLength(1)
|
expect(animations).toHaveLength(1)
|
||||||
const clip = animations[0]!
|
const clip = animations[0]!
|
||||||
expect(clip.name).toBe('Door: open')
|
expect(clip.name).toBe('door_swing: open')
|
||||||
expect(clip.duration).toBe(1)
|
expect(clip.duration).toBe(1)
|
||||||
// Playback intent carried in extras so consumers can play once and hold.
|
// Playback intent carried in extras so consumers can play once and hold.
|
||||||
expect(clip.userData).toEqual({ loop: false })
|
expect(clip.userData).toEqual({ loop: false })
|
||||||
@@ -264,4 +265,129 @@ describe('prepareSceneForExport', () => {
|
|||||||
const closed = new THREE.Quaternion().fromArray(Array.from(track.values).slice(0, 4))
|
const closed = new THREE.Quaternion().fromArray(Array.from(track.values).slice(0, 4))
|
||||||
expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
|
type DoorNode,
|
||||||
emitter,
|
emitter,
|
||||||
getLevelDisplayName,
|
getLevelDisplayName,
|
||||||
|
isOperationDoorType,
|
||||||
itemClipRegistry,
|
itemClipRegistry,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
type WindowNode,
|
type WindowNode,
|
||||||
type ZoneNode,
|
type ZoneNode,
|
||||||
} from '@pascal-app/core'
|
} 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 type { Object3D } from 'three'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
||||||
@@ -433,12 +440,128 @@ function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationCl
|
|||||||
return clip
|
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, 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
|
* 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
|
* (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.
|
* track; the leaf is left at the closed pose so the GLB's rest state is shut.
|
||||||
*/
|
*/
|
||||||
function bakeDoorClip(
|
function bakeSwingDoorClip(
|
||||||
id: string,
|
id: string,
|
||||||
node: AnyNode,
|
node: AnyNode,
|
||||||
doorObject: THREE.Object3D,
|
doorObject: THREE.Object3D,
|
||||||
@@ -465,21 +588,24 @@ function bakeDoorClip(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (tracks.length === 0) return null
|
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
|
* Wrap an open motion in a named 1-second clip. The name is keyed by the node id
|
||||||
* when set (e.g. "Door 1: open") so a glTF player lists readable clips, falling
|
* (`<id>: open`), NOT the node's display name: clip names must be unique because
|
||||||
* back to the id. glTF has no core loop flag — the player decides — so we stamp
|
* the baked viewer drives playback by clip name (`useAnimations` maps name →
|
||||||
* `extras.loop = false` (via the clip's userData, which `GLTFExporter`
|
* action), so two same-named openables (e.g. several "Window 1"s) would collapse
|
||||||
* serialises onto the animation): Pascal's `/viewer` and any extras-aware
|
* to a single action and a trigger on one would animate another. The
|
||||||
* consumer play it once and hold the open pose; a dumb glTF player still loops.
|
* human-readable name lives in `extras.label` instead. glTF has no core loop
|
||||||
* Consumers map a clip back to its node by walking up from a channel's target to
|
* flag — the player decides — so we stamp `extras.loop = false` (via the clip's
|
||||||
* the nearest ancestor carrying `extras.pascalId`, so the name stays cosmetic.
|
* 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 {
|
function openClip(id: string, tracks: THREE.KeyframeTrack[]): THREE.AnimationClip {
|
||||||
const clip = new THREE.AnimationClip(`${node.name ?? id}: open`, 1, tracks)
|
const clip = new THREE.AnimationClip(`${id}: open`, 1, tracks)
|
||||||
clip.userData = { loop: false }
|
clip.userData = { loop: false }
|
||||||
return clip
|
return clip
|
||||||
}
|
}
|
||||||
@@ -539,7 +665,7 @@ function bakeWindowClip(
|
|||||||
poseWindowMovingParts(node, windowObject, 0)
|
poseWindowMovingParts(node, windowObject, 0)
|
||||||
|
|
||||||
if (tracks.length === 0) return null
|
if (tracks.length === 0) return null
|
||||||
return openClip(id, node, tracks)
|
return openClip(id, tracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Identity stamping ---------------------------------------------------
|
// --- Identity stamping ---------------------------------------------------
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Mesh,
|
Mesh,
|
||||||
MeshBasicMaterial,
|
MeshBasicMaterial,
|
||||||
type Object3D,
|
type Object3D,
|
||||||
|
type PerspectiveCamera,
|
||||||
Quaternion,
|
Quaternion,
|
||||||
Vector3,
|
Vector3,
|
||||||
} from 'three'
|
} from 'three'
|
||||||
@@ -24,6 +25,7 @@ import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2'
|
|||||||
import { SCENE_LAYER } from '../../lib/layers'
|
import { SCENE_LAYER } from '../../lib/layers'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl'
|
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
|
// Eye/capsule geometry mirrors the editor's first-person controller so the
|
||||||
// baked walkthrough feels identical. The capsule centre sits below the eye; 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(() => {
|
useEffect(() => {
|
||||||
worldRef.current = world
|
worldRef.current = world
|
||||||
if (world) {
|
if (world) {
|
||||||
|
|||||||
@@ -3,12 +3,18 @@
|
|||||||
import { PointerLockControls } from '@react-three/drei'
|
import { PointerLockControls } from '@react-three/drei'
|
||||||
import { useFrame, useThree } from '@react-three/fiber'
|
import { useFrame, useThree } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { Vector3 } from 'three'
|
import { type PerspectiveCamera, Vector3 } from 'three'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
const MOVE_SPEED = 5
|
const MOVE_SPEED = 5
|
||||||
const EYE_HEIGHT = 1.6
|
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 _direction = new Vector3()
|
||||||
const _forward = new Vector3()
|
const _forward = new Vector3()
|
||||||
const _right = new Vector3()
|
const _right = new Vector3()
|
||||||
@@ -26,6 +32,20 @@ export const WalkthroughControls = () => {
|
|||||||
}
|
}
|
||||||
}, [walkthroughMode, camera])
|
}, [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
|
// Keyboard handlers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!walkthroughMode) return
|
if (!walkthroughMode) return
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export {
|
|||||||
createColumnTorusGeometry,
|
createColumnTorusGeometry,
|
||||||
} from './systems/column/column-geometry'
|
} from './systems/column/column-geometry'
|
||||||
export { DoorAnimationSystem } from './systems/door/door-animation-system'
|
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'
|
export { ElevatorInteractionSystem } from './systems/elevator/elevator-interaction-system'
|
||||||
// Fence system follows the wall re-export pattern — composed into the
|
// Fence system follows the wall re-export pattern — composed into the
|
||||||
// registry-driven fence definition's `def.system`. Removed in Phase 6
|
// registry-driven fence definition's `def.system`. Removed in Phase 6
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -290,26 +290,6 @@ function addRotatedBox(
|
|||||||
parent.add(m)
|
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(
|
function addShape(
|
||||||
parent: THREE.Object3D,
|
parent: THREE.Object3D,
|
||||||
material: THREE.Material,
|
material: THREE.Material,
|
||||||
@@ -1360,6 +1340,156 @@ 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 `<name><index>` 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 `<name><index>` 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.set(-angle, 0, 0)
|
||||||
|
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.
|
||||||
|
// 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++) {
|
||||||
|
const group = mesh.getObjectByName(`${FOLDING_PANEL_NAME}${index}`)
|
||||||
|
const direction = index % 2 === 0 ? -1 : 1
|
||||||
|
if (group) {
|
||||||
|
posed = true
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
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.set(rotationX, 0, 0)
|
||||||
|
}
|
||||||
|
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(
|
function addFoldingDoor(
|
||||||
mesh: THREE.Mesh,
|
mesh: THREE.Mesh,
|
||||||
{
|
{
|
||||||
@@ -1369,7 +1499,6 @@ function addFoldingDoor(
|
|||||||
leafDepth,
|
leafDepth,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
operationState,
|
|
||||||
leafCount,
|
leafCount,
|
||||||
doorHeight,
|
doorHeight,
|
||||||
handleHeight,
|
handleHeight,
|
||||||
@@ -1382,7 +1511,6 @@ function addFoldingDoor(
|
|||||||
leafDepth: number
|
leafDepth: number
|
||||||
frameThickness: number
|
frameThickness: number
|
||||||
frameDepth: number
|
frameDepth: number
|
||||||
operationState: number
|
|
||||||
leafCount: DoorNode['leafCount']
|
leafCount: DoorNode['leafCount']
|
||||||
doorHeight: number
|
doorHeight: number
|
||||||
handleHeight: number
|
handleHeight: number
|
||||||
@@ -1391,9 +1519,7 @@ function addFoldingDoor(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const panelCount = leafCount === 2 ? 2 : 4
|
const panelCount = leafCount === 2 ? 2 : 4
|
||||||
const foldAmount = clampDoorOperationState(operationState)
|
|
||||||
const panelLength = insideWidth / panelCount
|
const panelLength = insideWidth / panelCount
|
||||||
const foldAngle = Math.PI * 0.44 * foldAmount
|
|
||||||
|
|
||||||
currentDoorSlot = 'hardware'
|
currentDoorSlot = 'hardware'
|
||||||
addBox(
|
addBox(
|
||||||
@@ -1407,31 +1533,20 @@ function addFoldingDoor(
|
|||||||
0,
|
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++) {
|
for (let index = 0; index < panelCount; index++) {
|
||||||
const previous = vertices[index]!
|
const group = new THREE.Group()
|
||||||
const direction = index % 2 === 0 ? -1 : 1
|
group.name = `${FOLDING_PANEL_NAME}${index}`
|
||||||
const angle = direction * foldAngle
|
group.position.set(index === 0 ? -insideWidth / 2 : panelLength, 0, 0)
|
||||||
vertices.push({
|
parent.add(group)
|
||||||
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),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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 = (
|
const addFoldingLeafBox = (
|
||||||
material: THREE.Material,
|
material: THREE.Material,
|
||||||
w: number,
|
w: number,
|
||||||
@@ -1440,19 +1555,7 @@ function addFoldingDoor(
|
|||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
z: number,
|
z: number,
|
||||||
) => {
|
) => addBox(group, material, w, h, d, panelLength / 2 + x, y, z)
|
||||||
addRotatedBox(
|
|
||||||
mesh,
|
|
||||||
material,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
d,
|
|
||||||
centerX + localX.x * x + Math.sin(rotationY) * z,
|
|
||||||
y,
|
|
||||||
centerZ + localX.z * x + Math.cos(rotationY) * z,
|
|
||||||
rotationY,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
addLeafSegmentContent({
|
addLeafSegmentContent({
|
||||||
addLeafBox: addFoldingLeafBox,
|
addLeafBox: addFoldingLeafBox,
|
||||||
@@ -1466,43 +1569,37 @@ function addFoldingDoor(
|
|||||||
keepFrameWhenEmpty: true,
|
keepFrameWhenEmpty: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Reveal posts at the panel's hinge edges (local x=0 and x=panelLength).
|
||||||
currentDoorSlot = undefined
|
currentDoorSlot = undefined
|
||||||
for (const point of [start, end]) {
|
for (const px of [0, panelLength]) {
|
||||||
addBox(
|
addBox(group, revealMaterial, 0.018, leafHeight * 0.92, leafDepth + 0.016, px, leafCenterY, 0)
|
||||||
mesh,
|
|
||||||
revealMaterial,
|
|
||||||
0.018,
|
|
||||||
leafHeight * 0.92,
|
|
||||||
leafDepth + 0.016,
|
|
||||||
point.x,
|
|
||||||
leafCenterY,
|
|
||||||
point.z,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
const handleY = handleHeight - doorHeight / 2
|
||||||
currentDoorSlot = 'hardware'
|
currentDoorSlot = 'hardware'
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
parent,
|
||||||
hardwareMaterial,
|
hardwareMaterial,
|
||||||
0.035,
|
0.035,
|
||||||
0.16,
|
0.16,
|
||||||
leafDepth + 0.035,
|
leafDepth + 0.035,
|
||||||
handlePoint.x - 0.035,
|
panelLength - 0.035,
|
||||||
handleY,
|
handleY,
|
||||||
handlePoint.z + 0.045,
|
0.045,
|
||||||
)
|
)
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
parent,
|
||||||
hardwareMaterial,
|
hardwareMaterial,
|
||||||
0.035,
|
0.035,
|
||||||
0.16,
|
0.16,
|
||||||
leafDepth + 0.035,
|
leafDepth + 0.035,
|
||||||
handlePoint.x - 0.035,
|
panelLength - 0.035,
|
||||||
handleY,
|
handleY,
|
||||||
handlePoint.z - 0.045,
|
-0.045,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1515,7 +1612,6 @@ function addPocketDoor(
|
|||||||
leafDepth,
|
leafDepth,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
operationState,
|
|
||||||
slideDirection,
|
slideDirection,
|
||||||
doorHeight,
|
doorHeight,
|
||||||
handleHeight,
|
handleHeight,
|
||||||
@@ -1528,7 +1624,6 @@ function addPocketDoor(
|
|||||||
leafDepth: number
|
leafDepth: number
|
||||||
frameThickness: number
|
frameThickness: number
|
||||||
frameDepth: number
|
frameDepth: number
|
||||||
operationState: number
|
|
||||||
slideDirection: DoorNode['slideDirection']
|
slideDirection: DoorNode['slideDirection']
|
||||||
doorHeight: number
|
doorHeight: number
|
||||||
handleHeight: number
|
handleHeight: number
|
||||||
@@ -1536,14 +1631,14 @@ function addPocketDoor(
|
|||||||
contentPadding: DoorNode['contentPadding']
|
contentPadding: DoorNode['contentPadding']
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const openAmount = clampDoorOperationState(operationState)
|
|
||||||
const slideSign = slideDirection === 'right' ? 1 : -1
|
const slideSign = slideDirection === 'right' ? 1 : -1
|
||||||
const leafWidth = insideWidth
|
const leafWidth = insideWidth
|
||||||
const leafCenterX = slideSign * insideWidth * openAmount
|
|
||||||
const topY = leafCenterY + leafHeight / 2
|
const topY = leafCenterY + leafHeight / 2
|
||||||
const pocketCenterX = slideSign * insideWidth
|
const pocketCenterX = slideSign * insideWidth
|
||||||
const handleY = handleHeight - doorHeight / 2
|
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'
|
currentDoorSlot = 'hardware'
|
||||||
addBox(
|
addBox(
|
||||||
@@ -1578,6 +1673,10 @@ function addPocketDoor(
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const leafGroup = new THREE.Group()
|
||||||
|
leafGroup.name = POCKET_LEAF_NAME
|
||||||
|
mesh.add(leafGroup)
|
||||||
|
|
||||||
const addPocketLeafBox = (
|
const addPocketLeafBox = (
|
||||||
material: THREE.Material,
|
material: THREE.Material,
|
||||||
w: number,
|
w: number,
|
||||||
@@ -1586,13 +1685,13 @@ function addPocketDoor(
|
|||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
z: number,
|
z: number,
|
||||||
) => addBox(mesh, material, w, h, d, x, y, z)
|
) => addBox(leafGroup, material, w, h, d, x, y, z)
|
||||||
|
|
||||||
addLeafSegmentContent({
|
addLeafSegmentContent({
|
||||||
addLeafBox: addPocketLeafBox,
|
addLeafBox: addPocketLeafBox,
|
||||||
leafWidth,
|
leafWidth,
|
||||||
leafHeight,
|
leafHeight,
|
||||||
leafCenterX,
|
leafCenterX: 0,
|
||||||
leafCenterY,
|
leafCenterY,
|
||||||
leafDepth,
|
leafDepth,
|
||||||
segments,
|
segments,
|
||||||
@@ -1600,7 +1699,7 @@ function addPocketDoor(
|
|||||||
})
|
})
|
||||||
currentDoorSlot = 'hardware'
|
currentDoorSlot = 'hardware'
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
leafGroup,
|
||||||
hardwareMaterial,
|
hardwareMaterial,
|
||||||
0.03,
|
0.03,
|
||||||
0.18,
|
0.18,
|
||||||
@@ -1610,7 +1709,7 @@ function addPocketDoor(
|
|||||||
leafDepth / 2 + 0.02,
|
leafDepth / 2 + 0.02,
|
||||||
)
|
)
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
leafGroup,
|
||||||
hardwareMaterial,
|
hardwareMaterial,
|
||||||
0.03,
|
0.03,
|
||||||
0.18,
|
0.18,
|
||||||
@@ -1630,7 +1729,6 @@ function addBarnDoor(
|
|||||||
leafDepth,
|
leafDepth,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
operationState,
|
|
||||||
slideDirection,
|
slideDirection,
|
||||||
doorHeight,
|
doorHeight,
|
||||||
handleHeight,
|
handleHeight,
|
||||||
@@ -1643,7 +1741,6 @@ function addBarnDoor(
|
|||||||
leafDepth: number
|
leafDepth: number
|
||||||
frameThickness: number
|
frameThickness: number
|
||||||
frameDepth: number
|
frameDepth: number
|
||||||
operationState: number
|
|
||||||
slideDirection: DoorNode['slideDirection']
|
slideDirection: DoorNode['slideDirection']
|
||||||
doorHeight: number
|
doorHeight: number
|
||||||
handleHeight: number
|
handleHeight: number
|
||||||
@@ -1651,16 +1748,17 @@ function addBarnDoor(
|
|||||||
contentPadding: DoorNode['contentPadding']
|
contentPadding: DoorNode['contentPadding']
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const openAmount = clampDoorOperationState(operationState)
|
|
||||||
const slideSign = slideDirection === 'right' ? 1 : -1
|
const slideSign = slideDirection === 'right' ? 1 : -1
|
||||||
const leafWidth = insideWidth * 1.06
|
const leafWidth = insideWidth * 1.06
|
||||||
const leafCenterX = slideSign * insideWidth * openAmount
|
|
||||||
const faceZ = frameDepth / 2 + leafDepth / 2 + 0.028
|
const faceZ = frameDepth / 2 + leafDepth / 2 + 0.028
|
||||||
const trackY = leafCenterY + leafHeight / 2 + Math.max(frameThickness * 0.55, 0.045)
|
const trackY = leafCenterY + leafHeight / 2 + Math.max(frameThickness * 0.55, 0.045)
|
||||||
const railLength = insideWidth * 2.25
|
const railLength = insideWidth * 2.25
|
||||||
const railCenterX = slideSign * (insideWidth * 0.56)
|
const railCenterX = slideSign * (insideWidth * 0.56)
|
||||||
const handleY = handleHeight - doorHeight / 2
|
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
|
const wheelY = trackY - 0.075
|
||||||
|
|
||||||
currentDoorSlot = 'hardware'
|
currentDoorSlot = 'hardware'
|
||||||
@@ -1668,6 +1766,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)
|
||||||
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 = (
|
const addBarnLeafBox = (
|
||||||
material: THREE.Material,
|
material: THREE.Material,
|
||||||
w: number,
|
w: number,
|
||||||
@@ -1676,13 +1778,13 @@ function addBarnDoor(
|
|||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
z: number,
|
z: number,
|
||||||
) => addBox(mesh, material, w, h, d, x, y, faceZ + z)
|
) => addBox(leafGroup, material, w, h, d, x, y, faceZ + z)
|
||||||
|
|
||||||
addLeafSegmentContent({
|
addLeafSegmentContent({
|
||||||
addLeafBox: addBarnLeafBox,
|
addLeafBox: addBarnLeafBox,
|
||||||
leafWidth,
|
leafWidth,
|
||||||
leafHeight,
|
leafHeight,
|
||||||
leafCenterX,
|
leafCenterX: 0,
|
||||||
leafCenterY,
|
leafCenterY,
|
||||||
leafDepth,
|
leafDepth,
|
||||||
segments,
|
segments,
|
||||||
@@ -1692,23 +1794,23 @@ function addBarnDoor(
|
|||||||
|
|
||||||
currentDoorSlot = undefined
|
currentDoorSlot = undefined
|
||||||
addRotatedBox(
|
addRotatedBox(
|
||||||
mesh,
|
leafGroup,
|
||||||
revealMaterial,
|
revealMaterial,
|
||||||
0.018,
|
0.018,
|
||||||
leafHeight * 0.86,
|
leafHeight * 0.86,
|
||||||
0.012,
|
0.012,
|
||||||
leafCenterX,
|
0,
|
||||||
leafCenterY,
|
leafCenterY,
|
||||||
faceZ + leafDepth / 2 + 0.014,
|
faceZ + leafDepth / 2 + 0.014,
|
||||||
-0.52,
|
-0.52,
|
||||||
)
|
)
|
||||||
addRotatedBox(
|
addRotatedBox(
|
||||||
mesh,
|
leafGroup,
|
||||||
revealMaterial,
|
revealMaterial,
|
||||||
0.018,
|
0.018,
|
||||||
leafHeight * 0.86,
|
leafHeight * 0.86,
|
||||||
0.012,
|
0.012,
|
||||||
leafCenterX,
|
0,
|
||||||
leafCenterY,
|
leafCenterY,
|
||||||
faceZ + leafDepth / 2 + 0.014,
|
faceZ + leafDepth / 2 + 0.014,
|
||||||
0.52,
|
0.52,
|
||||||
@@ -1716,22 +1818,13 @@ function addBarnDoor(
|
|||||||
|
|
||||||
currentDoorSlot = 'hardware'
|
currentDoorSlot = 'hardware'
|
||||||
for (const offset of [-leafWidth * 0.28, leafWidth * 0.28]) {
|
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(leafGroup, hardwareMaterial, 0.085, 0.085, 0.035, offset, wheelY, faceZ + 0.022)
|
||||||
addBox(
|
addBox(leafGroup, hardwareMaterial, 0.026, 0.16, 0.026, offset, wheelY - 0.075, faceZ + 0.022)
|
||||||
mesh,
|
|
||||||
hardwareMaterial,
|
|
||||||
0.026,
|
|
||||||
0.16,
|
|
||||||
0.026,
|
|
||||||
leafCenterX + offset,
|
|
||||||
wheelY - 0.075,
|
|
||||||
faceZ + 0.022,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDoorSlot = 'hardware'
|
currentDoorSlot = 'hardware'
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
leafGroup,
|
||||||
hardwareMaterial,
|
hardwareMaterial,
|
||||||
0.032,
|
0.032,
|
||||||
0.22,
|
0.22,
|
||||||
@@ -1741,7 +1834,7 @@ function addBarnDoor(
|
|||||||
faceZ + leafDepth / 2 + 0.02,
|
faceZ + leafDepth / 2 + 0.02,
|
||||||
)
|
)
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
leafGroup,
|
||||||
hardwareMaterial,
|
hardwareMaterial,
|
||||||
0.032,
|
0.032,
|
||||||
0.22,
|
0.22,
|
||||||
@@ -1761,7 +1854,6 @@ function addSlidingDoor(
|
|||||||
leafDepth,
|
leafDepth,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
operationState,
|
|
||||||
slideDirection,
|
slideDirection,
|
||||||
doorHeight,
|
doorHeight,
|
||||||
handleHeight,
|
handleHeight,
|
||||||
@@ -1774,7 +1866,6 @@ function addSlidingDoor(
|
|||||||
leafDepth: number
|
leafDepth: number
|
||||||
frameThickness: number
|
frameThickness: number
|
||||||
frameDepth: number
|
frameDepth: number
|
||||||
operationState: number
|
|
||||||
slideDirection: DoorNode['slideDirection']
|
slideDirection: DoorNode['slideDirection']
|
||||||
doorHeight: number
|
doorHeight: number
|
||||||
handleHeight: number
|
handleHeight: number
|
||||||
@@ -1782,7 +1873,6 @@ function addSlidingDoor(
|
|||||||
contentPadding: DoorNode['contentPadding']
|
contentPadding: DoorNode['contentPadding']
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const openAmount = clampDoorOperationState(operationState)
|
|
||||||
const activeOnRight = slideDirection === 'left'
|
const activeOnRight = slideDirection === 'left'
|
||||||
const fixedSign = activeOnRight ? -1 : 1
|
const fixedSign = activeOnRight ? -1 : 1
|
||||||
const activeSign = activeOnRight ? 1 : -1
|
const activeSign = activeOnRight ? 1 : -1
|
||||||
@@ -1790,12 +1880,14 @@ function addSlidingDoor(
|
|||||||
const panelHeight = leafHeight
|
const panelHeight = leafHeight
|
||||||
const closedActiveX = activeSign * insideWidth * 0.23
|
const closedActiveX = activeSign * insideWidth * 0.23
|
||||||
const fixedX = fixedSign * insideWidth * 0.23
|
const fixedX = fixedSign * insideWidth * 0.23
|
||||||
const activeX = closedActiveX - activeSign * insideWidth * 0.44 * openAmount
|
|
||||||
const frontZ = leafDepth / 2 + 0.016
|
const frontZ = leafDepth / 2 + 0.016
|
||||||
const backZ = -leafDepth / 2 - 0.006
|
const backZ = -leafDepth / 2 - 0.006
|
||||||
const railY = leafCenterY + panelHeight / 2 - Math.min(frameThickness * 0.35, 0.02)
|
const railY = leafCenterY + panelHeight / 2 - Math.min(frameThickness * 0.35, 0.02)
|
||||||
const handleY = handleHeight - doorHeight / 2
|
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'
|
currentDoorSlot = 'hardware'
|
||||||
addBox(
|
addBox(
|
||||||
@@ -1829,6 +1921,10 @@ function addSlidingDoor(
|
|||||||
z: number,
|
z: number,
|
||||||
) => addBox(mesh, material, w, h, d, x + fixedX, y, z + backZ)
|
) => 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 = (
|
const addActivePanelBox = (
|
||||||
material: THREE.Material,
|
material: THREE.Material,
|
||||||
w: number,
|
w: number,
|
||||||
@@ -1837,7 +1933,7 @@ function addSlidingDoor(
|
|||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
z: 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({
|
addLeafSegmentContent({
|
||||||
addLeafBox: addFixedPanelBox,
|
addLeafBox: addFixedPanelBox,
|
||||||
@@ -1863,7 +1959,7 @@ function addSlidingDoor(
|
|||||||
})
|
})
|
||||||
currentDoorSlot = 'hardware'
|
currentDoorSlot = 'hardware'
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
activePanelGroup,
|
||||||
hardwareMaterial,
|
hardwareMaterial,
|
||||||
0.032,
|
0.032,
|
||||||
0.24,
|
0.24,
|
||||||
@@ -1873,7 +1969,7 @@ function addSlidingDoor(
|
|||||||
frontZ + leafDepth / 2 + 0.01,
|
frontZ + leafDepth / 2 + 0.01,
|
||||||
)
|
)
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
activePanelGroup,
|
||||||
hardwareMaterial,
|
hardwareMaterial,
|
||||||
0.032,
|
0.032,
|
||||||
0.24,
|
0.24,
|
||||||
@@ -1893,7 +1989,6 @@ function addGarageSectionalDoor(
|
|||||||
leafDepth,
|
leafDepth,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
operationState,
|
|
||||||
garagePanelCount,
|
garagePanelCount,
|
||||||
}: {
|
}: {
|
||||||
insideWidth: number
|
insideWidth: number
|
||||||
@@ -1902,19 +1997,13 @@ function addGarageSectionalDoor(
|
|||||||
leafDepth: number
|
leafDepth: number
|
||||||
frameThickness: number
|
frameThickness: number
|
||||||
frameDepth: number
|
frameDepth: number
|
||||||
operationState: number
|
|
||||||
garagePanelCount: number
|
garagePanelCount: number
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const openAmount = getDoorRenderOpenAmount('garage-sectional', operationState)
|
|
||||||
const panelCount = Math.max(3, Math.min(12, Math.round(garagePanelCount)))
|
const panelCount = Math.max(3, Math.min(12, Math.round(garagePanelCount)))
|
||||||
const panelHeight = leafHeight / panelCount
|
const panelHeight = leafHeight / panelCount
|
||||||
const panelGap = Math.min(0.012, panelHeight * 0.08)
|
const panelGap = Math.min(0.012, panelHeight * 0.08)
|
||||||
const travelDepth = Math.max(leafHeight, 1.4)
|
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 railY = leafCenterY + leafHeight / 2 - 0.04
|
||||||
const railZ = -travelDepth / 2
|
const railZ = -travelDepth / 2
|
||||||
|
|
||||||
@@ -1940,56 +2029,50 @@ function addGarageSectionalDoor(
|
|||||||
railZ,
|
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++) {
|
for (let index = 0; index < panelCount; index++) {
|
||||||
const orderFromTop = panelCount - 1 - index
|
const group = new THREE.Group()
|
||||||
const pathPosition = travel - orderFromTop * panelHeight
|
group.name = `${SECTIONAL_PANEL_NAME}${index}`
|
||||||
let y = overheadY + pathPosition
|
mesh.add(group)
|
||||||
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],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentDoorSlot = 'panel'
|
currentDoorSlot = 'panel'
|
||||||
addBoxWithRotation(
|
addBox(
|
||||||
mesh,
|
group,
|
||||||
baseMaterial,
|
baseMaterial,
|
||||||
insideWidth,
|
insideWidth,
|
||||||
Math.max(0.04, panelHeight - panelGap),
|
Math.max(0.04, panelHeight - panelGap),
|
||||||
leafDepth,
|
leafDepth,
|
||||||
0,
|
0,
|
||||||
y,
|
0,
|
||||||
z,
|
0,
|
||||||
[rotationX, 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'
|
currentDoorSlot = 'hardware'
|
||||||
@@ -2021,7 +2104,6 @@ function addGarageRollupDoor(
|
|||||||
const visibleHeight = leafHeight * (1 - openAmount)
|
const visibleHeight = leafHeight * (1 - openAmount)
|
||||||
const visibleSlatCount = Math.ceil(visibleHeight / slatHeight)
|
const visibleSlatCount = Math.ceil(visibleHeight / slatHeight)
|
||||||
const topY = leafCenterY + leafHeight / 2
|
const topY = leafCenterY + leafHeight / 2
|
||||||
const curtainCenterY = topY - visibleHeight / 2
|
|
||||||
const drumMaxRadius = Math.max(0.12, Math.min(0.22, leafHeight * 0.075))
|
const drumMaxRadius = Math.max(0.12, Math.min(0.22, leafHeight * 0.075))
|
||||||
const drumY = topY + drumMaxRadius * 0.12
|
const drumY = topY + drumMaxRadius * 0.12
|
||||||
const drumZ = -frameDepth / 2 - drumMaxRadius * 0.72
|
const drumZ = -frameDepth / 2 - drumMaxRadius * 0.72
|
||||||
@@ -2049,23 +2131,33 @@ function addGarageRollupDoor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (visibleHeight > 0.01) {
|
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'
|
currentDoorSlot = 'panel'
|
||||||
addBox(mesh, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, curtainCenterY, 0)
|
addBox(curtain, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, -visibleHeight / 2, 0)
|
||||||
|
|
||||||
currentDoorSlot = undefined
|
currentDoorSlot = undefined
|
||||||
for (let index = 0; index < visibleSlatCount; index++) {
|
for (let index = 0; index < visibleSlatCount; index++) {
|
||||||
const y = topY - Math.min(visibleHeight, index * slatHeight)
|
const y = -Math.min(visibleHeight, index * slatHeight)
|
||||||
addBox(mesh, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012)
|
addBox(curtain, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012)
|
||||||
}
|
}
|
||||||
|
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
curtain,
|
||||||
revealMaterial,
|
revealMaterial,
|
||||||
insideWidth - 0.04,
|
insideWidth - 0.04,
|
||||||
0.028,
|
0.028,
|
||||||
leafDepth + 0.018,
|
leafDepth + 0.018,
|
||||||
0,
|
0,
|
||||||
topY - visibleHeight,
|
-visibleHeight,
|
||||||
leafDepth / 2 + 0.004,
|
leafDepth / 2 + 0.004,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2102,7 +2194,6 @@ function addGarageTiltupDoor(
|
|||||||
leafDepth,
|
leafDepth,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
operationState,
|
|
||||||
}: {
|
}: {
|
||||||
insideWidth: number
|
insideWidth: number
|
||||||
leafHeight: number
|
leafHeight: number
|
||||||
@@ -2110,14 +2201,12 @@ function addGarageTiltupDoor(
|
|||||||
leafDepth: number
|
leafDepth: number
|
||||||
frameThickness: number
|
frameThickness: number
|
||||||
frameDepth: number
|
frameDepth: number
|
||||||
operationState: number
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const openAmount = clampDoorOperationState(operationState)
|
|
||||||
const angle = (Math.PI / 2) * openAmount
|
|
||||||
const hingeY = leafCenterY + leafHeight / 2
|
const hingeY = leafCenterY + leafHeight / 2
|
||||||
const panelCenterY = hingeY - Math.cos(angle) * (leafHeight / 2)
|
// Leaf built closed (hanging from the lintel); `poseDoorMovingParts` rotates
|
||||||
const panelCenterZ = -Math.sin(angle) * (leafHeight / 2)
|
// 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 railLength = Math.max(leafHeight * 0.72, 1.2)
|
||||||
const railY = hingeY - frameThickness * 0.35
|
const railY = hingeY - frameThickness * 0.35
|
||||||
const railZ = -railLength / 2
|
const railZ = -railLength / 2
|
||||||
@@ -2144,18 +2233,12 @@ function addGarageTiltupDoor(
|
|||||||
railZ,
|
railZ,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const leafGroup = new THREE.Group()
|
||||||
|
leafGroup.name = TILTUP_LEAF_NAME
|
||||||
|
mesh.add(leafGroup)
|
||||||
|
|
||||||
currentDoorSlot = 'panel'
|
currentDoorSlot = 'panel'
|
||||||
addBoxWithRotation(
|
addBox(leafGroup, baseMaterial, insideWidth, leafHeight, leafDepth, 0, panelCenterY, 0)
|
||||||
mesh,
|
|
||||||
baseMaterial,
|
|
||||||
insideWidth,
|
|
||||||
leafHeight,
|
|
||||||
leafDepth,
|
|
||||||
0,
|
|
||||||
panelCenterY,
|
|
||||||
panelCenterZ,
|
|
||||||
[-angle, 0, 0],
|
|
||||||
)
|
|
||||||
|
|
||||||
const insetWidth = Math.max(0.1, insideWidth - 0.22)
|
const insetWidth = Math.max(0.1, insideWidth - 0.22)
|
||||||
const insetHeight = Math.max(0.1, leafHeight - 0.28)
|
const insetHeight = Math.max(0.1, leafHeight - 0.28)
|
||||||
@@ -2163,16 +2246,15 @@ function addGarageTiltupDoor(
|
|||||||
const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006
|
const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006
|
||||||
const addTiltupTrim = (localX: number, localY: number, trimWidth: number, trimHeight: number) => {
|
const addTiltupTrim = (localX: number, localY: number, trimWidth: number, trimHeight: number) => {
|
||||||
currentDoorSlot = undefined
|
currentDoorSlot = undefined
|
||||||
addBoxWithRotation(
|
addBox(
|
||||||
mesh,
|
leafGroup,
|
||||||
revealMaterial,
|
revealMaterial,
|
||||||
trimWidth,
|
trimWidth,
|
||||||
trimHeight,
|
trimHeight,
|
||||||
trimDepth,
|
trimDepth,
|
||||||
localX,
|
localX,
|
||||||
panelCenterY + localY * Math.cos(angle) + trimFaceOffset * Math.sin(angle),
|
panelCenterY + localY,
|
||||||
panelCenterZ - localY * Math.sin(angle) + trimFaceOffset * Math.cos(angle),
|
trimFaceOffset,
|
||||||
[-angle, 0, 0],
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2393,7 +2475,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
|||||||
leafDepth,
|
leafDepth,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
operationState,
|
|
||||||
garagePanelCount,
|
garagePanelCount,
|
||||||
})
|
})
|
||||||
} else if (doorType === 'garage-rollup') {
|
} else if (doorType === 'garage-rollup') {
|
||||||
@@ -2414,7 +2495,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
|||||||
leafDepth,
|
leafDepth,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
operationState,
|
|
||||||
})
|
})
|
||||||
} else if (doorType === 'folding') {
|
} else if (doorType === 'folding') {
|
||||||
addFoldingDoor(mesh, {
|
addFoldingDoor(mesh, {
|
||||||
@@ -2424,7 +2504,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
|||||||
leafDepth,
|
leafDepth,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
operationState,
|
|
||||||
leafCount,
|
leafCount,
|
||||||
doorHeight: height,
|
doorHeight: height,
|
||||||
handleHeight,
|
handleHeight,
|
||||||
@@ -2439,7 +2518,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
|||||||
leafDepth,
|
leafDepth,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
operationState,
|
|
||||||
slideDirection,
|
slideDirection,
|
||||||
doorHeight: height,
|
doorHeight: height,
|
||||||
handleHeight,
|
handleHeight,
|
||||||
@@ -2454,7 +2532,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
|||||||
leafDepth,
|
leafDepth,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
operationState,
|
|
||||||
slideDirection,
|
slideDirection,
|
||||||
doorHeight: height,
|
doorHeight: height,
|
||||||
handleHeight,
|
handleHeight,
|
||||||
@@ -2469,7 +2546,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
|||||||
leafDepth,
|
leafDepth,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
operationState,
|
|
||||||
slideDirection,
|
slideDirection,
|
||||||
doorHeight: height,
|
doorHeight: height,
|
||||||
handleHeight,
|
handleHeight,
|
||||||
@@ -2574,6 +2650,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)
|
syncDoorCutout(node, mesh)
|
||||||
|
|
||||||
// Guard: some degenerate door configs can leave a child mesh with an
|
// Guard: some degenerate door configs can leave a child mesh with an
|
||||||
|
|||||||
Reference in New Issue
Block a user