import { useFrame } from '@react-three/fiber' import { type AnyNodeId, sceneRegistry, useScene, type WindowNode, } from '@pascal-app/core' import * as THREE from 'three' import { baseMaterial, glassMaterial } from '../../lib/materials' // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) export const WindowSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) const clearDirty = useScene((state) => state.clearDirty) useFrame(() => { if (dirtyNodes.size === 0) return const nodes = useScene.getState().nodes dirtyNodes.forEach((id) => { const node = nodes[id] if (!node || node.type !== 'window') return const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh if (!mesh) return // Keep dirty until mesh mounts updateWindowMesh(node as WindowNode, mesh) clearDirty(id as AnyNodeId) // Rebuild the parent wall so its cutout reflects the updated window geometry if ((node as WindowNode).parentId) { useScene.getState().dirtyNodes.add((node as WindowNode).parentId as AnyNodeId) } }) }, 3) return null } 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) parent.add(m) } 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 if (child instanceof THREE.Mesh) child.geometry.dispose() mesh.remove(child) } const { width, height, frameDepth, frameThickness, columnRatios, rowRatios, columnDividerThickness, rowDividerThickness, sill, sillDepth, sillThickness, openingKind, } = node if (openingKind === 'opening') { 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() cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) cutout.visible = false }