Files
editor/packages/viewer/src/systems/window/window-system.tsx
T

258 lines
6.3 KiB
TypeScript

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
}