window building

This commit is contained in:
wass08
2026-02-18 10:41:12 +09:00
parent 9a2c89547b
commit 076faae7df
6 changed files with 374 additions and 21 deletions
+11 -6
View File
@@ -13,6 +13,7 @@ export type {
SiteEvent,
SlabEvent,
WallEvent,
WindowEvent,
ZoneEvent,
} from './events/bus'
// Events
@@ -22,12 +23,21 @@ export {
sceneRegistry,
useRegistry,
} from './hooks/scene-registry/scene-registry'
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
export {
initSpatialGridSync,
resolveLevelId,
} from './hooks/spatial-grid/spatial-grid-sync'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
// Asset storage
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
// Space detection
export {
detectSpacesForLevel,
initSpaceDetectionSync,
type Space,
wallTouchesOthers,
} from './lib/space-detection'
// Schema
export * from './schema'
export { default as useScene } from './store/use-scene'
@@ -38,9 +48,4 @@ export { RoofSystem } from './systems/roof/roof-system'
export { SlabSystem } from './systems/slab/slab-system'
export { WallSystem } from './systems/wall/wall-system'
export { WindowSystem } from './systems/window/window-system'
export { isObject } from './utils/types'
// Asset storage
export { saveAsset, loadAssetUrl } from './lib/asset-storage'
// Space detection
export { detectSpacesForLevel, wallTouchesOthers, initSpaceDetectionSync, type Space } from './lib/space-detection'
@@ -7,15 +7,25 @@ import useScene from '../../store/use-scene'
const glassMaterial = new MeshStandardNodeMaterial({
name: 'glass',
color: 'lightgray',
roughness: 0.8,
metalness: 0,
color: 'lightblue',
roughness: 0.05,
metalness: 0.1,
transparent: true,
opacity: 0.35,
opacity: 0.3,
side: DoubleSide,
depthWrite: false,
})
const frameMaterial = new MeshStandardNodeMaterial({
name: 'window-frame',
color: '#e8e8e8',
roughness: 0.6,
metalness: 0,
})
// 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)
@@ -45,17 +55,117 @@ export const WindowSystem = () => {
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) {
// Replace geometry with a box matching the overall window dimensions
// 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 = glassMaterial
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])
// Update (or create) the named cutout mesh used by wall-system for CSG subtraction
// 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, dividerThickness,
sill, sillDepth, sillThickness,
} = node
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
// ── Frame members ──
// Top / bottom — full width
addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0)
addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0)
// Left / right — inner height to avoid corner overlap
addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0)
addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0)
// ── Pane grid ──
const numCols = columnRatios.length
const numRows = rowRatios.length
const usableW = innerW - (numCols - 1) * dividerThickness
const usableH = innerH - (numRows - 1) * dividerThickness
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 += dividerThickness
}
// Compute row y-centers starting from bottom edge of inner area
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 += dividerThickness
}
// Column dividers — full inner height
cx = -innerW / 2
for (let c = 0; c < numCols - 1; c++) {
cx += colWidths[c]!
addBox(mesh, frameMaterial, dividerThickness, innerH, frameDepth, cx + dividerThickness / 2, 0, 0)
cx += dividerThickness
}
// Row dividers — per column width, so they don't overlap column dividers
cy = -innerH / 2
for (let r = 0; r < numRows - 1; r++) {
cy += rowHeights[r]!
const divY = cy + dividerThickness / 2
for (let c = 0; c < numCols; c++) {
addBox(mesh, frameMaterial, colWidths[c]!, dividerThickness, frameDepth, colXCenters[c]!, divY, 0)
}
cy += dividerThickness
}
// 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, frameMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ)
}
// ── 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()
@@ -63,7 +173,6 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
mesh.add(cutout)
}
cutout.geometry.dispose()
// Extends 1m through the wall so the CSG brush covers full wall thickness
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
cutout.visible = false;
cutout.visible = false
}
@@ -1,11 +1,13 @@
import { useRegistry, type WindowNode } from '@pascal-app/core'
import { useRef } from 'react'
import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
export const WindowRenderer = ({ node }: { node: WindowNode }) => {
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'window', ref)
const handlers = useNodeEvents(node, 'window')
return (
<mesh
@@ -15,6 +17,7 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
visible={node.visible}
position={node.position}
rotation={node.rotation}
{...handlers}
>
{/* WindowSystem replaces this geometry each time the node is dirty */}
<boxGeometry args={[0, 0, 0]} />
+24 -6
View File
@@ -17,11 +17,13 @@ import {
type SlabNode,
type WallEvent,
type WallNode,
type WindowEvent,
type WindowNode,
type ZoneEvent,
type ZoneNode,
} from '@pascal-app/core'
import type { ThreeEvent } from '@react-three/fiber'
import useViewer from '../store/use-viewer';
import useViewer from '../store/use-viewer'
type NodeConfig = {
site: { node: SiteNode; event: SiteEvent }
@@ -33,6 +35,7 @@ type NodeConfig = {
slab: { node: SlabNode; event: SlabEvent }
ceiling: { node: CeilingNode; event: CeilingEvent }
roof: { node: RoofNode; event: RoofEvent }
window: { node: WindowNode; event: WindowEvent }
}
type NodeType = keyof NodeConfig
@@ -69,10 +72,25 @@ export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], t
if (e.button !== 0) return
emit('click', e)
},
onPointerEnter: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('enter', e)},
onPointerLeave: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('leave', e)},
onPointerMove: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('move', e)},
onDoubleClick: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('double-click', e)},
onContextMenu: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('context-menu', e)},
onPointerEnter: (e: ThreeEvent<PointerEvent>) => {
if (useViewer.getState().cameraDragging) return
emit('enter', e)
},
onPointerLeave: (e: ThreeEvent<PointerEvent>) => {
if (useViewer.getState().cameraDragging) return
emit('leave', e)
},
onPointerMove: (e: ThreeEvent<PointerEvent>) => {
if (useViewer.getState().cameraDragging) return
emit('move', e)
},
onDoubleClick: (e: ThreeEvent<PointerEvent>) => {
if (useViewer.getState().cameraDragging) return
emit('double-click', e)
},
onContextMenu: (e: ThreeEvent<PointerEvent>) => {
if (useViewer.getState().cameraDragging) return
emit('context-menu', e)
},
}
}