import { type AnyNodeId, getEffectiveNode, type SceneMaterial, type SceneMaterialId, sceneRegistry, useInteractive, useLiveNodeOverrides, useScene, type WindowNode, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import * as THREE from 'three' import { type ColorPreset, 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 }) let baseMaterial = getBaseMaterial() let glassMaterial: THREE.Material = defaultGlassMaterial // Per-frame viewer state, captured so the per-node mesh builder (which runs // outside React) can resolve each window's slot materials. let currentShading: RenderShading = 'rendered' let currentTextures = true let currentColorPreset: ColorPreset = 'clay' let currentSceneMaterials: Record | undefined export const CASEMENT_WINDOW_SASH_NAME = 'casement-window-sash' export const FRENCH_CASEMENT_LEFT_SASH_NAME = 'french-casement-left-sash' export const FRENCH_CASEMENT_RIGHT_SASH_NAME = 'french-casement-right-sash' export const SLIDING_WINDOW_ACTIVE_PANEL_NAME = 'sliding-window-active-panel' export const SINGLE_HUNG_ACTIVE_SASH_NAME = 'single-hung-active-sash' export const DOUBLE_HUNG_TOP_SASH_NAME = 'double-hung-top-sash' export const DOUBLE_HUNG_BOTTOM_SASH_NAME = 'double-hung-bottom-sash' export const LOUVERED_WINDOW_SLATS_NAME = 'louvered-window-slats' export const AWNING_WINDOW_SASH_NAME = 'awning-window-sash' export const HOPPER_WINDOW_SASH_NAME = 'hopper-window-sash' const MAX_WINDOW_REBUILDS_PER_FRAME = 16 const WINDOW_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WINDOW_REBUILDS_PER_FRAME const WINDOW_PROGRESSIVE_TIME_BUDGET_MS = 8 export const WindowSystem = () => { 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(null) // Subscribe so override-only updates re-run this component. Mirrors // WallSystem + DoorSystem. useLiveNodeOverrides((s) => s.overrides) baseMaterial = textures ? getBaseMaterial(shading) : createSurfaceRoleMaterial('joinery', colorPreset) glassMaterial = textures ? defaultGlassMaterial : createSurfaceRoleMaterial('glazing', colorPreset) 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 === 'window') { useScene.getState().dirtyNodes.add(node.id as AnyNodeId) } } }) // Editing a scene material a window slot references must rebuild that window // (window meshes are built by this system, not , so its // scene-material re-dirty doesn't cover them). useEffect(() => { const nodes = useScene.getState().nodes for (const node of Object.values(nodes)) { if (node?.type !== 'window') continue if (!nodeReferencesSceneMaterial(node)) continue useScene.getState().dirtyNodes.add(node.id as AnyNodeId) } }, [sceneMaterials]) useFrame(() => { if (dirtyNodes.size === 0) return baseMaterial = textures ? getBaseMaterial(shading) : createSurfaceRoleMaterial('joinery', colorPreset) glassMaterial = textures ? defaultGlassMaterial : createSurfaceRoleMaterial('glazing', colorPreset) currentShading = shading currentTextures = textures currentColorPreset = colorPreset currentSceneMaterials = sceneMaterials const nodes = useScene.getState().nodes const dirtyWindowIds: AnyNodeId[] = [] dirtyNodes.forEach((id) => { const node = nodes[id] if (!node || node.type !== 'window') return dirtyWindowIds.push(id as AnyNodeId) }) const useProgressiveWindowRebuilds = dirtyWindowIds.length > WINDOW_PROGRESSIVE_DIRTY_THRESHOLD const frameStartedAt = performance.now() let rebuiltWindowsThisFrame = 0 for (const id of dirtyWindowIds) { if (useProgressiveWindowRebuilds) { if (rebuiltWindowsThisFrame >= MAX_WINDOW_REBUILDS_PER_FRAME) { break } if ( rebuiltWindowsThisFrame > 0 && performance.now() - frameStartedAt >= WINDOW_PROGRESSIVE_TIME_BUDGET_MS ) { break } } const node = nodes[id] if (!node || node.type !== 'window') 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. const effectiveNode = getEffectiveNode(node as WindowNode) updateWindowMesh(effectiveNode, mesh) clearDirty(id as AnyNodeId) rebuiltWindowsThisFrame += 1 // Rebuild the parent wall so its cutout reflects the updated window geometry // Avoid triggering expensive wall CSG rebuilds while the window is being interactively moved/duplicated. // The editor tools will request a final wall rebuild on commit. const isTransient = !!(node.metadata as Record | null)?.isTransient if (!isTransient && effectiveNode.parentId) { useScene.getState().dirtyNodes.add(effectiveNode.parentId as AnyNodeId) } } }, 3) return null } // A window exposes two slots: `frame` (every joinery member) and `glass`. The // builders pass `baseMaterial` / `glassMaterial`, so tag each mesh by which one // it got — that's what `(nodeId, slotId)` paint resolves against. function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh { if (mesh.material === glassMaterial) mesh.userData.slotId = 'glass' else if (mesh.material === baseMaterial) mesh.userData.slotId = 'frame' return mesh } function nodeReferencesSceneMaterial(node: { slots?: Record }): 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 } function windowSlotDefault(slotId: 'frame' | 'glass'): THREE.Material { if (slotId === 'glass') { return currentTextures ? defaultGlassMaterial : createSurfaceRoleMaterial('glazing', currentColorPreset) } return currentTextures ? getBaseMaterial(currentShading) : createSurfaceRoleMaterial('joinery', currentColorPreset) } // Resolve a window's slot to a material: the `node.slots` override (colored mode // only) → the role/base default. Textures-off ignores overrides — the monochrome // escape hatch. function resolveWindowSlotMaterial(node: WindowNode, slotId: 'frame' | 'glass'): THREE.Material { const fallback = windowSlotDefault(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 m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) m.position.set(x, y, z) tagWindowSlot(m) parent.add(m) } function addShape( parent: THREE.Object3D, material: THREE.Material, shape: THREE.Shape, depth: number, z = 0, ) { const geometry = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false, curveSegments: 24, }) geometry.translate(0, 0, -depth / 2 + z) const mesh = new THREE.Mesh(geometry, material) tagWindowSlot(mesh) parent.add(mesh) } function disposeObjectGeometry(object: THREE.Object3D) { object.traverse((child) => { if (child instanceof THREE.Mesh) child.geometry.dispose() }) } function createRectShape(left: number, right: number, bottom: number, top: number) { const shape = new THREE.Shape() shape.moveTo(left, bottom) shape.lineTo(right, bottom) shape.lineTo(right, top) shape.lineTo(left, top) shape.closePath() return shape } type CornerRadii = { topLeft: number topRight: number bottomRight: number bottomLeft: number } function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii { const next = { ...radii } const scale = Math.min( 1, width / Math.max(next.topLeft + next.topRight, 1e-6), width / Math.max(next.bottomLeft + next.bottomRight, 1e-6), height / Math.max(next.topLeft + next.bottomLeft, 1e-6), height / Math.max(next.topRight + next.bottomRight, 1e-6), ) if (scale < 1) { next.topLeft *= scale next.topRight *= scale next.bottomRight *= scale next.bottomLeft *= scale } return next } function getWindowRoundedRadii(node: WindowNode, width: number, height: number): CornerRadii { if (node.openingRadiusMode === 'individual') { const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15] return normalizeCornerRadii( { topLeft: Math.max(topLeft, 0), topRight: Math.max(topRight, 0), bottomRight: Math.max(bottomRight, 0), bottomLeft: Math.max(bottomLeft, 0), }, width, height, ) } const maxRadius = Math.min(width / 2, height / 2) const radius = Math.min(Math.max(node.cornerRadius ?? 0.15, 0), maxRadius) return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius } } function insetCornerRadii(radii: CornerRadii, inset: number, width: number, height: number) { return normalizeCornerRadii( { topLeft: Math.max(radii.topLeft - inset, 0), topRight: Math.max(radii.topRight - inset, 0), bottomRight: Math.max(radii.bottomRight - inset, 0), bottomLeft: Math.max(radii.bottomLeft - inset, 0), }, width, height, ) } function createRoundedShape( left: number, right: number, bottom: number, top: number, radii: CornerRadii, ) { const shape = new THREE.Shape() const { topLeft, topRight, bottomRight, bottomLeft } = radii shape.moveTo(left + bottomLeft, bottom) shape.lineTo(right - bottomRight, bottom) if (bottomRight > 1e-6) { shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false) } else { 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 + bottomLeft) if (bottomLeft > 1e-6) { shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false) } else { shape.lineTo(left, bottom) } shape.closePath() return shape } function createRoundedFrameShape( width: number, height: number, frameThickness: number, outerRadii: CornerRadii, ) { const halfWidth = width / 2 const bottom = -height / 2 const top = height / 2 const outer = createRoundedShape(-halfWidth, halfWidth, bottom, top, outerRadii) const inset = Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005) if (inset <= 0.001) return outer const innerLeft = -halfWidth + inset const innerRight = halfWidth - inset const innerBottom = bottom + inset const innerTop = top - inset const innerRadii = insetCornerRadii( outerRadii, inset, innerRight - innerLeft, innerTop - innerBottom, ) const holeShape = createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii) const hole = new THREE.Path(holeShape.getPoints(32).reverse()) outer.holes.push(hole) return outer } 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 createArchedFrameShape( width: number, height: number, archHeight: number, frameThickness: number, ) { const halfWidth = width / 2 const bottom = -height / 2 const top = height / 2 const outer = createArchShape(-halfWidth, halfWidth, bottom, top, archHeight) const inset = Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005) if (inset <= 0.001) return outer const innerLeft = -halfWidth + inset const innerRight = halfWidth - inset const innerBottom = bottom + inset const innerTop = top - inset const innerArchHeight = getClampedArchHeight( innerRight - innerLeft, innerTop - innerBottom, archHeight - inset, ) const hole = new THREE.Path( createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight) .getPoints(32) .reverse(), ) outer.holes.push(hole) return outer } 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 getArchedOpeningHalfWidthAtY( y: number, halfWidth: number, springY: number, archHeight: number, ) { if (y <= springY || archHeight <= 1e-6) return halfWidth const normalizedY = Math.min(Math.max((y - springY) / archHeight, 0), 1) return halfWidth * Math.sqrt(Math.max(1 - normalizedY * normalizedY, 0)) } function getRoundedBoundaryYAtX( x: number, left: number, right: number, top: number, radii: CornerRadii, ) { 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 getRoundedBottomBoundaryYAtX( x: number, left: number, right: number, bottom: number, radii: CornerRadii, ) { if (radii.bottomLeft > 1e-6 && x < left + radii.bottomLeft) { const centerX = left + radii.bottomLeft const centerY = bottom + radii.bottomLeft const dx = x - centerX return centerY - Math.sqrt(Math.max(radii.bottomLeft * radii.bottomLeft - dx * dx, 0)) } if (radii.bottomRight > 1e-6 && x > right - radii.bottomRight) { const centerX = right - radii.bottomRight const centerY = bottom + radii.bottomRight const dx = x - centerX return centerY - Math.sqrt(Math.max(radii.bottomRight * radii.bottomRight - dx * dx, 0)) } return bottom } function getRoundedHorizontalBoundsAtY( y: number, left: number, right: number, top: number, radii: CornerRadii, ) { let minX = left let maxX = right if (radii.topLeft > 1e-6 && y > top - radii.topLeft) { const centerX = left + radii.topLeft const centerY = top - radii.topLeft const dy = y - centerY minX = centerX - Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dy * dy, 0)) } if (radii.topRight > 1e-6 && y > top - radii.topRight) { const centerX = right - radii.topRight const centerY = top - radii.topRight const dy = y - centerY maxX = centerX + Math.sqrt(Math.max(radii.topRight * radii.topRight - dy * dy, 0)) } return { minX, maxX } } function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, columnRatios, rowRatios, columnDividerThickness, rowDividerThickness, sill, sillDepth, sillThickness, } = node const halfWidth = width / 2 const bottom = -height / 2 const top = height / 2 const outerRadii = getWindowRoundedRadii(node, width, height) const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005)) const innerLeft = -halfWidth + inset const innerRight = halfWidth - inset const innerBottom = bottom + inset const innerTop = top - inset const innerW = innerRight - innerLeft const innerH = innerTop - innerBottom const innerRadii = insetCornerRadii(outerRadii, inset, innerW, innerH) addShape( mesh, baseMaterial, createRoundedFrameShape(width, height, inset, outerRadii), frameDepth, ) if (innerW > 0.01 && innerH > 0.01) { const glassDepth = Math.max(0.004, frameDepth * 0.08) addShape( mesh, glassMaterial, createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii), glassDepth, ) const numCols = columnRatios.length const numRows = rowRatios.length const usableW = innerW - (numCols - 1) * columnDividerThickness const usableH = innerH - (numRows - 1) * rowDividerThickness const colSum = columnRatios.reduce((a, b) => a + b, 0) const rowSum = rowRatios.reduce((a, b) => a + b, 0) const colWidths = columnRatios.map((r) => (r / colSum) * usableW) const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH) let x = innerLeft for (let c = 0; c < numCols - 1; c++) { x += colWidths[c]! const x1 = x const x2 = x + columnDividerThickness const dividerTop = Math.min( getRoundedBoundaryYAtX(x1, innerLeft, innerRight, innerTop, innerRadii), getRoundedBoundaryYAtX(x2, innerLeft, innerRight, innerTop, innerRadii), ) if (dividerTop > innerBottom + 0.01) { addShape( mesh, baseMaterial, createRectShape(x1, x2, innerBottom, dividerTop), frameDepth + 0.001, ) } x += columnDividerThickness } let y = innerTop for (let r = 0; r < numRows - 1; r++) { y -= rowHeights[r]! const yTop = y const yBottom = y - rowDividerThickness const { minX, maxX } = getRoundedHorizontalBoundsAtY( yTop, innerLeft, innerRight, innerTop, innerRadii, ) if (maxX - minX > 0.01 && yTop > innerBottom) { addShape( mesh, baseMaterial, createRectShape(minX, maxX, Math.max(yBottom, innerBottom), yTop), frameDepth + 0.001, ) } y -= rowDividerThickness } } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, columnRatios, rowRatios, columnDividerThickness, rowDividerThickness, sill, sillDepth, sillThickness, } = node const halfWidth = width / 2 const bottom = -height / 2 const top = height / 2 const archHeight = getClampedArchHeight(width, height, node.archHeight) const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005)) const innerLeft = -halfWidth + inset const innerRight = halfWidth - inset const innerBottom = bottom + inset const innerTop = top - inset const innerW = innerRight - innerLeft const innerH = innerTop - innerBottom const innerArchHeight = getClampedArchHeight(innerW, innerH, archHeight - inset) const innerSpringY = innerTop - innerArchHeight addShape(mesh, baseMaterial, createArchedFrameShape(width, height, archHeight, inset), frameDepth) if (innerW > 0.01 && innerH > 0.01) { const glassDepth = Math.max(0.004, frameDepth * 0.08) addShape( mesh, glassMaterial, createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight), glassDepth, ) const numCols = columnRatios.length const numRows = rowRatios.length const usableW = innerW - (numCols - 1) * columnDividerThickness const usableH = innerH - (numRows - 1) * rowDividerThickness const colSum = columnRatios.reduce((a, b) => a + b, 0) const rowSum = rowRatios.reduce((a, b) => a + b, 0) const colWidths = columnRatios.map((r) => (r / colSum) * usableW) const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH) const innerHalfWidth = innerW / 2 let x = innerLeft for (let c = 0; c < numCols - 1; c++) { x += colWidths[c]! const x1 = x const x2 = x + columnDividerThickness const dividerTop = Math.min( getArchBoundaryY(x1, innerHalfWidth, innerSpringY, innerArchHeight), getArchBoundaryY(x2, innerHalfWidth, innerSpringY, innerArchHeight), ) if (dividerTop > innerBottom + 0.01) { addShape( mesh, baseMaterial, createRectShape(x1, x2, innerBottom, dividerTop), frameDepth + 0.001, ) } x += columnDividerThickness } let y = innerTop for (let r = 0; r < numRows - 1; r++) { y -= rowHeights[r]! const yTop = y const yBottom = y - rowDividerThickness const halfAtTop = getArchedOpeningHalfWidthAtY( yTop, innerHalfWidth, innerSpringY, innerArchHeight, ) const x1 = -halfAtTop const x2 = halfAtTop if (x2 - x1 > 0.01 && yTop > innerBottom) { addShape( mesh, baseMaterial, createRectShape(x1, x2, Math.max(yBottom, innerBottom), yTop), frameDepth + 0.001, ) } y -= rowDividerThickness } } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function getWindowRenderOpenAmount(node: WindowNode) { const runtimeValue = useInteractive.getState().windows[node.id]?.operationState return Math.min(Math.max(runtimeValue ?? node.operationState ?? 0, 0), 1) } function getAwningDirection(node: WindowNode) { return node.windowType === 'hopper' ? 'down' : (node.awningDirection ?? 'up') } function isRectangleOnlyWindowType(node: WindowNode) { return ( node.windowType === 'sliding' || node.windowType === 'single-hung' || node.windowType === 'double-hung' || node.windowType === 'bay' || node.windowType === 'bow' ) } function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness // Outer frame. addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0, ) addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0, ) if (innerW > 0.01 && innerH > 0.01) { const glassDepth = Math.max(0.004, frameDepth * 0.08) const railThickness = Math.max(frameThickness * 0.55, 0.025) const trackThickness = Math.max(frameThickness * 0.35, 0.018) const panelOverlap = Math.min(Math.max(frameThickness * 0.9, 0.04), innerW * 0.12) const openAmount = getWindowRenderOpenAmount(node) const travel = Math.max(innerW / 2 - panelOverlap, 0) * openAmount const panelWidth = (innerW + panelOverlap) / 2 const leftPanelBaseX = -innerW / 4 - panelOverlap / 4 const leftPanelX = leftPanelBaseX + travel const rightPanelX = innerW / 4 + panelOverlap / 4 const leftZ = frameDepth * 0.16 const rightZ = -frameDepth * 0.12 const panelH = Math.max(innerH - trackThickness * 2, 0.01) const activePanel = new THREE.Group() activePanel.name = SLIDING_WINDOW_ACTIVE_PANEL_NAME activePanel.position.set(leftPanelX, 0, leftZ) mesh.add(activePanel) // Twin tracks signal the sliding operation without adding editor-only state. addBox( mesh, baseMaterial, innerW, trackThickness, frameDepth, 0, innerH / 2 - trackThickness / 2, 0, ) addBox( mesh, baseMaterial, innerW, trackThickness, frameDepth, 0, -innerH / 2 + trackThickness / 2, 0, ) addBox(activePanel, glassMaterial, panelWidth, panelH, glassDepth, 0, 0, 0) addBox(mesh, glassMaterial, panelWidth, panelH, glassDepth, rightPanelX, 0, rightZ) // The right sash stays fixed. The left sash is the active panel that slides across it. addBox( activePanel, baseMaterial, railThickness, panelH, frameDepth * 0.72, -panelWidth / 2 + railThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, railThickness, panelH, frameDepth * 0.72, rightPanelX + panelWidth / 2 - railThickness / 2, 0, rightZ, ) addBox( activePanel, baseMaterial, railThickness, panelH, frameDepth * 0.78, panelWidth / 2 - railThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, railThickness, panelH, frameDepth * 0.78, rightPanelX - panelWidth / 2 + railThickness / 2, 0, rightZ, ) } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addRectCasementSash( parent: THREE.Object3D, name: string, panelW: number, panelH: number, frameThickness: number, frameDepth: number, pivotX: number, sashCenterX: number, rotationY: number, ) { const sash = new THREE.Group() const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032) const sashDepth = frameDepth * 0.72 const glassDepth = Math.max(0.004, frameDepth * 0.08) const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01) const glassH = Math.max(panelH - 2 * sashFrameThickness, 0.01) sash.name = name sash.position.set(pivotX, 0, frameDepth * 0.06) sash.rotation.y = rotationY parent.add(sash) addBox( sash, baseMaterial, panelW, sashFrameThickness, sashDepth, sashCenterX, panelH / 2 - sashFrameThickness / 2, 0, ) addBox( sash, baseMaterial, panelW, sashFrameThickness, sashDepth, sashCenterX, -panelH / 2 + sashFrameThickness / 2, 0, ) addBox( sash, baseMaterial, sashFrameThickness, panelH, sashDepth, sashCenterX - panelW / 2 + sashFrameThickness / 2, 0, 0, ) addBox( sash, baseMaterial, sashFrameThickness, panelH, sashDepth, sashCenterX + panelW / 2 - sashFrameThickness / 2, 0, 0, ) addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) } function addFrenchCasementHingeMarkers( mesh: THREE.Mesh, innerW: number, innerH: number, frameThickness: number, frameDepth: number, ) { const markerW = Math.max(frameThickness * 0.38, 0.018) const markerH = innerH * 0.24 for (const pivotX of [-innerW / 2, innerW / 2]) { addBox( mesh, baseMaterial, markerW, markerH, frameDepth * 1.1, pivotX, innerH * 0.25, frameDepth * 0.08, ) addBox( mesh, baseMaterial, markerW, markerH, frameDepth * 1.1, pivotX, -innerH * 0.25, frameDepth * 0.08, ) } } function createFrenchArchLeafShape( leafW: number, leafH: number, fullW: number, springY: number, archHeight: number, side: 'left' | 'right', inset = 0, ) { const left = -leafW / 2 + inset const right = leafW / 2 - inset const bottom = -leafH / 2 + inset const halfFullW = fullW / 2 const xOffset = side === 'left' ? -leafW / 2 : leafW / 2 const shape = new THREE.Shape() const segments = 32 const topAt = (localX: number) => Math.max( bottom + 0.01, getArchBoundaryY(localX + xOffset, halfFullW, springY, archHeight) - inset, ) shape.moveTo(left, bottom) shape.lineTo(right, bottom) shape.lineTo(right, topAt(right)) for (let index = 1; index <= segments; index += 1) { const x = right + (left - right) * (index / segments) shape.lineTo(x, topAt(x)) } shape.lineTo(left, bottom) shape.closePath() return shape } function createFrenchArchLeafFrameShape( leafW: number, leafH: number, fullW: number, springY: number, archHeight: number, frameThickness: number, side: 'left' | 'right', ) { const outer = createFrenchArchLeafShape(leafW, leafH, fullW, springY, archHeight, side) const inset = Math.min(frameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005) if (inset <= 0.001) return outer const hole = new THREE.Path( createFrenchArchLeafShape(leafW, leafH, fullW, springY, archHeight, side, inset) .getPoints(32) .reverse(), ) outer.holes.push(hole) return outer } function createFrenchRoundedLeafShape( leafW: number, leafH: number, fullW: number, fullRadii: CornerRadii, side: 'left' | 'right', inset = 0, ) { const left = -leafW / 2 + inset const right = leafW / 2 - inset const bottom = -leafH / 2 + inset const top = leafH / 2 - inset const fullLeft = -fullW / 2 + inset const fullRight = fullW / 2 - inset const globalOffset = side === 'left' ? -leafW / 2 : leafW / 2 const radii = inset > 0 ? insetCornerRadii(fullRadii, inset, fullRight - fullLeft, top - bottom) : fullRadii const shape = new THREE.Shape() const segments = 32 const topAt = (localX: number) => getRoundedBoundaryYAtX(localX + globalOffset, fullLeft, fullRight, top, radii) const bottomAt = (localX: number) => getRoundedBottomBoundaryYAtX(localX + globalOffset, fullLeft, fullRight, bottom, radii) shape.moveTo(right, bottomAt(right)) shape.lineTo(right, topAt(right)) for (let index = 1; index <= segments; index += 1) { const x = right + (left - right) * (index / segments) shape.lineTo(x, topAt(x)) } shape.lineTo(left, bottomAt(left)) for (let index = 1; index <= segments; index += 1) { const x = left + (right - left) * (index / segments) shape.lineTo(x, bottomAt(x)) } shape.closePath() return shape } function createFrenchRoundedLeafFrameShape( leafW: number, leafH: number, fullW: number, fullRadii: CornerRadii, frameThickness: number, side: 'left' | 'right', ) { const outer = createFrenchRoundedLeafShape(leafW, leafH, fullW, fullRadii, side) const inset = Math.min(frameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005) if (inset <= 0.001) return outer const hole = new THREE.Path( createFrenchRoundedLeafShape(leafW, leafH, fullW, fullRadii, side, inset) .getPoints(32) .reverse(), ) outer.holes.push(hole) return outer } function addShapedFrenchCasementSash( parent: THREE.Object3D, node: WindowNode, name: string, side: 'left' | 'right', fullW: number, leafW: number, leafH: number, frameThickness: number, frameDepth: number, pivotX: number, sashCenterX: number, rotationY: number, ) { const sash = new THREE.Group() const sashVisual = new THREE.Group() const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032) const sashDepth = frameDepth * 0.72 const glassDepth = Math.max(0.004, frameDepth * 0.08) sash.name = name sash.position.set(pivotX, 0, frameDepth * 0.06) sash.rotation.y = rotationY sashVisual.position.x = sashCenterX sash.add(sashVisual) parent.add(sash) if (node.openingShape === 'arch') { const outerArchHeight = getClampedArchHeight(node.width, node.height, node.archHeight) const sashArchHeight = getClampedArchHeight(fullW, leafH, outerArchHeight - frameThickness) const sashSpringY = node.height / 2 - outerArchHeight addShape( sashVisual, baseMaterial, createFrenchArchLeafFrameShape( leafW, leafH, fullW, sashSpringY, sashArchHeight, sashFrameThickness, side, ), sashDepth, ) const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005) if (glassInset > 0.001) { addShape( sashVisual, glassMaterial, createFrenchArchLeafShape( leafW, leafH, fullW, sashSpringY, sashArchHeight, side, glassInset, ), glassDepth, sashDepth * 0.08, ) } return } const frameRadii = insetCornerRadii( getWindowRoundedRadii(node, node.width, node.height), frameThickness, fullW, leafH, ) addShape( sashVisual, baseMaterial, createFrenchRoundedLeafFrameShape(leafW, leafH, fullW, frameRadii, sashFrameThickness, side), sashDepth, ) const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005) if (glassInset > 0.001) { addShape( sashVisual, glassMaterial, createFrenchRoundedLeafShape(leafW, leafH, fullW, frameRadii, side, glassInset), glassDepth, sashDepth * 0.08, ) } } function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness // Fixed outer frame. addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0, ) addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0, ) if (innerW > 0.01 && innerH > 0.01) { const openAngle = getWindowRenderOpenAmount(node) * (Math.PI / 2) const leafW = innerW / 2 addRectCasementSash( mesh, FRENCH_CASEMENT_LEFT_SASH_NAME, leafW, innerH, frameThickness, frameDepth, -innerW / 2, leafW / 2, -openAngle, ) addRectCasementSash( mesh, FRENCH_CASEMENT_RIGHT_SASH_NAME, leafW, innerH, frameThickness, frameDepth, innerW / 2, -leafW / 2, openAngle, ) addFrenchCasementHingeMarkers(mesh, innerW, innerH, frameThickness, frameDepth) } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness if (node.openingShape === 'arch') { addShape( mesh, baseMaterial, createArchedFrameShape( width, height, getClampedArchHeight(width, height, node.archHeight), frameThickness, ), frameDepth, ) } else { addShape( mesh, baseMaterial, createRoundedFrameShape( width, height, frameThickness, getWindowRoundedRadii(node, width, height), ), frameDepth, ) } if ((node.casementStyle ?? 'single') === 'french') { if (innerW > 0.01 && innerH > 0.01) { const openAngle = getWindowRenderOpenAmount(node) * (Math.PI / 2) const leafW = innerW / 2 addShapedFrenchCasementSash( mesh, node, FRENCH_CASEMENT_LEFT_SASH_NAME, 'left', innerW, leafW, innerH, frameThickness, frameDepth, -innerW / 2, leafW / 2, -openAngle, ) addShapedFrenchCasementSash( mesh, node, FRENCH_CASEMENT_RIGHT_SASH_NAME, 'right', innerW, leafW, innerH, frameThickness, frameDepth, innerW / 2, -leafW / 2, openAngle, ) addFrenchCasementHingeMarkers(mesh, innerW, innerH, frameThickness, frameDepth) } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } return } if (innerW > 0.01 && innerH > 0.01) { const openAmount = getWindowRenderOpenAmount(node) const openAngle = openAmount * (Math.PI / 2) const hingeSide = node.hingesSide ?? 'left' const hingeSign = hingeSide === 'left' ? -1 : 1 const pivotX = hingeSide === 'left' ? -innerW / 2 : innerW / 2 const sashCenterX = hingeSide === 'left' ? innerW / 2 : -innerW / 2 const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032) const sashDepth = frameDepth * 0.72 const glassDepth = Math.max(0.004, frameDepth * 0.08) const sash = new THREE.Group() const sashVisual = new THREE.Group() sash.name = CASEMENT_WINDOW_SASH_NAME sash.position.set(pivotX, 0, frameDepth * 0.06) sash.rotation.y = hingeSign * openAngle sashVisual.position.x = sashCenterX sash.add(sashVisual) mesh.add(sash) if (node.openingShape === 'arch') { const sashArchHeight = getClampedArchHeight( innerW, innerH, (node.archHeight ?? innerW / 2) - frameThickness, ) addShape( sashVisual, baseMaterial, createArchedFrameShape(innerW, innerH, sashArchHeight, sashFrameThickness), sashDepth, ) const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005) if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset addShape( sashVisual, glassMaterial, createArchShape( -glassW / 2, glassW / 2, -glassH / 2, glassH / 2, getClampedArchHeight(glassW, glassH, sashArchHeight - glassInset), ), glassDepth, sashDepth * 0.08, ) } } else { const outerRadii = getWindowRoundedRadii(node, innerW, innerH) addShape( sashVisual, baseMaterial, createRoundedFrameShape(innerW, innerH, sashFrameThickness, outerRadii), sashDepth, ) const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005) if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset addShape( sashVisual, glassMaterial, createRoundedShape( -glassW / 2, glassW / 2, -glassH / 2, glassH / 2, insetCornerRadii(outerRadii, glassInset, glassW, glassH), ), glassDepth, sashDepth * 0.08, ) } } addBox( mesh, baseMaterial, Math.max(frameThickness * 0.38, 0.018), innerH * 0.28, frameDepth * 1.1, pivotX, innerH * 0.24, frameDepth * 0.08, ) addBox( mesh, baseMaterial, Math.max(frameThickness * 0.38, 0.018), innerH * 0.28, frameDepth * 1.1, pivotX, -innerH * 0.24, frameDepth * 0.08, ) } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node if (node.openingShape === 'rounded' || node.openingShape === 'arch') { addShapedCasementWindowVisuals(node, mesh) return } if ((node.casementStyle ?? 'single') === 'french') { addFrenchCasementWindowVisuals(node, mesh) return } const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness // Fixed outer frame. addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0, ) addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0, ) if (innerW > 0.01 && innerH > 0.01) { const openAmount = getWindowRenderOpenAmount(node) const openAngle = openAmount * (Math.PI / 2) const hingeSide = node.hingesSide ?? 'left' const hingeSign = hingeSide === 'left' ? -1 : 1 const sash = new THREE.Group() const pivotX = hingeSide === 'left' ? -innerW / 2 : innerW / 2 const sashCenterX = hingeSide === 'left' ? innerW / 2 : -innerW / 2 const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032) const sashDepth = frameDepth * 0.72 const glassDepth = Math.max(0.004, frameDepth * 0.08) const glassW = Math.max(innerW - 2 * sashFrameThickness, 0.01) const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01) sash.name = CASEMENT_WINDOW_SASH_NAME sash.position.set(pivotX, 0, frameDepth * 0.06) sash.rotation.y = hingeSign * openAngle mesh.add(sash) addBox( sash, baseMaterial, innerW, sashFrameThickness, sashDepth, sashCenterX, innerH / 2 - sashFrameThickness / 2, 0, ) addBox( sash, baseMaterial, innerW, sashFrameThickness, sashDepth, sashCenterX, -innerH / 2 + sashFrameThickness / 2, 0, ) addBox( sash, baseMaterial, sashFrameThickness, innerH, sashDepth, sashCenterX - innerW / 2 + sashFrameThickness / 2, 0, 0, ) addBox( sash, baseMaterial, sashFrameThickness, innerH, sashDepth, sashCenterX + innerW / 2 - sashFrameThickness / 2, 0, 0, ) addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) // Small hinge markers make the pivot side legible when the sash is closed. addBox( mesh, baseMaterial, Math.max(frameThickness * 0.38, 0.018), innerH * 0.28, frameDepth * 1.1, pivotX, innerH * 0.24, frameDepth * 0.08, ) addBox( mesh, baseMaterial, Math.max(frameThickness * 0.38, 0.018), innerH * 0.28, frameDepth * 1.1, pivotX, -innerH * 0.24, frameDepth * 0.08, ) } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node if (node.openingShape === 'rounded' || node.openingShape === 'arch') { addShapedAwningWindowVisuals(node, mesh) return } const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness // Fixed outer frame. addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0, ) addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0, ) if (innerW > 0.01 && innerH > 0.01) { const openAmount = getWindowRenderOpenAmount(node) const openAngle = openAmount * (Math.PI / 3) const isDownward = getAwningDirection(node) === 'down' const sash = new THREE.Group() const pivotY = isDownward ? -innerH / 2 : innerH / 2 const sashCenterY = isDownward ? innerH / 2 : -innerH / 2 const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032) const sashDepth = frameDepth * 0.72 const glassDepth = Math.max(0.004, frameDepth * 0.08) const glassW = Math.max(innerW - 2 * sashFrameThickness, 0.01) const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01) sash.name = AWNING_WINDOW_SASH_NAME sash.position.set(0, pivotY, frameDepth * 0.06) sash.rotation.x = -openAngle mesh.add(sash) addBox( sash, baseMaterial, innerW, sashFrameThickness, sashDepth, 0, sashCenterY + innerH / 2 - sashFrameThickness / 2, 0, ) addBox( sash, baseMaterial, innerW, sashFrameThickness, sashDepth, 0, sashCenterY - innerH / 2 + sashFrameThickness / 2, 0, ) addBox( sash, baseMaterial, sashFrameThickness, innerH, sashDepth, -innerW / 2 + sashFrameThickness / 2, sashCenterY, 0, ) addBox( sash, baseMaterial, sashFrameThickness, innerH, sashDepth, innerW / 2 - sashFrameThickness / 2, sashCenterY, 0, ) addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, sashCenterY, sashDepth * 0.08) // Compact hinge rail, visible even when the sash is closed. addBox( mesh, baseMaterial, innerW * 0.42, Math.max(frameThickness * 0.38, 0.018), frameDepth * 1.1, 0, pivotY, frameDepth * 0.08, ) } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness if (node.openingShape === 'arch') { addShape( mesh, baseMaterial, createArchedFrameShape( width, height, getClampedArchHeight(width, height, node.archHeight), frameThickness, ), frameDepth, ) } else { addShape( mesh, baseMaterial, createRoundedFrameShape( width, height, frameThickness, getWindowRoundedRadii(node, width, height), ), frameDepth, ) } if (innerW > 0.01 && innerH > 0.01) { const openAmount = getWindowRenderOpenAmount(node) const openAngle = openAmount * (Math.PI / 3) const isDownward = getAwningDirection(node) === 'down' const pivotY = isDownward ? -innerH / 2 : innerH / 2 const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032) const sashDepth = frameDepth * 0.72 const glassDepth = Math.max(0.004, frameDepth * 0.08) const sash = new THREE.Group() const sashVisual = new THREE.Group() sash.name = AWNING_WINDOW_SASH_NAME sash.position.set(0, pivotY, frameDepth * 0.06) sash.rotation.x = -openAngle sashVisual.position.y = isDownward ? innerH / 2 : -innerH / 2 sash.add(sashVisual) mesh.add(sash) if (node.openingShape === 'arch') { const sashArchHeight = getClampedArchHeight( innerW, innerH, (node.archHeight ?? innerW / 2) - frameThickness, ) addShape( sashVisual, baseMaterial, createArchedFrameShape(innerW, innerH, sashArchHeight, sashFrameThickness), sashDepth, ) const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005) if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset addShape( sashVisual, glassMaterial, createArchShape( -glassW / 2, glassW / 2, -glassH / 2, glassH / 2, getClampedArchHeight(glassW, glassH, sashArchHeight - glassInset), ), glassDepth, sashDepth * 0.08, ) } } else { const outerRadii = getWindowRoundedRadii(node, innerW, innerH) addShape( sashVisual, baseMaterial, createRoundedFrameShape(innerW, innerH, sashFrameThickness, outerRadii), sashDepth, ) const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005) if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset addShape( sashVisual, glassMaterial, createRoundedShape( -glassW / 2, glassW / 2, -glassH / 2, glassH / 2, insetCornerRadii(outerRadii, glassInset, glassW, glassH), ), glassDepth, sashDepth * 0.08, ) } } addBox( mesh, baseMaterial, innerW * 0.42, Math.max(frameThickness * 0.38, 0.018), frameDepth * 1.1, 0, pivotY, frameDepth * 0.08, ) } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node if (node.openingShape === 'rounded' || node.openingShape === 'arch') { addShapedHopperWindowVisuals(node, mesh) return } const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness // Fixed outer frame. addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0, ) addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0, ) if (innerW > 0.01 && innerH > 0.01) { const openAmount = getWindowRenderOpenAmount(node) const openAngle = openAmount * (Math.PI / 3) const sash = new THREE.Group() const pivotY = -innerH / 2 const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032) const sashDepth = frameDepth * 0.72 const glassDepth = Math.max(0.004, frameDepth * 0.08) const glassW = Math.max(innerW - 2 * sashFrameThickness, 0.01) const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01) sash.name = HOPPER_WINDOW_SASH_NAME sash.position.set(0, pivotY, frameDepth * 0.06) sash.rotation.x = -openAngle mesh.add(sash) addBox( sash, baseMaterial, innerW, sashFrameThickness, sashDepth, 0, innerH - sashFrameThickness / 2, 0, ) addBox(sash, baseMaterial, innerW, sashFrameThickness, sashDepth, 0, sashFrameThickness / 2, 0) addBox( sash, baseMaterial, sashFrameThickness, innerH, sashDepth, -innerW / 2 + sashFrameThickness / 2, innerH / 2, 0, ) addBox( sash, baseMaterial, sashFrameThickness, innerH, sashDepth, innerW / 2 - sashFrameThickness / 2, innerH / 2, 0, ) addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, innerH / 2, sashDepth * 0.08) // Compact bottom hinge rail, visible even when the sash is closed. addBox( mesh, baseMaterial, innerW * 0.42, Math.max(frameThickness * 0.38, 0.018), frameDepth * 1.1, 0, pivotY, frameDepth * 0.08, ) } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness if (node.openingShape === 'arch') { addShape( mesh, baseMaterial, createArchedFrameShape( width, height, getClampedArchHeight(width, height, node.archHeight), frameThickness, ), frameDepth, ) } else { addShape( mesh, baseMaterial, createRoundedFrameShape( width, height, frameThickness, getWindowRoundedRadii(node, width, height), ), frameDepth, ) } if (innerW > 0.01 && innerH > 0.01) { const openAmount = getWindowRenderOpenAmount(node) const openAngle = openAmount * (Math.PI / 3) const pivotY = -innerH / 2 const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032) const sashDepth = frameDepth * 0.72 const glassDepth = Math.max(0.004, frameDepth * 0.08) const sash = new THREE.Group() const sashVisual = new THREE.Group() sash.name = HOPPER_WINDOW_SASH_NAME sash.position.set(0, pivotY, frameDepth * 0.06) sash.rotation.x = -openAngle sashVisual.position.y = innerH / 2 sash.add(sashVisual) mesh.add(sash) if (node.openingShape === 'arch') { const sashArchHeight = getClampedArchHeight( innerW, innerH, (node.archHeight ?? innerW / 2) - frameThickness, ) addShape( sashVisual, baseMaterial, createArchedFrameShape(innerW, innerH, sashArchHeight, sashFrameThickness), sashDepth, ) const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005) if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset addShape( sashVisual, glassMaterial, createArchShape( -glassW / 2, glassW / 2, -glassH / 2, glassH / 2, getClampedArchHeight(glassW, glassH, sashArchHeight - glassInset), ), glassDepth, sashDepth * 0.08, ) } } else { const outerRadii = getWindowRoundedRadii(node, innerW, innerH) addShape( sashVisual, baseMaterial, createRoundedFrameShape(innerW, innerH, sashFrameThickness, outerRadii), sashDepth, ) const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005) if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset addShape( sashVisual, glassMaterial, createRoundedShape( -glassW / 2, glassW / 2, -glassH / 2, glassH / 2, insetCornerRadii(outerRadii, glassInset, glassW, glassH), ), glassDepth, sashDepth * 0.08, ) } } addBox( mesh, baseMaterial, innerW * 0.42, Math.max(frameThickness * 0.38, 0.018), frameDepth * 1.1, 0, pivotY, frameDepth * 0.08, ) } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addHungSash( parent: THREE.Object3D, panelW: number, panelHeight: number, sashFrameThickness: number, frameDepth: number, glassDepth: number, glassW: number, glassH: number, ) { addBox( parent, baseMaterial, panelW, sashFrameThickness, frameDepth * 0.72, 0, panelHeight / 2 - sashFrameThickness / 2, 0, ) addBox( parent, baseMaterial, panelW, sashFrameThickness, frameDepth * 0.72, 0, -panelHeight / 2 + sashFrameThickness / 2, 0, ) addBox( parent, baseMaterial, sashFrameThickness, panelHeight, frameDepth * 0.72, -panelW / 2 + sashFrameThickness / 2, 0, 0, ) addBox( parent, baseMaterial, sashFrameThickness, panelHeight, frameDepth * 0.72, panelW / 2 - sashFrameThickness / 2, 0, 0, ) addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, 0) } function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness // Fixed outer frame. addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0, ) addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0, ) if (innerW > 0.01 && innerH > 0.01) { const glassDepth = Math.max(0.004, frameDepth * 0.08) const railThickness = Math.max(frameThickness * 0.55, 0.025) const trackThickness = Math.max(frameThickness * 0.35, 0.018) const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032) const panelOverlap = Math.min(Math.max(frameThickness * 0.9, 0.04), innerH * 0.12) const openAmount = getWindowRenderOpenAmount(node) const travel = Math.max(innerH / 2 - panelOverlap, 0) * openAmount const panelHeight = (innerH + panelOverlap) / 2 const topPanelY = innerH / 4 + panelOverlap / 4 const bottomPanelY = -innerH / 4 - panelOverlap / 4 + travel const topZ = -frameDepth * 0.12 const bottomZ = frameDepth * 0.16 const panelW = Math.max(innerW - trackThickness * 2, 0.01) const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01) const glassH = Math.max(panelHeight - 2 * sashFrameThickness, 0.01) const activeSash = new THREE.Group() activeSash.name = SINGLE_HUNG_ACTIVE_SASH_NAME activeSash.position.set(0, bottomPanelY, bottomZ) mesh.add(activeSash) // Side tracks show the lower sash is the moving element. addBox( mesh, baseMaterial, trackThickness, innerH, frameDepth, -innerW / 2 + trackThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, trackThickness, innerH, frameDepth, innerW / 2 - trackThickness / 2, 0, 0, ) const topSash = new THREE.Group() topSash.position.set(0, topPanelY, topZ) mesh.add(topSash) addHungSash( topSash, panelW, panelHeight, sashFrameThickness, frameDepth, glassDepth, glassW, glassH, ) addHungSash( activeSash, panelW, panelHeight, sashFrameThickness, frameDepth, glassDepth, glassW, glassH, ) // Meeting rails: top sash fixed, bottom sash moves upward over it. addBox( mesh, baseMaterial, panelW, railThickness, frameDepth * 0.78, 0, topPanelY - panelHeight / 2 + railThickness / 2, topZ, ) addBox( activeSash, baseMaterial, panelW, railThickness, frameDepth * 0.78, 0, panelHeight / 2 - railThickness / 2, 0, ) } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness // Fixed outer frame. addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0, ) addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0, ) if (innerW > 0.01 && innerH > 0.01) { const glassDepth = Math.max(0.004, frameDepth * 0.08) const railThickness = Math.max(frameThickness * 0.55, 0.025) const trackThickness = Math.max(frameThickness * 0.35, 0.018) const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032) const panelOverlap = Math.min(Math.max(frameThickness * 0.9, 0.04), innerH * 0.12) const openAmount = getWindowRenderOpenAmount(node) const travel = Math.max(innerH / 2 - panelOverlap, 0) * openAmount const panelHeight = (innerH + panelOverlap) / 2 const topPanelY = innerH / 4 + panelOverlap / 4 - travel const bottomPanelY = -innerH / 4 - panelOverlap / 4 + travel const topZ = -frameDepth * 0.12 const bottomZ = frameDepth * 0.16 const panelW = Math.max(innerW - trackThickness * 2, 0.01) const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01) const glassH = Math.max(panelHeight - 2 * sashFrameThickness, 0.01) const topSash = new THREE.Group() const bottomSash = new THREE.Group() topSash.name = DOUBLE_HUNG_TOP_SASH_NAME topSash.position.set(0, topPanelY, topZ) mesh.add(topSash) bottomSash.name = DOUBLE_HUNG_BOTTOM_SASH_NAME bottomSash.position.set(0, bottomPanelY, bottomZ) mesh.add(bottomSash) // Side tracks show both sashes move vertically. addBox( mesh, baseMaterial, trackThickness, innerH, frameDepth, -innerW / 2 + trackThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, trackThickness, innerH, frameDepth, innerW / 2 - trackThickness / 2, 0, 0, ) addHungSash( topSash, panelW, panelHeight, sashFrameThickness, frameDepth, glassDepth, glassW, glassH, ) addHungSash( bottomSash, panelW, panelHeight, sashFrameThickness, frameDepth, glassDepth, glassW, glassH, ) // Opposing meeting rails: top sash descends while bottom sash rises. addBox( topSash, baseMaterial, panelW, railThickness, frameDepth * 0.78, 0, -panelHeight / 2 + railThickness / 2, 0, ) addBox( bottomSash, baseMaterial, panelW, railThickness, frameDepth * 0.78, 0, panelHeight / 2 - railThickness / 2, 0, ) } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0, ) addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0, ) if (innerW > 0.01 && innerH > 0.01) { const projectionDepth = Math.max(width * 0.22, 0.28) const centerW = innerW * 0.48 const sideRun = Math.max((innerW - centerW) / 2, 0.01) const sideW = Math.hypot(sideRun, projectionDepth) const sideAngle = Math.atan2(projectionDepth, sideRun) const panelDepth = Math.max(frameDepth * 0.72, 0.04) const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032) const glassDepth = Math.max(0.004, frameDepth * 0.08) const bayFootprint: Array<[number, number]> = [ [-innerW / 2, 0], [-centerW / 2, projectionDepth], [centerW / 2, projectionDepth], [innerW / 2, 0], ] const addBayPanel = (parent: THREE.Object3D, panelW: number) => { const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01) const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01) addBox( parent, baseMaterial, panelW, sashFrameThickness, panelDepth, 0, innerH / 2 - sashFrameThickness / 2, 0, ) addBox( parent, baseMaterial, panelW, sashFrameThickness, panelDepth, 0, -innerH / 2 + sashFrameThickness / 2, 0, ) addBox( parent, baseMaterial, sashFrameThickness, innerH, panelDepth, -panelW / 2 + sashFrameThickness / 2, 0, 0, ) addBox( parent, baseMaterial, sashFrameThickness, innerH, panelDepth, panelW / 2 - sashFrameThickness / 2, 0, 0, ) addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, panelDepth * 0.08) } const addBayCap = (centerY: number) => { const halfThickness = frameThickness / 2 const vertices: number[] = [] const indices: number[] = [] for (const [x, z] of bayFootprint) { vertices.push(x, centerY - halfThickness, z) } for (const [x, z] of bayFootprint) { vertices.push(x, centerY + halfThickness, z) } indices.push( 0, 1, 2, 0, 2, 3, 4, 6, 5, 4, 7, 6, 0, 4, 5, 0, 5, 1, 1, 5, 6, 1, 6, 2, 2, 6, 7, 2, 7, 3, 3, 7, 4, 3, 4, 0, ) const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)) geometry.setIndex(indices) geometry.computeVertexNormals() mesh.add(new THREE.Mesh(geometry, baseMaterial)) } const center = new THREE.Group() center.position.set(0, 0, projectionDepth) mesh.add(center) addBayPanel(center, centerW) const left = new THREE.Group() left.position.set((-innerW / 2 - centerW / 2) / 2, 0, projectionDepth / 2) left.rotation.y = -sideAngle mesh.add(left) addBayPanel(left, sideW) const right = new THREE.Group() right.position.set((innerW / 2 + centerW / 2) / 2, 0, projectionDepth / 2) right.rotation.y = sideAngle mesh.add(right) addBayPanel(right, sideW) addBayCap(innerH / 2) addBayCap(-innerH / 2) } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addBowWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0, ) addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0, ) if (innerW > 0.01 && innerH > 0.01) { const mullionCount = 5 const curveSegments = 28 const projectionDepth = Math.max(width * 0.18, 0.22) const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032) const halfSpan = innerW / 2 const arcZAt = (x: number) => projectionDepth * (1 - (x / halfSpan) ** 2) const slabYTop = innerH / 2 const slabYBottom = -innerH / 2 const glassTop = innerH / 2 - sashFrameThickness const glassBottom = -innerH / 2 + sashFrameThickness const createCurvedVerticalBand = (yBottom: number, yTop: number, zOffset = 0) => { const positions: number[] = [] const indices: number[] = [] for (let index = 0; index <= curveSegments; index += 1) { const x = -halfSpan + (innerW * index) / curveSegments const z = arcZAt(x) + zOffset positions.push(x, yBottom, z, x, yTop, z) } for (let index = 0; index < curveSegments; index += 1) { const a = index * 2 indices.push(a, a + 1, a + 2, a + 1, a + 3, a + 2) } const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setIndex(indices) geometry.computeVertexNormals() return geometry } const createCurvedCap = (centerY: number, thickness: number) => { const positions: number[] = [] const indices: number[] = [] const yBottom = centerY - thickness / 2 const yTop = centerY + thickness / 2 for (let index = 0; index <= curveSegments; index += 1) { const x = -halfSpan + (innerW * index) / curveSegments const z = arcZAt(x) positions.push(x, yBottom, 0, x, yBottom, z, x, yTop, 0, x, yTop, z) } for (let index = 0; index < curveSegments; index += 1) { const a = index * 4 const b = a + 4 indices.push( a, b, a + 2, b, b + 2, a + 2, a + 1, a + 3, b + 1, b + 1, a + 3, b + 3, a + 2, b + 2, a + 3, b + 2, b + 3, a + 3, a, a + 1, b, b, a + 1, b + 1, ) } const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setIndex(indices) geometry.computeVertexNormals() return geometry } const addCurvedMesh = (material: THREE.Material, geometry: THREE.BufferGeometry) => { mesh.add(new THREE.Mesh(geometry, material)) } addCurvedMesh(baseMaterial, createCurvedVerticalBand(glassTop, innerH / 2)) addCurvedMesh(baseMaterial, createCurvedVerticalBand(-innerH / 2, glassBottom)) addCurvedMesh(glassMaterial, createCurvedVerticalBand(glassBottom, glassTop, frameDepth * 0.04)) addCurvedMesh(baseMaterial, createCurvedCap(slabYTop, frameThickness)) addCurvedMesh(baseMaterial, createCurvedCap(slabYBottom, frameThickness)) for (let index = 0; index <= mullionCount; index += 1) { const x = -halfSpan + (innerW * index) / mullionCount addBox(mesh, baseMaterial, sashFrameThickness, innerH, frameDepth * 0.72, x, 0, arcZAt(x)) } } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node if (node.openingShape === 'rounded' || node.openingShape === 'arch') { addShapedLouveredWindowVisuals(node, mesh) return } const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0, ) addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0, ) if (innerW > 0.01 && innerH > 0.01) { const openAmount = getWindowRenderOpenAmount(node) const slatCount = Math.max(4, Math.min(9, Math.round(height / 0.22))) const slatGap = innerH / slatCount const slatHeight = Math.max(Math.min(slatGap * 0.62, 0.14), 0.045) const slatDepth = Math.max(frameDepth * 0.16, 0.012) const slatAngle = -openAmount * (Math.PI / 3) const railThickness = Math.max(frameThickness * 0.45, 0.022) const slats = new THREE.Group() slats.name = LOUVERED_WINDOW_SLATS_NAME mesh.add(slats) addBox( mesh, baseMaterial, railThickness, innerH, frameDepth * 0.95, -innerW / 2 + railThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, railThickness, innerH, frameDepth * 0.95, innerW / 2 - railThickness / 2, 0, 0, ) for (let index = 0; index < slatCount; index += 1) { const y = innerH / 2 - slatGap * (index + 0.5) const slat = new THREE.Group() slat.position.set(0, y, 0) slat.rotation.x = slatAngle slats.add(slat) addBox( slat, glassMaterial, Math.max(innerW - 2 * railThickness, 0.01), slatHeight, slatDepth, 0, 0, 0, ) } } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node const halfWidth = width / 2 const bottom = -height / 2 const top = height / 2 const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005)) const innerLeft = -halfWidth + inset const innerRight = halfWidth - inset const innerBottom = bottom + inset const innerTop = top - inset const innerW = innerRight - innerLeft const innerH = innerTop - innerBottom if (node.openingShape === 'arch') { addShape( mesh, baseMaterial, createArchedFrameShape( width, height, getClampedArchHeight(width, height, node.archHeight), frameThickness, ), frameDepth, ) } else { addShape( mesh, baseMaterial, createRoundedFrameShape( width, height, frameThickness, getWindowRoundedRadii(node, width, height), ), frameDepth, ) } if (innerW > 0.01 && innerH > 0.01) { const openAmount = getWindowRenderOpenAmount(node) const slatCount = Math.max(4, Math.min(9, Math.round(height / 0.22))) const slatGap = innerH / slatCount const slatHeight = Math.max(Math.min(slatGap * 0.62, 0.14), 0.045) const slatDepth = Math.max(frameDepth * 0.16, 0.012) const slatAngle = -openAmount * (Math.PI / 3) const railThickness = Math.max(frameThickness * 0.45, 0.022) const slatInset = railThickness + 0.004 const slats = new THREE.Group() slats.name = LOUVERED_WINDOW_SLATS_NAME mesh.add(slats) const getBoundsAtY = node.openingShape === 'arch' ? (() => { const outerArchHeight = getClampedArchHeight(width, height, node.archHeight) const archHeight = getClampedArchHeight(innerW, innerH, outerArchHeight - inset) const springY = top - outerArchHeight return (y: number) => { const half = getArchedOpeningHalfWidthAtY(y, innerW / 2, springY, archHeight) return { minX: -half, maxX: half } } })() : (() => { const innerRadii = insetCornerRadii( getWindowRoundedRadii(node, width, height), inset, innerW, innerH, ) return (y: number) => getRoundedHorizontalBoundsAtY(y, innerLeft, innerRight, innerTop, innerRadii) })() const addVerticalRail = (x: number) => { const railX1 = x const railX2 = x + (x < 0 ? railThickness : -railThickness) const sampleX = x < 0 ? Math.max(railX1, railX2) : Math.min(railX1, railX2) const railTop = node.openingShape === 'arch' ? getArchBoundaryY( sampleX, innerW / 2, top - getClampedArchHeight(width, height, node.archHeight), getClampedArchHeight( innerW, innerH, getClampedArchHeight(width, height, node.archHeight) - inset, ), ) : getRoundedBoundaryYAtX( sampleX, innerLeft, innerRight, innerTop, insetCornerRadii(getWindowRoundedRadii(node, width, height), inset, innerW, innerH), ) addShape( mesh, baseMaterial, createRectShape(Math.min(railX1, railX2), Math.max(railX1, railX2), innerBottom, railTop), frameDepth * 0.95, ) } addVerticalRail(innerLeft) addVerticalRail(innerRight) for (let index = 0; index < slatCount; index += 1) { const y = innerTop - slatGap * (index + 0.5) const topBounds = getBoundsAtY(Math.min(y + slatHeight / 2, innerTop)) const bottomBounds = getBoundsAtY(Math.max(y - slatHeight / 2, innerBottom)) const minX = Math.max(topBounds.minX, bottomBounds.minX) + slatInset const maxX = Math.min(topBounds.maxX, bottomBounds.maxX) - slatInset const slatW = Math.max(maxX - minX, 0) if (slatW <= 0.01) continue const slat = new THREE.Group() slat.position.set((minX + maxX) / 2, y, 0) slat.rotation.x = slatAngle slats.add(slat) addBox(slat, glassMaterial, slatW, slatHeight, slatDepth, 0, 0, 0) } } if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } } function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // 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 disposeObjectGeometry(child) mesh.remove(child) } // Point the builder-facing frame/glass materials at this window's slot // overrides for the duration of its build (recomputed per node, so the next // window resets cleanly without a restore). baseMaterial = resolveWindowSlotMaterial(node, 'frame') glassMaterial = resolveWindowSlotMaterial(node, 'glass') const { width, height, frameDepth, frameThickness, columnRatios, rowRatios, columnDividerThickness, rowDividerThickness, sill, sillDepth, sillThickness, openingKind, openingShape, windowType, } = node if (openingKind === 'opening') { syncWindowCutout(node, mesh) return } if (windowType === 'sliding') { addSlidingWindowVisuals(node, mesh) syncWindowCutout(node, mesh) return } if (windowType === 'casement') { addCasementWindowVisuals(node, mesh) syncWindowCutout(node, mesh) return } if (windowType === 'awning') { addAwningWindowVisuals(node, mesh) syncWindowCutout(node, mesh) return } if (windowType === 'hopper') { addAwningWindowVisuals(node, mesh) syncWindowCutout(node, mesh) return } if (windowType === 'single-hung') { addSingleHungWindowVisuals(node, mesh) syncWindowCutout(node, mesh) return } if (windowType === 'double-hung') { addDoubleHungWindowVisuals(node, mesh) syncWindowCutout(node, mesh) return } if (windowType === 'bay') { addBayWindowVisuals(node, mesh) syncWindowCutout(node, mesh) return } if (windowType === 'bow') { addBowWindowVisuals(node, mesh) syncWindowCutout(node, mesh) return } if (windowType === 'louvered') { addLouveredWindowVisuals(node, mesh) syncWindowCutout(node, mesh) return } if (openingShape === 'arch') { addArchedWindowVisuals(node, mesh) syncWindowCutout(node, mesh) return } if (openingShape === 'rounded') { addRoundedWindowVisuals(node, mesh) syncWindowCutout(node, mesh) return } const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness // ── Frame members ── // Top / bottom — full width addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0, ) addBox( mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0, ) // Left / right — inner height to avoid corner overlap addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0, ) addBox( mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0, ) // ── Pane grid ── const numCols = columnRatios.length const numRows = rowRatios.length const usableW = innerW - (numCols - 1) * columnDividerThickness const usableH = innerH - (numRows - 1) * rowDividerThickness const colSum = columnRatios.reduce((a, b) => a + b, 0) const rowSum = rowRatios.reduce((a, b) => a + b, 0) const colWidths = columnRatios.map((r) => (r / colSum) * usableW) const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH) // Compute column x-centers starting from left edge of inner area const colXCenters: number[] = [] let cx = -innerW / 2 for (let c = 0; c < numCols; c++) { colXCenters.push(cx + colWidths[c]! / 2) cx += colWidths[c]! if (c < numCols - 1) cx += columnDividerThickness } // Compute row y-centers starting from top edge of inner area (R1 = top) const rowYCenters: number[] = [] let cy = innerH / 2 for (let r = 0; r < numRows; r++) { rowYCenters.push(cy - rowHeights[r]! / 2) cy -= rowHeights[r]! if (r < numRows - 1) cy -= rowDividerThickness } // Column dividers — full inner height cx = -innerW / 2 for (let c = 0; c < numCols - 1; c++) { cx += colWidths[c]! addBox( mesh, baseMaterial, columnDividerThickness, innerH, frameDepth, cx + columnDividerThickness / 2, 0, 0, ) cx += columnDividerThickness } // Row dividers — per column width, so they don't overlap column dividers (top to bottom) cy = innerH / 2 for (let r = 0; r < numRows - 1; r++) { cy -= rowHeights[r]! const divY = cy - rowDividerThickness / 2 for (let c = 0; c < numCols; c++) { addBox( mesh, baseMaterial, colWidths[c]!, rowDividerThickness, frameDepth, colXCenters[c]!, divY, 0, ) } cy -= rowDividerThickness } // Glass panes const glassDepth = Math.max(0.004, frameDepth * 0.08) for (let c = 0; c < numCols; c++) { for (let r = 0; r < numRows; r++) { addBox( mesh, glassMaterial, colWidths[c]!, rowHeights[r]!, glassDepth, colXCenters[c]!, rowYCenters[r]!, 0, ) } } // ── Sill ── if (sill) { const sillW = width + sillDepth * 0.4 // slightly wider than frame // Protrudes from the front face of the frame (+Z) const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ, ) } syncWindowCutout(node, mesh) } function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { // ── Cutout (for wall CSG) — always full window dimensions, 1m deep ── let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined if (!cutout) { cutout = new THREE.Mesh() cutout.name = 'cutout' mesh.add(cutout) } cutout.geometry.dispose() if (isRectangleOnlyWindowType(node)) { cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) } else if (node.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 (node.openingShape === 'rounded') { cutout.geometry = new THREE.ExtrudeGeometry( createRoundedShape( -node.width / 2, node.width / 2, -node.height / 2, node.height / 2, getWindowRoundedRadii(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 }