Wassim SAMAD 708fa5278e Merge remote-tracking branch 'origin/main' into feat/baked-glb-export
# Conflicts:
#	packages/editor/src/components/editor/export-manager.tsx
2026-06-25 12:34:24 -04:00

2632 lines
71 KiB
TypeScript

import {
type AnyNodeId,
clampDoorOperationState,
type DoorNode,
DoorNode as DoorNodeSchema,
getDoorRenderOpenAmount,
getEffectiveNode,
type SceneMaterial,
type SceneMaterialId,
sceneRegistry,
useInteractive,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import { applyWorldScaleBoxUVs } from '../../lib/box-uv'
import {
type ColorPreset,
createDefaultMaterial,
createSurfaceRoleMaterial,
glassMaterial as defaultGlassMaterial,
baseMaterial as getBaseMaterial,
type RenderShading,
resolveMaterialRef,
} from '../../lib/materials'
import useViewer from '../../store/use-viewer'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
const defaultRevealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' })
// Door hardware (handle / hinges / closer / panic bar) renders a catalog metal
// finish by default (chrome), separate from the door body. The flat material is
// only a fallback if the catalog ref ever fails to resolve.
const HARDWARE_DEFAULT_REF = 'library:metal-chrome'
// Door body defaults to a catalog colour (generic approach). Glass keeps the
// built-in FrontSide glass material — the catalog `preset-glass` is DoubleSide,
// which poisons the WebGPU MRT scene pass.
const PANEL_DEFAULT_REF = 'library:preset-softwhite'
const FRAME_DEFAULT_REF = 'library:preset-softwhite'
const GLASS_DEFAULT_REF = 'library:preset-glass'
const defaultHardwareMaterial = createDefaultMaterial('#3a3a3a', 0.4)
let baseMaterial = getBaseMaterial()
let frameMaterial: THREE.Material = getBaseMaterial()
let revealMaterial: THREE.Material = defaultRevealMaterial
let glassMaterial: THREE.Material = defaultGlassMaterial
let hardwareMaterial: THREE.Material = defaultHardwareMaterial
let currentDoorSlot: string | undefined
// Per-frame viewer state, captured so the per-node mesh builder (which runs
// outside React) can resolve each door's slot materials.
let currentShading: RenderShading = 'rendered'
let currentTextures = true
let currentColorPreset: ColorPreset = 'clay'
let currentSceneMaterials: Record<SceneMaterialId, SceneMaterial> | undefined
const DOOR_RENDER_DEFAULTS = DoorNodeSchema.parse({ id: 'door_render_default' })
const MAX_DOOR_REBUILDS_PER_FRAME = 16
const DOOR_PROGRESSIVE_DIRTY_THRESHOLD = MAX_DOOR_REBUILDS_PER_FRAME
const DOOR_PROGRESSIVE_TIME_BUDGET_MS = 8
// Legacy/unparsed door nodes can miss schema-defaulted fields (segments,
// columnRatios, dividerThickness, …) and crash the geometry build. Re-apply the
// Zod defaults; if the node is structurally invalid (e.g. a segment missing a
// required field) drop the bad segments, then fall back to defaults entirely.
function normalizeDoorNodeForRender(node: DoorNode): DoorNode {
const parsed = DoorNodeSchema.safeParse(node)
if (parsed.success) return parsed.data
const retry = DoorNodeSchema.safeParse({ ...node, segments: undefined })
if (retry.success) return retry.data
return { ...DOOR_RENDER_DEFAULTS, id: node.id, parentId: node.parentId }
}
export const DoorSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset)
const sceneMaterials = useScene((state) => state.materials)
const materialRevisionRef = useRef<string | null>(null)
// Subscribe so an override-only update (no scene write) still re-runs
// the component, letting the gate below pick up the latest dirtyNodes
// set from the same render pass that received the override-publishing
// `markDirty` call. Mirrors WallSystem.
useLiveNodeOverrides((s) => s.overrides)
const joineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset)
baseMaterial = textures ? getBaseMaterial(shading) : joineryMaterial
frameMaterial = textures ? getBaseMaterial(shading) : joineryMaterial
revealMaterial = textures ? defaultRevealMaterial : joineryMaterial
glassMaterial = textures ? defaultGlassMaterial : joineryMaterial
hardwareMaterial = textures ? defaultHardwareMaterial : joineryMaterial
useEffect(() => {
const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}`
if (materialRevisionRef.current === materialRevision) return
materialRevisionRef.current = materialRevision
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
if (node?.type === 'door') {
useScene.getState().dirtyNodes.add(node.id as AnyNodeId)
}
}
})
// Editing a scene material a door slot references must rebuild that door
// (door meshes are built by this system, not <GeometrySystem>).
useEffect(() => {
void sceneMaterials
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
if (node?.type !== 'door') continue
if (!nodeReferencesSceneMaterial(node)) continue
useScene.getState().dirtyNodes.add(node.id as AnyNodeId)
}
}, [sceneMaterials])
useFrame(() => {
if (dirtyNodes.size === 0) return
const frameJoineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset)
baseMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial
frameMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial
revealMaterial = textures ? defaultRevealMaterial : frameJoineryMaterial
glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial
hardwareMaterial = textures ? defaultHardwareMaterial : frameJoineryMaterial
currentShading = shading
currentTextures = textures
currentColorPreset = colorPreset
currentSceneMaterials = sceneMaterials
const nodes = useScene.getState().nodes
const dirtyDoorIds: AnyNodeId[] = []
dirtyNodes.forEach((id) => {
const node = nodes[id]
if (!node || node.type !== 'door') return
dirtyDoorIds.push(id as AnyNodeId)
})
const useProgressiveDoorRebuilds = dirtyDoorIds.length > DOOR_PROGRESSIVE_DIRTY_THRESHOLD
const frameStartedAt = performance.now()
let rebuiltDoorsThisFrame = 0
for (const id of dirtyDoorIds) {
if (useProgressiveDoorRebuilds) {
if (rebuiltDoorsThisFrame >= MAX_DOOR_REBUILDS_PER_FRAME) {
break
}
if (
rebuiltDoorsThisFrame > 0 &&
performance.now() - frameStartedAt >= DOOR_PROGRESSIVE_TIME_BUDGET_MS
) {
break
}
}
const node = nodes[id]
if (!node || node.type !== 'door') continue
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
if (!mesh) continue // Keep dirty until mesh mounts
// Merge any live override (width / height / position) so the mesh
// rebuild reflects the in-flight drag without zustand churn. When
// no override is set this returns the scene node unchanged.
const effectiveNode = getEffectiveNode(node as DoorNode)
updateDoorMesh(effectiveNode, mesh)
clearDirty(id as AnyNodeId)
rebuiltDoorsThisFrame += 1
// Rebuild the parent wall so its cutout reflects the updated door geometry
// Avoid triggering expensive wall CSG rebuilds while the door is being interactively moved/duplicated.
// The editor tools will request a final wall rebuild on commit.
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
if (!isTransient && effectiveNode.parentId) {
useScene.getState().dirtyNodes.add(effectiveNode.parentId as AnyNodeId)
}
}
}, 3)
return null
}
function tagDoorSlot(mesh: THREE.Mesh): THREE.Mesh {
mesh.userData.slotId = currentDoorSlot
return mesh
}
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
const slots = node.slots
if (!slots) return false
for (const ref of Object.values(slots)) {
if (typeof ref === 'string' && ref.startsWith('scene:')) return true
}
return false
}
type DoorMaterialSlotId = 'panel' | 'frame' | 'glass' | 'hardware'
function doorSlotDefault(slotId: DoorMaterialSlotId): THREE.Material {
if (!currentTextures) return createSurfaceRoleMaterial('joinery', currentColorPreset)
if (slotId === 'glass') {
return (
resolveMaterialRef(GLASS_DEFAULT_REF, currentSceneMaterials, currentShading) ??
defaultGlassMaterial
)
}
if (slotId === 'hardware') {
return (
resolveMaterialRef(HARDWARE_DEFAULT_REF, currentSceneMaterials, currentShading) ??
defaultHardwareMaterial
)
}
if (slotId === 'frame') {
return (
resolveMaterialRef(FRAME_DEFAULT_REF, currentSceneMaterials, currentShading) ??
getBaseMaterial(currentShading)
)
}
return (
resolveMaterialRef(PANEL_DEFAULT_REF, currentSceneMaterials, currentShading) ??
getBaseMaterial(currentShading)
)
}
// Resolve a door's slot to a material: the `node.slots` override (colored mode
// only) → the body/glass/hardware default. Textures-off ignores overrides — the
// monochrome escape hatch.
function resolveDoorSlotMaterial(node: DoorNode, slotId: DoorMaterialSlotId): THREE.Material {
const fallback = doorSlotDefault(slotId)
if (!currentTextures) return fallback
const ref = node.slots?.[slotId]
if (!ref) return fallback
return resolveMaterialRef(ref, currentSceneMaterials, currentShading) ?? fallback
}
function addBox(
parent: THREE.Object3D,
material: THREE.Material,
w: number,
h: number,
d: number,
x: number,
y: number,
z: 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)
tagDoorSlot(m)
parent.add(m)
}
function addRotatedBox(
parent: THREE.Object3D,
material: THREE.Material,
w: number,
h: number,
d: number,
x: number,
y: number,
z: number,
rotationY: 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.y = rotationY
tagDoorSlot(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(
parent: THREE.Object3D,
material: THREE.Material,
shape: THREE.Shape,
depth: number,
) {
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: false,
curveSegments: 24,
})
geometry.translate(0, 0, -depth / 2)
const mesh = new THREE.Mesh(geometry, material)
tagDoorSlot(mesh)
parent.add(mesh)
}
function addShapeAt(
parent: THREE.Object3D,
material: THREE.Material,
shape: THREE.Shape,
depth: number,
x: number,
y: number,
z: number,
) {
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: false,
curveSegments: 24,
})
geometry.translate(x, y, z - depth / 2)
const mesh = new THREE.Mesh(geometry, material)
tagDoorSlot(mesh)
parent.add(mesh)
}
function getClampedArchHeight(width: number, height: number, archHeight: number | undefined) {
return Math.min(Math.max(archHeight ?? width / 2, 0.01), Math.max(height, 0.01))
}
function createArchShape(
left: number,
right: number,
bottom: number,
top: number,
archHeight: number,
) {
const centerX = (left + right) / 2
const halfWidth = (right - left) / 2
const clampedArchHeight = getClampedArchHeight(right - left, top - bottom, archHeight)
const springY = top - clampedArchHeight
const shape = new THREE.Shape()
const segments = 32
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, springY)
for (let index = 1; index <= segments; index += 1) {
const x = right + (left - right) * (index / segments)
shape.lineTo(x, getArchBoundaryY(x - centerX, halfWidth, springY, clampedArchHeight))
}
shape.lineTo(left, bottom)
shape.closePath()
return shape
}
function getArchBoundaryY(x: number, halfWidth: number, springY: number, archHeight: number) {
if (halfWidth <= 1e-6) return springY
const t = Math.min(Math.abs(x) / halfWidth, 1)
return springY + archHeight * Math.sqrt(Math.max(1 - t * t, 0))
}
function getOneSidedArchBoundaryYAtX(
x: number,
left: number,
right: number,
top: number,
archHeight: number,
curvedSide: 'left' | 'right',
) {
const width = right - left
if (width <= 1e-6) return top
const clampedArchHeight = getClampedArchHeight(width, Number.MAX_SAFE_INTEGER, archHeight)
const springY = top - clampedArchHeight
const distanceFromApex =
curvedSide === 'left'
? Math.max(0, Math.min((right - x) / width, 1))
: Math.max(0, Math.min((x - left) / width, 1))
return (
springY + clampedArchHeight * Math.sqrt(Math.max(1 - distanceFromApex * distanceFromApex, 0))
)
}
function getRoundedBoundaryYAtX(
x: number,
left: number,
right: number,
top: number,
radii: TopCornerRadii,
) {
if (radii.topLeft > 1e-6 && x < left + radii.topLeft) {
const centerX = left + radii.topLeft
const centerY = top - radii.topLeft
const dx = x - centerX
return centerY + Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dx * dx, 0))
}
if (radii.topRight > 1e-6 && x > right - radii.topRight) {
const centerX = right - radii.topRight
const centerY = top - radii.topRight
const dx = x - centerX
return centerY + Math.sqrt(Math.max(radii.topRight * radii.topRight - dx * dx, 0))
}
return top
}
function createArchBandShape(
width: number,
outerSpringY: number,
outerTopY: number,
innerSpringY: number,
innerTopY: number,
insetX: number,
) {
const halfWidth = width / 2
const innerHalfWidth = Math.max(halfWidth - insetX, 0)
const outerArchHeight = Math.max(outerTopY - outerSpringY, 0)
const safeInnerTopY = Math.min(innerTopY, outerTopY - 0.001)
const safeInnerSpringY = Math.min(innerSpringY, safeInnerTopY - 0.001)
const innerArchHeight = Math.max(safeInnerTopY - safeInnerSpringY, 0)
const shape = new THREE.Shape()
const segments = 32
const getSafeInnerBoundaryY = (x: number) =>
Math.min(
getArchBoundaryY(x, innerHalfWidth, safeInnerSpringY, innerArchHeight),
getArchBoundaryY(x, halfWidth, outerSpringY, outerArchHeight) - 0.001,
)
shape.moveTo(-halfWidth, outerSpringY)
for (let index = 1; index <= segments; index += 1) {
const x = -halfWidth + width * (index / segments)
shape.lineTo(x, getArchBoundaryY(x, halfWidth, outerSpringY, outerArchHeight))
}
if (innerHalfWidth <= 0.001 || safeInnerTopY <= safeInnerSpringY + 0.001) {
shape.lineTo(halfWidth, outerSpringY)
shape.closePath()
return shape
}
shape.lineTo(innerHalfWidth, outerSpringY)
shape.lineTo(innerHalfWidth, getSafeInnerBoundaryY(innerHalfWidth))
for (let index = segments - 1; index >= 0; index -= 1) {
const x = -innerHalfWidth + innerHalfWidth * 2 * (index / segments)
shape.lineTo(x, getSafeInnerBoundaryY(x))
}
shape.lineTo(-innerHalfWidth, outerSpringY)
shape.lineTo(-halfWidth, outerSpringY)
shape.closePath()
return shape
}
function createArchHeadBarShape(width: number, bottomY: number, springY: number, topY: number) {
const halfWidth = width / 2
const archHeight = Math.max(topY - springY, 0)
const shape = new THREE.Shape()
const segments = 32
shape.moveTo(-halfWidth, bottomY)
shape.lineTo(halfWidth, bottomY)
shape.lineTo(halfWidth, springY)
for (let index = 1; index <= segments; index += 1) {
const x = halfWidth - width * (index / segments)
shape.lineTo(x, getArchBoundaryY(x, halfWidth, springY, archHeight))
}
shape.lineTo(-halfWidth, bottomY)
shape.closePath()
return shape
}
type TopCornerRadii = {
topLeft: number
topRight: number
}
function normalizeTopCornerRadii(
radii: TopCornerRadii,
width: number,
height: number,
): TopCornerRadii {
const next = { ...radii }
const scale = Math.min(
1,
width / Math.max(next.topLeft + next.topRight, 1e-6),
height / Math.max(next.topLeft, 1e-6),
height / Math.max(next.topRight, 1e-6),
)
if (scale < 1) {
next.topLeft *= scale
next.topRight *= scale
}
return next
}
function getDoorTopRadii(node: DoorNode, width: number, height: number): TopCornerRadii {
if (node.openingRadiusMode === 'individual') {
const [topLeft = 0, topRight = 0] = node.openingTopRadii ?? [0.15, 0.15]
return normalizeTopCornerRadii(
{
topLeft: Math.max(topLeft, 0),
topRight: Math.max(topRight, 0),
},
width,
height,
)
}
const maxRadius = Math.min(width / 2, height)
const radius = Math.min(Math.max(node.cornerRadius ?? 0.15, 0), maxRadius)
return { topLeft: radius, topRight: radius }
}
function createRoundedTopShape(
left: number,
right: number,
bottom: number,
top: number,
radii: TopCornerRadii,
) {
const shape = new THREE.Shape()
const { topLeft, topRight } = normalizeTopCornerRadii(radii, right - left, top - bottom)
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, top - topRight)
if (topRight > 1e-6) {
shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false)
} else {
shape.lineTo(right, top)
}
shape.lineTo(left + topLeft, top)
if (topLeft > 1e-6) {
shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false)
} else {
shape.lineTo(left, top)
}
shape.lineTo(left, bottom)
shape.closePath()
return shape
}
function createRoundedDoorFrameShape(
width: number,
height: number,
frameThickness: number,
radii: TopCornerRadii,
) {
const halfWidth = width / 2
const bottom = -height / 2
const top = height / 2
const outerRadii = normalizeTopCornerRadii(radii, width, height)
const outer = createRoundedTopShape(-halfWidth, halfWidth, bottom, top, outerRadii)
const inset = Math.min(frameThickness, width / 2 - 0.005, height - 0.005)
if (inset <= 0.001) return outer
const innerLeft = -halfWidth + inset
const innerRight = halfWidth - inset
const innerTop = top - inset
const innerRadii = normalizeTopCornerRadii(
{
topLeft: Math.max(outerRadii.topLeft - inset, 0),
topRight: Math.max(outerRadii.topRight - inset, 0),
},
innerRight - innerLeft,
innerTop - bottom,
)
const holeShape = createRoundedTopShape(innerLeft, innerRight, bottom, innerTop, innerRadii)
const hole = new THREE.Path(holeShape.getPoints(32).reverse())
outer.holes.push(hole)
return outer
}
function shapeToReversedPath(shape: THREE.Shape) {
return new THREE.Path(shape.getPoints(40).reverse())
}
function createRoundedLeafFrameShape(
width: number,
bottom: number,
top: number,
radii: TopCornerRadii,
insetX: number,
insetY: number,
) {
const halfWidth = width / 2
const outerRadii = normalizeTopCornerRadii(radii, width, top - bottom)
const outer = createRoundedTopShape(-halfWidth, halfWidth, bottom, top, outerRadii)
const innerLeft = -halfWidth + insetX
const innerRight = halfWidth - insetX
const innerBottom = bottom + insetY
const innerTop = top - insetY
if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer
const innerRadii = normalizeTopCornerRadii(
{
topLeft: Math.max(outerRadii.topLeft - Math.max(insetX, insetY), 0),
topRight: Math.max(outerRadii.topRight - Math.max(insetX, insetY), 0),
},
innerRight - innerLeft,
innerTop - innerBottom,
)
outer.holes.push(
shapeToReversedPath(
createRoundedTopShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii),
),
)
return outer
}
function createRoundedClippedLeafFrameShape(
left: number,
right: number,
bottom: number,
top: number,
fullLeft: number,
fullRight: number,
radii: TopCornerRadii,
insetX: number,
insetY: number,
) {
const outerRadii = normalizeTopCornerRadii(radii, fullRight - fullLeft, top - bottom)
const outer = createTopClippedRectShape(left, right, bottom, top, (x) =>
getRoundedBoundaryYAtX(x, fullLeft, fullRight, top, {
topLeft: outerRadii.topLeft,
topRight: outerRadii.topRight,
}),
)
if (!outer) return null
const innerLeft = left + insetX
const innerRight = right - insetX
const innerBottom = bottom + insetY
const innerTop = top - insetY
if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer
const innerFullLeft = fullLeft + insetX
const innerFullRight = fullRight - insetX
const innerRadii = normalizeTopCornerRadii(
{
topLeft: Math.max(outerRadii.topLeft - Math.max(insetX, insetY), 0),
topRight: Math.max(outerRadii.topRight - Math.max(insetX, insetY), 0),
},
innerFullRight - innerFullLeft,
innerTop - innerBottom,
)
const holeShape = createTopClippedRectShape(innerLeft, innerRight, innerBottom, innerTop, (x) =>
getRoundedBoundaryYAtX(x, innerFullLeft, innerFullRight, innerTop, {
topLeft: innerRadii.topLeft,
topRight: innerRadii.topRight,
}),
)
if (holeShape) outer.holes.push(shapeToReversedPath(holeShape))
return outer
}
function createArchedLeafFrameShape(
width: number,
bottom: number,
top: number,
archHeight: number,
insetX: number,
insetY: number,
) {
const halfWidth = width / 2
const outer = createArchShape(-halfWidth, halfWidth, bottom, top, archHeight)
const innerLeft = -halfWidth + insetX
const innerRight = halfWidth - insetX
const innerBottom = bottom + insetY
const innerTop = top - insetY
if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer
const innerArchHeight = getClampedArchHeight(
innerRight - innerLeft,
innerTop - innerBottom,
Math.max(archHeight - insetY, 0.01),
)
outer.holes.push(
shapeToReversedPath(
createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight),
),
)
return outer
}
function createArchedClippedLeafFrameShape(
left: number,
right: number,
bottom: number,
top: number,
fullLeft: number,
fullRight: number,
archHeight: number,
insetX: number,
insetY: number,
) {
const fullCenterX = (fullLeft + fullRight) / 2
const fullHalfWidth = (fullRight - fullLeft) / 2
const springY = top - archHeight
const outer = createTopClippedRectShape(left, right, bottom, top, (x) =>
getArchBoundaryY(x - fullCenterX, fullHalfWidth, springY, archHeight),
)
if (!outer) return null
const innerLeft = left + insetX
const innerRight = right - insetX
const innerBottom = bottom + insetY
const innerTop = top - insetY
if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer
const innerFullLeft = fullLeft + insetX
const innerFullRight = fullRight - insetX
const innerArchHeight = getClampedArchHeight(
innerFullRight - innerFullLeft,
innerTop - innerBottom,
Math.max(archHeight - insetY, 0.01),
)
const innerFullCenterX = (innerFullLeft + innerFullRight) / 2
const innerFullHalfWidth = (innerFullRight - innerFullLeft) / 2
const innerSpringY = innerTop - innerArchHeight
const holeShape = createTopClippedRectShape(innerLeft, innerRight, innerBottom, innerTop, (x) =>
getArchBoundaryY(x - innerFullCenterX, innerFullHalfWidth, innerSpringY, innerArchHeight),
)
if (holeShape) outer.holes.push(shapeToReversedPath(holeShape))
return outer
}
function createOneSidedArchLeafFrameShape(
left: number,
right: number,
bottom: number,
top: number,
archHeight: number,
insetX: number,
insetY: number,
curvedSide: 'left' | 'right',
) {
const outer = createTopClippedRectShape(left, right, bottom, top, (x) =>
getOneSidedArchBoundaryYAtX(x, left, right, top, archHeight, curvedSide),
)
if (!outer) return null
const innerLeft = left + insetX
const innerRight = right - insetX
const innerBottom = bottom + insetY
const innerTop = top - insetY
if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer
const innerArchHeight = getClampedArchHeight(
innerRight - innerLeft,
innerTop - innerBottom,
Math.max(archHeight - insetY, 0.01),
)
const holeShape = createTopClippedRectShape(innerLeft, innerRight, innerBottom, innerTop, (x) =>
getOneSidedArchBoundaryYAtX(x, innerLeft, innerRight, innerTop, innerArchHeight, curvedSide),
)
if (holeShape) outer.holes.push(shapeToReversedPath(holeShape))
return outer
}
function createTopClippedRectShape(
left: number,
right: number,
bottom: number,
top: number,
getBoundaryY: (x: number) => number,
) {
const segments = 20
const points: { x: number; y: number }[] = []
for (let index = 0; index <= segments; index += 1) {
const t = index / segments
const x = right + (left - right) * t
const y = Math.min(top, getBoundaryY(x))
if (y > bottom + 0.001) points.push({ x, y })
}
if (points.length < 2) return null
const shape = new THREE.Shape()
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
for (const point of points) {
shape.lineTo(point.x, point.y)
}
shape.closePath()
return shape
}
function disposeObject(object: THREE.Object3D) {
object.traverse((child) => {
if (child instanceof THREE.Mesh) child.geometry.dispose()
})
}
function addLeafSegmentContent({
addLeafBox,
addLeafShape,
leafWidth,
leafHeight,
leafCenterX,
leafCenterY,
leafDepth,
segments,
contentPadding,
keepFrameWhenEmpty = false,
renderPerimeterFrame = true,
openingShape = 'rectangle',
openingTopRadii,
archHeight,
archOuterSide,
}: {
addLeafBox: (
material: THREE.Material,
w: number,
h: number,
d: number,
x: number,
y: number,
z: number,
) => void
addLeafShape?: (material: THREE.Material, shape: THREE.Shape, depth: number) => void
leafWidth: number
leafHeight: number
leafCenterX: number
leafCenterY: number
leafDepth: number
segments: DoorNode['segments']
contentPadding: DoorNode['contentPadding']
keepFrameWhenEmpty?: boolean
renderPerimeterFrame?: boolean
openingShape?: DoorNode['openingShape']
openingTopRadii?: TopCornerRadii
archHeight?: number
archOuterSide?: 'left' | 'right'
}) {
const hasLeafContent = segments.some((seg) => seg.type !== 'empty')
const shouldRenderFrame = hasLeafContent || keepFrameWhenEmpty
const cpX = contentPadding[0]
const cpY = contentPadding[1]
if (renderPerimeterFrame && shouldRenderFrame && cpY > 0) {
currentDoorSlot = 'panel'
addLeafBox(
baseMaterial,
leafWidth,
cpY,
leafDepth,
leafCenterX,
leafCenterY + leafHeight / 2 - cpY / 2,
0,
)
addLeafBox(
baseMaterial,
leafWidth,
cpY,
leafDepth,
leafCenterX,
leafCenterY - leafHeight / 2 + cpY / 2,
0,
)
}
if (renderPerimeterFrame && shouldRenderFrame && cpX > 0) {
const innerH = leafHeight - 2 * cpY
currentDoorSlot = 'panel'
addLeafBox(
baseMaterial,
cpX,
innerH,
leafDepth,
leafCenterX - leafWidth / 2 + cpX / 2,
leafCenterY,
0,
)
addLeafBox(
baseMaterial,
cpX,
innerH,
leafDepth,
leafCenterX + leafWidth / 2 - cpX / 2,
leafCenterY,
0,
)
}
const contentW = leafWidth - 2 * cpX
const contentH = leafHeight - 2 * cpY
const totalRatio = segments.reduce((sum, s) => sum + s.heightRatio, 0)
const contentTop = leafCenterY + contentH / 2
let segY = contentTop
const leafLeft = leafCenterX - leafWidth / 2
const leafRight = leafCenterX + leafWidth / 2
const leafTop = leafCenterY + leafHeight / 2
const hasShapedTop = openingShape === 'rounded' || openingShape === 'arch'
const clampedLeafArchHeight =
openingShape === 'arch' ? getClampedArchHeight(leafWidth, leafHeight, archHeight) : 0
const leafSpringY = leafTop - clampedLeafArchHeight
const topBoundaryAtX = (x: number) => {
if (openingShape === 'rounded' && openingTopRadii) {
return getRoundedBoundaryYAtX(x, leafLeft, leafRight, leafTop, {
topLeft: openingTopRadii.topLeft,
topRight: openingTopRadii.topRight,
})
}
if (openingShape === 'arch') {
if (archOuterSide) {
return getOneSidedArchBoundaryYAtX(
x,
leafLeft,
leafRight,
leafTop,
clampedLeafArchHeight,
archOuterSide,
)
}
return getArchBoundaryY(x - leafCenterX, leafWidth / 2, leafSpringY, clampedLeafArchHeight)
}
return leafTop
}
for (const seg of segments) {
const segH = (seg.heightRatio / totalRatio) * contentH
const segCenterY = segY - segH / 2
const segTop = segY
const segBottom = segY - segH
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const usableW = contentW - (numCols - 1) * seg.dividerThickness
const colWidths = seg.columnRatios.map((r) => (r / colSum) * usableW)
const colXCenters: number[] = []
let cx = leafCenterX - contentW / 2
for (let c = 0; c < numCols; c++) {
colXCenters.push(cx + colWidths[c]! / 2)
cx += colWidths[c]!
if (c < numCols - 1) cx += seg.dividerThickness
}
if (seg.type !== 'empty') {
cx = leafCenterX - contentW / 2
currentDoorSlot = 'panel'
for (let c = 0; c < numCols - 1; c++) {
cx += colWidths[c]!
const dividerLeft = cx
const dividerRight = cx + seg.dividerThickness
const dividerShape =
hasShapedTop && addLeafShape
? createTopClippedRectShape(
dividerLeft,
dividerRight,
segBottom,
segTop,
topBoundaryAtX,
)
: null
if (dividerShape && addLeafShape) {
addLeafShape(baseMaterial, dividerShape, leafDepth + 0.001)
} else {
addLeafBox(
baseMaterial,
seg.dividerThickness,
segH,
leafDepth + 0.001,
cx + seg.dividerThickness / 2,
segCenterY,
0,
)
}
cx += seg.dividerThickness
}
}
for (let c = 0; c < numCols; c++) {
const colW = colWidths[c]!
const colX = colXCenters[c]!
if (seg.type === 'glass') {
currentDoorSlot = 'glass'
const glassDepth = Math.max(0.004, leafDepth * 0.15)
const segmentLeft = colX - colW / 2
const segmentRight = colX + colW / 2
const glassShape =
hasShapedTop && addLeafShape
? createTopClippedRectShape(
segmentLeft,
segmentRight,
segBottom,
segTop,
topBoundaryAtX,
)
: null
if (glassShape && addLeafShape) {
addLeafShape(glassMaterial, glassShape, glassDepth)
} else {
addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0)
}
} else if (seg.type === 'panel') {
currentDoorSlot = 'panel'
const segmentLeft = colX - colW / 2
const segmentRight = colX + colW / 2
const outerPanelShape =
hasShapedTop && addLeafShape
? createTopClippedRectShape(
segmentLeft,
segmentRight,
segBottom,
segTop,
topBoundaryAtX,
)
: null
if (outerPanelShape && addLeafShape) {
addLeafShape(baseMaterial, outerPanelShape, leafDepth)
} else {
addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0)
}
const panelW = colW - 2 * seg.panelInset
const panelH = segH - 2 * seg.panelInset
if (panelW > 0.01 && panelH > 0.01) {
const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth)
const panelZ = leafDepth / 2 + effectiveDepth / 2
const insetLeft = colX - panelW / 2
const insetRight = colX + panelW / 2
const insetTop = segTop - seg.panelInset
const insetBottom = segBottom + seg.panelInset
const innerPanelShape =
hasShapedTop && addLeafShape
? createTopClippedRectShape(
insetLeft,
insetRight,
insetBottom,
insetTop,
topBoundaryAtX,
)
: null
if (innerPanelShape && addLeafShape) {
addLeafShape(baseMaterial, innerPanelShape, effectiveDepth)
} else {
addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ)
}
}
}
}
segY -= segH
}
}
function addDoorLeaf(
mesh: THREE.Mesh,
{
leafWidth,
leafHeight,
leafCenterX,
leafCenterY,
leafDepth,
hingeX,
hingeSide,
swingRotation,
openRotationY,
segments,
contentPadding,
handle,
handleBothSides = false,
handleHeight,
handleSide,
doorCloser,
panicBar,
panicBarHeight,
doorHeight,
openingShape,
openingTopRadii,
archHeight,
roundedBoundary,
archedBoundary,
archOuterSide,
}: {
leafWidth: number
leafHeight: number
leafCenterX: number
leafCenterY: number
leafDepth: number
hingeX: number
hingeSide: 'left' | 'right'
swingRotation: number
// Leaf rotation (radians, about the hinge Y axis) at fully-open. The GLB
// exporter reads this off the leaf group to bake an open/close clip; it is
// the kinematic endpoint, independent of the current `swingRotation`.
openRotationY: number
segments: DoorNode['segments']
contentPadding: DoorNode['contentPadding']
handle: boolean
handleBothSides?: boolean
handleHeight: number
handleSide: DoorNode['handleSide']
doorCloser: boolean
panicBar: boolean
panicBarHeight: number
doorHeight: number
openingShape: DoorNode['openingShape']
openingTopRadii: TopCornerRadii
archHeight: number
roundedBoundary?: {
fullLeft: number
fullRight: number
radii: TopCornerRadii
}
archedBoundary?: {
fullLeft: number
fullRight: number
archHeight: number
}
archOuterSide?: 'left' | 'right'
},
) {
const hasLeafContent = segments.some((seg) => seg.type !== 'empty')
const leafGroup = new THREE.Group()
leafGroup.position.set(hingeX, 0, 0)
leafGroup.rotation.y = swingRotation
// Marks this group as the swing leaf and records its fully-open angle so the
// GLB exporter can bake an open/close animation clip from a single pose. The
// exporter strips this marker before writing the file.
leafGroup.userData.pascalSwingLeaf = { axis: 'y', openRotationY }
mesh.add(leafGroup)
const addLeafBox = (
material: THREE.Material,
w: number,
h: number,
d: number,
x: number,
y: number,
z: number,
) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z)
const addLeafShape = (material: THREE.Material, shape: THREE.Shape, depth: number) =>
addShapeAt(leafGroup, material, shape, depth, -hingeX, 0, 0)
const localLeafCenterX = leafCenterX - hingeX
const leafBottom = leafCenterY - leafHeight / 2
const leafTop = leafCenterY + leafHeight / 2
const usesShapedLeafFrame = openingShape === 'rounded' || openingShape === 'arch'
if (usesShapedLeafFrame && hasLeafContent) {
currentDoorSlot = 'panel'
if (openingShape === 'rounded') {
const roundedLeafShape = roundedBoundary
? createRoundedClippedLeafFrameShape(
leafCenterX - leafWidth / 2,
leafCenterX + leafWidth / 2,
leafBottom,
leafTop,
roundedBoundary.fullLeft,
roundedBoundary.fullRight,
roundedBoundary.radii,
contentPadding[0],
contentPadding[1],
)
: createRoundedLeafFrameShape(
leafWidth,
leafBottom,
leafTop,
openingTopRadii,
contentPadding[0],
contentPadding[1],
)
if (roundedLeafShape) {
if (roundedBoundary) {
addLeafShape(baseMaterial, roundedLeafShape, leafDepth)
} else {
addShapeAt(leafGroup, baseMaterial, roundedLeafShape, leafDepth, localLeafCenterX, 0, 0)
}
}
} else if (openingShape === 'arch') {
const archedLeafShape = archOuterSide
? createOneSidedArchLeafFrameShape(
leafCenterX - leafWidth / 2,
leafCenterX + leafWidth / 2,
leafBottom,
leafTop,
archHeight,
contentPadding[0],
contentPadding[1],
archOuterSide,
)
: archedBoundary
? createArchedClippedLeafFrameShape(
leafCenterX - leafWidth / 2,
leafCenterX + leafWidth / 2,
leafBottom,
leafTop,
archedBoundary.fullLeft,
archedBoundary.fullRight,
archedBoundary.archHeight,
contentPadding[0],
contentPadding[1],
)
: createArchedLeafFrameShape(
leafWidth,
leafBottom,
leafTop,
getClampedArchHeight(leafWidth, leafHeight, archHeight),
contentPadding[0],
contentPadding[1],
)
if (archedLeafShape) {
if (archOuterSide || archedBoundary) {
addLeafShape(baseMaterial, archedLeafShape, leafDepth)
} else {
addShapeAt(leafGroup, baseMaterial, archedLeafShape, leafDepth, localLeafCenterX, 0, 0)
}
}
}
}
addLeafSegmentContent({
addLeafBox,
addLeafShape,
leafWidth,
leafHeight,
leafCenterX,
leafCenterY,
leafDepth,
segments,
contentPadding,
renderPerimeterFrame: !usesShapedLeafFrame,
openingShape,
openingTopRadii,
archHeight,
archOuterSide,
})
if (hasLeafContent && handle) {
currentDoorSlot = 'hardware'
const handleY = handleHeight - doorHeight / 2
const faceZ = leafDepth / 2
const handleX =
handleSide === 'right'
? leafCenterX + leafWidth / 2 - 0.045
: leafCenterX - leafWidth / 2 + 0.045
addLeafBox(hardwareMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005)
addLeafBox(hardwareMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025)
if (handleBothSides) {
addLeafBox(hardwareMaterial, 0.028, 0.14, 0.01, handleX, handleY, -faceZ - 0.005)
addLeafBox(hardwareMaterial, 0.022, 0.1, 0.035, handleX, handleY, -faceZ - 0.025)
}
}
if (hasLeafContent && doorCloser) {
currentDoorSlot = 'hardware'
const closerY = leafCenterY + leafHeight / 2 - 0.04
addLeafBox(hardwareMaterial, 0.28, 0.055, 0.055, leafCenterX, closerY, leafDepth / 2 + 0.03)
addLeafBox(
hardwareMaterial,
0.14,
0.015,
0.015,
leafCenterX + leafWidth / 4,
closerY + 0.025,
leafDepth / 2 + 0.015,
)
}
if (hasLeafContent && panicBar) {
currentDoorSlot = 'hardware'
const barY = panicBarHeight - doorHeight / 2
addLeafBox(
hardwareMaterial,
leafWidth * 0.72,
0.04,
0.055,
leafCenterX,
barY,
leafDepth / 2 + 0.03,
)
}
if (hasLeafContent) {
currentDoorSlot = 'hardware'
const hingeMarkerX = hingeSide === 'right' ? hingeX - 0.012 : hingeX + 0.012
const hingeH = 0.1
const hingeW = 0.024
const hingeD = leafDepth + 0.016
addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0)
addBox(
mesh,
hardwareMaterial,
hingeW,
hingeH,
hingeD,
hingeMarkerX,
(leafBottom + leafTop) / 2,
0,
)
addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0)
}
}
function addFoldingDoor(
mesh: THREE.Mesh,
{
insideWidth,
leafHeight,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
leafCount,
doorHeight,
handleHeight,
segments,
contentPadding,
}: {
insideWidth: number
leafHeight: number
leafCenterY: number
leafDepth: number
frameThickness: number
frameDepth: number
operationState: number
leafCount: DoorNode['leafCount']
doorHeight: number
handleHeight: number
segments: DoorNode['segments']
contentPadding: DoorNode['contentPadding']
},
) {
const panelCount = leafCount === 2 ? 2 : 4
const foldAmount = clampDoorOperationState(operationState)
const panelLength = insideWidth / panelCount
const foldAngle = Math.PI * 0.44 * foldAmount
currentDoorSlot = 'hardware'
addBox(
mesh,
hardwareMaterial,
insideWidth,
Math.min(frameThickness * 0.5, 0.025),
Math.max(frameDepth * 0.45, 0.035),
0,
leafCenterY + leafHeight / 2 - 0.018,
0,
)
const vertices: Array<{ x: number; z: number }> = [{ x: -insideWidth / 2, z: 0 }]
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 addFoldingLeafBox = (
material: THREE.Material,
w: number,
h: number,
d: number,
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,
)
}
addLeafSegmentContent({
addLeafBox: addFoldingLeafBox,
leafWidth: Math.max(0.08, panelLength),
leafHeight,
leafCenterX: 0,
leafCenterY,
leafDepth,
segments,
contentPadding,
keepFrameWhenEmpty: true,
})
currentDoorSlot = undefined
for (const point of [start, end]) {
addBox(
mesh,
revealMaterial,
0.018,
leafHeight * 0.92,
leafDepth + 0.016,
point.x,
leafCenterY,
point.z,
)
}
}
const handlePoint = vertices[vertices.length - 1]!
const handleY = handleHeight - doorHeight / 2
currentDoorSlot = 'hardware'
addBox(
mesh,
hardwareMaterial,
0.035,
0.16,
leafDepth + 0.035,
handlePoint.x - 0.035,
handleY,
handlePoint.z + 0.045,
)
addBox(
mesh,
hardwareMaterial,
0.035,
0.16,
leafDepth + 0.035,
handlePoint.x - 0.035,
handleY,
handlePoint.z - 0.045,
)
}
function addPocketDoor(
mesh: THREE.Mesh,
{
insideWidth,
leafHeight,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
slideDirection,
doorHeight,
handleHeight,
segments,
contentPadding,
}: {
insideWidth: number
leafHeight: number
leafCenterY: number
leafDepth: number
frameThickness: number
frameDepth: number
operationState: number
slideDirection: DoorNode['slideDirection']
doorHeight: number
handleHeight: number
segments: DoorNode['segments']
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)
currentDoorSlot = 'hardware'
addBox(
mesh,
hardwareMaterial,
insideWidth * 2,
Math.min(frameThickness * 0.45, 0.024),
Math.max(frameDepth * 0.38, 0.03),
slideSign * (insideWidth / 2),
topY - 0.018,
0,
)
currentDoorSlot = undefined
addBox(
mesh,
revealMaterial,
insideWidth * 0.9,
0.018,
Math.max(frameDepth * 0.32, 0.026),
pocketCenterX,
topY - 0.055,
0,
)
addBox(
mesh,
revealMaterial,
0.018,
leafHeight * 0.94,
leafDepth + 0.014,
slideSign * insideWidth * 0.5,
leafCenterY,
0,
)
const addPocketLeafBox = (
material: THREE.Material,
w: number,
h: number,
d: number,
x: number,
y: number,
z: number,
) => addBox(mesh, material, w, h, d, x, y, z)
addLeafSegmentContent({
addLeafBox: addPocketLeafBox,
leafWidth,
leafHeight,
leafCenterX,
leafCenterY,
leafDepth,
segments,
contentPadding,
})
currentDoorSlot = 'hardware'
addBox(
mesh,
hardwareMaterial,
0.03,
0.18,
leafDepth + 0.03,
handleX,
handleY,
leafDepth / 2 + 0.02,
)
addBox(
mesh,
hardwareMaterial,
0.03,
0.18,
leafDepth + 0.03,
handleX,
handleY,
-leafDepth / 2 - 0.02,
)
}
function addBarnDoor(
mesh: THREE.Mesh,
{
insideWidth,
leafHeight,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
slideDirection,
doorHeight,
handleHeight,
segments,
contentPadding,
}: {
insideWidth: number
leafHeight: number
leafCenterY: number
leafDepth: number
frameThickness: number
frameDepth: number
operationState: number
slideDirection: DoorNode['slideDirection']
doorHeight: number
handleHeight: number
segments: DoorNode['segments']
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)
const wheelY = trackY - 0.075
currentDoorSlot = 'hardware'
addBox(mesh, hardwareMaterial, railLength, 0.035, 0.035, railCenterX, trackY, 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 addBarnLeafBox = (
material: THREE.Material,
w: number,
h: number,
d: number,
x: number,
y: number,
z: number,
) => addBox(mesh, material, w, h, d, x, y, faceZ + z)
addLeafSegmentContent({
addLeafBox: addBarnLeafBox,
leafWidth,
leafHeight,
leafCenterX,
leafCenterY,
leafDepth,
segments,
contentPadding,
keepFrameWhenEmpty: true,
})
currentDoorSlot = undefined
addRotatedBox(
mesh,
revealMaterial,
0.018,
leafHeight * 0.86,
0.012,
leafCenterX,
leafCenterY,
faceZ + leafDepth / 2 + 0.014,
-0.52,
)
addRotatedBox(
mesh,
revealMaterial,
0.018,
leafHeight * 0.86,
0.012,
leafCenterX,
leafCenterY,
faceZ + leafDepth / 2 + 0.014,
0.52,
)
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,
)
}
currentDoorSlot = 'hardware'
addBox(
mesh,
hardwareMaterial,
0.032,
0.22,
leafDepth + 0.034,
handleX,
handleY,
faceZ + leafDepth / 2 + 0.02,
)
addBox(
mesh,
hardwareMaterial,
0.032,
0.22,
leafDepth + 0.034,
handleX,
handleY,
faceZ - leafDepth / 2 - 0.02,
)
}
function addSlidingDoor(
mesh: THREE.Mesh,
{
insideWidth,
leafHeight,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
slideDirection,
doorHeight,
handleHeight,
segments,
contentPadding,
}: {
insideWidth: number
leafHeight: number
leafCenterY: number
leafDepth: number
frameThickness: number
frameDepth: number
operationState: number
slideDirection: DoorNode['slideDirection']
doorHeight: number
handleHeight: number
segments: DoorNode['segments']
contentPadding: DoorNode['contentPadding']
},
) {
const openAmount = clampDoorOperationState(operationState)
const activeOnRight = slideDirection === 'left'
const fixedSign = activeOnRight ? -1 : 1
const activeSign = activeOnRight ? 1 : -1
const panelWidth = insideWidth * 0.54
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)
currentDoorSlot = 'hardware'
addBox(
mesh,
hardwareMaterial,
insideWidth,
0.024,
Math.max(frameDepth * 0.32, 0.026),
0,
railY,
0,
)
addBox(
mesh,
hardwareMaterial,
insideWidth,
0.018,
Math.max(frameDepth * 0.28, 0.022),
0,
-leafHeight / 2 + 0.04,
0,
)
const addFixedPanelBox = (
material: THREE.Material,
w: number,
h: number,
d: number,
x: number,
y: number,
z: number,
) => addBox(mesh, material, w, h, d, x + fixedX, y, z + backZ)
const addActivePanelBox = (
material: THREE.Material,
w: number,
h: number,
d: number,
x: number,
y: number,
z: number,
) => addBox(mesh, material, w, h, d, x + activeX, y, z + frontZ)
addLeafSegmentContent({
addLeafBox: addFixedPanelBox,
leafWidth: panelWidth,
leafHeight: panelHeight,
leafCenterX: 0,
leafCenterY,
leafDepth,
segments,
contentPadding,
keepFrameWhenEmpty: true,
})
addLeafSegmentContent({
addLeafBox: addActivePanelBox,
leafWidth: panelWidth,
leafHeight: panelHeight,
leafCenterX: 0,
leafCenterY,
leafDepth,
segments,
contentPadding,
keepFrameWhenEmpty: true,
})
currentDoorSlot = 'hardware'
addBox(
mesh,
hardwareMaterial,
0.032,
0.24,
0.016,
handleX,
handleY,
frontZ + leafDepth / 2 + 0.01,
)
addBox(
mesh,
hardwareMaterial,
0.032,
0.24,
0.016,
handleX,
handleY,
frontZ - leafDepth / 2 - 0.01,
)
}
function addGarageSectionalDoor(
mesh: THREE.Mesh,
{
insideWidth,
leafHeight,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
garagePanelCount,
}: {
insideWidth: number
leafHeight: number
leafCenterY: number
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
currentDoorSlot = 'hardware'
addBox(
mesh,
hardwareMaterial,
0.035,
Math.max(0.04, frameThickness * 0.75),
travelDepth,
-insideWidth / 2 + 0.035,
railY,
railZ,
)
addBox(
mesh,
hardwareMaterial,
0.035,
Math.max(0.04, frameThickness * 0.75),
travelDepth,
insideWidth / 2 - 0.035,
railY,
railZ,
)
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],
)
}
currentDoorSlot = 'panel'
addBoxWithRotation(
mesh,
baseMaterial,
insideWidth,
Math.max(0.04, panelHeight - panelGap),
leafDepth,
0,
y,
z,
[rotationX, 0, 0],
)
addSectionalTrim(revealOffset)
addSectionalTrim(-revealOffset)
}
currentDoorSlot = 'hardware'
addBox(mesh, hardwareMaterial, insideWidth, 0.032, Math.max(frameDepth * 0.36, 0.03), 0, railY, 0)
}
function addGarageRollupDoor(
mesh: THREE.Mesh,
{
insideWidth,
leafHeight,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
}: {
insideWidth: number
leafHeight: number
leafCenterY: number
leafDepth: number
frameThickness: number
frameDepth: number
operationState: number
},
) {
const openAmount = clampDoorOperationState(operationState)
const slatHeight = Math.max(0.055, Math.min(0.11, leafHeight / 22))
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
currentDoorSlot = 'hardware'
addBox(
mesh,
hardwareMaterial,
0.032,
leafHeight,
Math.max(frameDepth * 0.48, 0.035),
-insideWidth / 2 + 0.03,
leafCenterY,
0,
)
addBox(
mesh,
hardwareMaterial,
0.032,
leafHeight,
Math.max(frameDepth * 0.48, 0.035),
insideWidth / 2 - 0.03,
leafCenterY,
0,
)
if (visibleHeight > 0.01) {
currentDoorSlot = 'panel'
addBox(mesh, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, curtainCenterY, 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)
}
addBox(
mesh,
revealMaterial,
insideWidth - 0.04,
0.028,
leafDepth + 0.018,
0,
topY - visibleHeight,
leafDepth / 2 + 0.004,
)
}
currentDoorSlot = 'panel'
const drum = new THREE.Mesh(
new THREE.CylinderGeometry(drumMaxRadius, drumMaxRadius, insideWidth + frameThickness, 36),
baseMaterial,
)
drum.position.set(0, drumY, drumZ)
drum.rotation.z = Math.PI / 2
tagDoorSlot(drum)
mesh.add(drum)
currentDoorSlot = 'hardware'
addBox(
mesh,
hardwareMaterial,
insideWidth + frameThickness,
0.026,
Math.max(frameDepth * 0.52, 0.04),
0,
topY + 0.02,
0,
)
}
function addGarageTiltupDoor(
mesh: THREE.Mesh,
{
insideWidth,
leafHeight,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
}: {
insideWidth: number
leafHeight: number
leafCenterY: number
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)
const railLength = Math.max(leafHeight * 0.72, 1.2)
const railY = hingeY - frameThickness * 0.35
const railZ = -railLength / 2
currentDoorSlot = 'hardware'
addBox(
mesh,
hardwareMaterial,
0.03,
Math.max(frameThickness * 0.7, 0.035),
railLength,
-insideWidth / 2 + 0.04,
railY,
railZ,
)
addBox(
mesh,
hardwareMaterial,
0.03,
Math.max(frameThickness * 0.7, 0.035),
railLength,
insideWidth / 2 - 0.04,
railY,
railZ,
)
currentDoorSlot = 'panel'
addBoxWithRotation(
mesh,
baseMaterial,
insideWidth,
leafHeight,
leafDepth,
0,
panelCenterY,
panelCenterZ,
[-angle, 0, 0],
)
const insetWidth = Math.max(0.1, insideWidth - 0.22)
const insetHeight = Math.max(0.1, leafHeight - 0.28)
const trimDepth = 0.012
const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006
const addTiltupTrim = (localX: number, localY: number, trimWidth: number, trimHeight: number) => {
currentDoorSlot = undefined
addBoxWithRotation(
mesh,
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],
)
}
addTiltupTrim(0, insetHeight / 2, insetWidth, 0.018)
addTiltupTrim(0, -insetHeight / 2, insetWidth, 0.018)
addTiltupTrim(-insetWidth / 2, 0, 0.018, insetHeight)
addTiltupTrim(insetWidth / 2, 0, 0.018, insetHeight)
currentDoorSlot = 'hardware'
addBox(
mesh,
hardwareMaterial,
insideWidth,
0.026,
Math.max(frameDepth * 0.4, 0.035),
0,
hingeY,
0,
)
}
function getEffectiveOpeningShape(node: DoorNode): DoorNode['openingShape'] {
return node.doorType === 'folding' ||
node.doorType === 'pocket' ||
node.doorType === 'barn' ||
node.doorType === 'sliding'
? 'rectangle'
: (node.openingShape ?? 'rectangle')
}
function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
const node = normalizeDoorNodeForRender(rawNode)
currentDoorSlot = undefined
// Root mesh is an invisible hitbox; all visuals live in child meshes
mesh.geometry.dispose()
mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth)
mesh.material = hitboxMaterial
// Sync transform from node (React may lag behind the system by a frame during drag)
mesh.position.set(node.position[0], node.position[1], node.position[2])
mesh.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2])
// Dispose and remove all old visual children; preserve 'cutout'
for (const child of [...mesh.children]) {
if (child.name === 'cutout') continue
disposeObject(child)
mesh.remove(child)
}
// Point the builder-facing materials at this door's slot overrides for the
// duration of its build (recomputed per node, so the next door resets cleanly
// without a restore). Reveal keeps its own material.
baseMaterial = resolveDoorSlotMaterial(node, 'panel')
frameMaterial = resolveDoorSlotMaterial(node, 'frame')
glassMaterial = resolveDoorSlotMaterial(node, 'glass')
hardwareMaterial = resolveDoorSlotMaterial(node, 'hardware')
const {
width,
height,
openingKind,
openingShape: rawOpeningShape,
frameThickness,
frameDepth,
threshold,
thresholdHeight,
segments,
handle,
handleHeight,
handleSide,
doorCloser,
panicBar,
panicBarHeight,
contentPadding,
hingesSide,
swingDirection,
swingAngle: nodeSwingAngle = 0,
doorType = 'hinged',
operationState: nodeOperationState = 0,
leafCount = 1,
slideDirection = 'left',
garagePanelCount = 4,
} = node
const openingShape = getEffectiveOpeningShape(node) ?? rawOpeningShape
const runtimeDoorState = useInteractive.getState().doors[node.id]
const swingAngle = runtimeDoorState?.swingAngle ?? nodeSwingAngle
const operationState = runtimeDoorState?.operationState ?? nodeOperationState
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle))
if (openingKind === 'opening') {
syncDoorCutout(node, mesh)
return
}
const insideWidth = width - 2 * frameThickness
const leafH = height - frameThickness // only top frame
const leafDepth = 0.04
const leafCenterY = -frameThickness / 2
const swingDirectionSign = swingDirection === 'inward' ? 1 : -1
// ── Frame members ──
currentDoorSlot = 'frame'
if (openingShape === 'arch') {
const frameBottom = -height / 2
const frameTop = height / 2
const frameArchHeight = getClampedArchHeight(width, height, node.archHeight)
const frameSpringY = frameTop - frameArchHeight
const frameInnerTopY = frameTop - frameThickness
const frameInnerSpringY = Math.min(frameSpringY + frameThickness, frameInnerTopY)
const useShallowHeadBar = frameArchHeight <= frameThickness * 2
const frameHeadBottomY = useShallowHeadBar ? frameSpringY - frameThickness : frameSpringY
const postHeight = Math.max(frameHeadBottomY - frameBottom, 0.01)
addBox(
mesh,
frameMaterial,
frameThickness,
postHeight,
frameDepth,
-width / 2 + frameThickness / 2,
frameBottom + postHeight / 2,
0,
)
addBox(
mesh,
frameMaterial,
frameThickness,
postHeight,
frameDepth,
width / 2 - frameThickness / 2,
frameBottom + postHeight / 2,
0,
)
addShape(
mesh,
frameMaterial,
useShallowHeadBar
? createArchHeadBarShape(width, frameHeadBottomY, frameSpringY, frameTop)
: createArchBandShape(
width,
frameSpringY,
frameTop,
frameInnerSpringY,
frameInnerTopY,
frameThickness,
),
frameDepth,
)
} else if (openingShape === 'rounded') {
addShape(
mesh,
frameMaterial,
createRoundedDoorFrameShape(
width,
height,
frameThickness,
getDoorTopRadii(node, width, height),
),
frameDepth,
)
} else {
// Left post — full height
addBox(
mesh,
frameMaterial,
frameThickness,
height,
frameDepth,
-width / 2 + frameThickness / 2,
0,
0,
)
// Right post — full height
addBox(
mesh,
frameMaterial,
frameThickness,
height,
frameDepth,
width / 2 - frameThickness / 2,
0,
0,
)
// Head (top bar) — full width
addBox(
mesh,
frameMaterial,
width,
frameThickness,
frameDepth,
0,
height / 2 - frameThickness / 2,
0,
)
}
// ── Threshold (inside the frame) ──
if (threshold) {
currentDoorSlot = 'frame'
addBox(
mesh,
frameMaterial,
insideWidth,
thresholdHeight,
frameDepth,
0,
-height / 2 + thresholdHeight / 2,
0,
)
}
if (doorType === 'garage-sectional') {
addGarageSectionalDoor(mesh, {
insideWidth,
leafHeight: leafH,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
garagePanelCount,
})
} else if (doorType === 'garage-rollup') {
addGarageRollupDoor(mesh, {
insideWidth,
leafHeight: leafH,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
})
} else if (doorType === 'garage-tiltup') {
addGarageTiltupDoor(mesh, {
insideWidth,
leafHeight: leafH,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
})
} else if (doorType === 'folding') {
addFoldingDoor(mesh, {
insideWidth,
leafHeight: leafH,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
leafCount,
doorHeight: height,
handleHeight,
segments,
contentPadding,
})
} else if (doorType === 'pocket') {
addPocketDoor(mesh, {
insideWidth,
leafHeight: leafH,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
slideDirection,
doorHeight: height,
handleHeight,
segments,
contentPadding,
})
} else if (doorType === 'barn') {
addBarnDoor(mesh, {
insideWidth,
leafHeight: leafH,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
slideDirection,
doorHeight: height,
handleHeight,
segments,
contentPadding,
})
} else if (doorType === 'sliding') {
addSlidingDoor(mesh, {
insideWidth,
leafHeight: leafH,
leafCenterY,
leafDepth,
frameThickness,
frameDepth,
operationState,
slideDirection,
doorHeight: height,
handleHeight,
segments,
contentPadding,
})
} else if (doorType === 'double' || doorType === 'french') {
const doubleLeafW = insideWidth / 2
const fullLeafTopRadii = getDoorTopRadii(node, insideWidth, leafH)
const roundedBoundary =
openingShape === 'rounded'
? {
fullLeft: -insideWidth / 2,
fullRight: insideWidth / 2,
radii: fullLeafTopRadii,
}
: undefined
addDoorLeaf(mesh, {
leafWidth: doubleLeafW,
leafHeight: leafH,
leafCenterX: -insideWidth / 4,
leafCenterY,
leafDepth,
hingeX: -insideWidth / 2,
hingeSide: 'left',
swingRotation: -clampedSwingAngle * swingDirectionSign,
openRotationY: (-Math.PI / 2) * swingDirectionSign,
segments,
contentPadding,
handle,
handleBothSides: doorType === 'double' || doorType === 'french',
handleHeight,
handleSide: 'right',
doorCloser,
panicBar,
panicBarHeight,
doorHeight: height,
openingShape,
openingTopRadii:
openingShape === 'rounded'
? { topLeft: fullLeafTopRadii.topLeft, topRight: 0 }
: fullLeafTopRadii,
archHeight: node.archHeight ?? 0.45,
roundedBoundary,
archOuterSide: openingShape === 'arch' ? 'left' : undefined,
})
addDoorLeaf(mesh, {
leafWidth: doubleLeafW,
leafHeight: leafH,
leafCenterX: insideWidth / 4,
leafCenterY,
leafDepth,
hingeX: insideWidth / 2,
hingeSide: 'right',
swingRotation: clampedSwingAngle * swingDirectionSign,
openRotationY: (Math.PI / 2) * swingDirectionSign,
segments,
contentPadding,
handle,
handleBothSides: doorType === 'double' || doorType === 'french',
handleHeight,
handleSide: 'left',
doorCloser: false,
panicBar,
panicBarHeight,
doorHeight: height,
openingShape,
openingTopRadii:
openingShape === 'rounded'
? { topLeft: 0, topRight: fullLeafTopRadii.topRight }
: fullLeafTopRadii,
archHeight: node.archHeight ?? 0.45,
roundedBoundary,
archOuterSide: openingShape === 'arch' ? 'right' : undefined,
})
} else {
const hingeX = hingesSide === 'right' ? insideWidth / 2 : -insideWidth / 2
const hingeDirectionSign = hingesSide === 'right' ? 1 : -1
addDoorLeaf(mesh, {
leafWidth: insideWidth,
leafHeight: leafH,
leafCenterX: 0,
leafCenterY,
leafDepth,
hingeX,
hingeSide: hingesSide,
swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign,
openRotationY: (Math.PI / 2) * swingDirectionSign * hingeDirectionSign,
segments,
contentPadding,
handle,
handleBothSides: doorType === 'hinged',
handleHeight,
handleSide,
doorCloser,
panicBar,
panicBarHeight,
doorHeight: height,
openingShape,
openingTopRadii: getDoorTopRadii(node, insideWidth, leafH),
archHeight: node.archHeight ?? 0.45,
})
}
syncDoorCutout(node, mesh)
// Guard: some degenerate door configs can leave a child mesh with an
// empty (0-vertex) geometry — e.g. a zero-area extruded leaf frame.
// Submitting such a mesh trips a WebGPU error ("Vertex buffer slot 0
// … was not set" on a Draw(0, …)). Hide any empty mesh so it is never
// drawn (it would render nothing anyway).
hideEmptyGeometryMeshes(mesh)
}
function hideEmptyGeometryMeshes(root: THREE.Object3D) {
root.traverse((obj) => {
const child = obj as THREE.Mesh
if (!child.isMesh || !child.geometry) return
const position = child.geometry.getAttribute('position')
if (!position || position.count === 0) child.visible = false
})
}
function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
// ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) {
cutout = new THREE.Mesh()
cutout.name = 'cutout'
// The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it
// wins the scene raycast over the wall in front of the recessed door body —
// making it the selection AND paint hit target for the whole opening. The
// paint capability then re-raycasts the door's parts to find the slot.
mesh.add(cutout)
}
cutout.geometry.dispose()
const openingShape = getEffectiveOpeningShape(node)
if (openingShape === 'arch') {
cutout.geometry = new THREE.ExtrudeGeometry(
createArchShape(
-node.width / 2,
node.width / 2,
-node.height / 2,
node.height / 2,
getClampedArchHeight(node.width, node.height, node.archHeight),
),
{
depth: 1,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
} else if (openingShape === 'rounded') {
cutout.geometry = new THREE.ExtrudeGeometry(
createRoundedTopShape(
-node.width / 2,
node.width / 2,
-node.height / 2,
node.height / 2,
getDoorTopRadii(node, node.width, node.height),
),
{
depth: 1,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
} else {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
}
cutout.visible = false
}
/**
* Build a fresh door mesh for preview/ghost rendering.
* Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware).
*/
export function buildDoorPreviewMesh(node: DoorNode): THREE.Mesh {
const mesh = new THREE.Mesh()
updateDoorMesh(node, mesh)
return mesh
}