258 lines
6.3 KiB
TypeScript
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
|
|
}
|