Move render systems out of core
This commit is contained in:
@@ -52,7 +52,6 @@ export {
|
||||
type MaterialCategory,
|
||||
toLibraryMaterialRef,
|
||||
} from './material-library'
|
||||
export { baseMaterial, glassMaterial } from './materials'
|
||||
export * from './schema'
|
||||
export {
|
||||
getSceneHistoryPauseDepth,
|
||||
@@ -67,13 +66,7 @@ export {
|
||||
} from './store/use-interactive'
|
||||
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||
export { DoorSystem } from './systems/door/door-system'
|
||||
export { FenceSystem } from './systems/fence/fence-system'
|
||||
export { ItemSystem } from './systems/item/item-system'
|
||||
export { RoofSystem } from './systems/roof/roof-system'
|
||||
export { SlabSystem } from './systems/slab/slab-system'
|
||||
export { StairSystem } from './systems/stair/stair-system'
|
||||
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
|
||||
export {
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
@@ -95,14 +88,13 @@ export {
|
||||
} from './systems/wall/wall-footprint'
|
||||
export {
|
||||
calculateLevelMiters,
|
||||
getAdjacentWallIds,
|
||||
getWallMiterBoundaryPoints,
|
||||
type Point2D,
|
||||
pointToKey,
|
||||
type WallMiterBoundaryPoints,
|
||||
type WallMiterData,
|
||||
} from './systems/wall/wall-mitering'
|
||||
export { WallSystem } from './systems/wall/wall-system'
|
||||
export { WindowSystem } from './systems/window/window-system'
|
||||
export type { SceneGraph } from './utils/clone-scene-graph'
|
||||
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
||||
export { isObject } from './utils/types'
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
|
||||
/**
|
||||
* Shared base material for structural elements: walls, frames, slabs, roof.
|
||||
*/
|
||||
export const baseMaterial = new MeshStandardNodeMaterial({
|
||||
color: '#f2f0ed',
|
||||
roughness: 0.5,
|
||||
metalness: 0,
|
||||
})
|
||||
|
||||
/**
|
||||
* Shared glass material for windows, glazed door panels, and glass items.
|
||||
*/
|
||||
export const glassMaterial = new MeshStandardNodeMaterial({
|
||||
name: 'glass',
|
||||
color: 'lightblue',
|
||||
roughness: 0.05,
|
||||
metalness: 0.1,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
side: DoubleSide,
|
||||
depthWrite: false,
|
||||
})
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||
import type { AnyNodeId, CeilingNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!uv) return
|
||||
|
||||
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CEILING SYSTEM
|
||||
// ============================================================================
|
||||
|
||||
export const CeilingSystem = () => {
|
||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||
const clearDirty = useScene((state) => state.clearDirty)
|
||||
|
||||
useFrame(() => {
|
||||
if (dirtyNodes.size === 0) return
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
// Process dirty ceilings
|
||||
dirtyNodes.forEach((id) => {
|
||||
const node = nodes[id]
|
||||
if (!node || node.type !== 'ceiling') return
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
|
||||
if (mesh) {
|
||||
updateCeilingGeometry(node as CeilingNode, mesh)
|
||||
clearDirty(id as AnyNodeId)
|
||||
}
|
||||
// If mesh not found, keep it dirty for next frame
|
||||
})
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the geometry for a single ceiling
|
||||
*/
|
||||
function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) {
|
||||
const newGeo = generateCeilingGeometry(node)
|
||||
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = newGeo
|
||||
|
||||
const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh
|
||||
if (gridMesh) {
|
||||
gridMesh.geometry.dispose()
|
||||
gridMesh.geometry = newGeo
|
||||
}
|
||||
|
||||
// Position at the ceiling height
|
||||
mesh.position.y = (node.height ?? 2.5) - 0.01 // Slight offset to avoid z-fighting with upper-level slabs
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates flat ceiling geometry from polygon (no extrusion)
|
||||
*/
|
||||
export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferGeometry {
|
||||
const polygon = ceilingNode.polygon
|
||||
|
||||
if (polygon.length < 3) {
|
||||
return new THREE.BufferGeometry()
|
||||
}
|
||||
|
||||
// Create shape from polygon
|
||||
// Shape is in X-Y plane, we'll rotate to X-Z plane
|
||||
const shape = new THREE.Shape()
|
||||
const firstPt = polygon[0]!
|
||||
|
||||
// Negate Y (which becomes Z) to get correct orientation after rotation
|
||||
shape.moveTo(firstPt[0], -firstPt[1])
|
||||
|
||||
for (let i = 1; i < polygon.length; i++) {
|
||||
const pt = polygon[i]!
|
||||
shape.lineTo(pt[0], -pt[1])
|
||||
}
|
||||
shape.closePath()
|
||||
|
||||
// Add holes to the shape
|
||||
const holes = ceilingNode.holes || []
|
||||
for (const holePolygon of holes) {
|
||||
if (holePolygon.length < 3) continue
|
||||
|
||||
const holePath = new THREE.Path()
|
||||
const holeFirstPt = holePolygon[0]!
|
||||
holePath.moveTo(holeFirstPt[0], -holeFirstPt[1])
|
||||
|
||||
for (let i = 1; i < holePolygon.length; i++) {
|
||||
const pt = holePolygon[i]!
|
||||
holePath.lineTo(pt[0], -pt[1])
|
||||
}
|
||||
holePath.closePath()
|
||||
|
||||
shape.holes.push(holePath)
|
||||
}
|
||||
|
||||
// Create flat shape geometry (no extrusion)
|
||||
const geometry = new THREE.ShapeGeometry(shape)
|
||||
|
||||
// Rotate so the shape lies flat in X-Z plane
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeVertexNormals()
|
||||
ensureUv2Attribute(geometry)
|
||||
|
||||
return geometry
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||
import { baseMaterial, glassMaterial } from '../../materials'
|
||||
import type { AnyNodeId, DoorNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
|
||||
// Invisible material for root mesh — used as selection hitbox only
|
||||
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
||||
|
||||
export const DoorSystem = () => {
|
||||
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 !== 'door') return
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
|
||||
if (!mesh) return // Keep dirty until mesh mounts
|
||||
|
||||
updateDoorMesh(node as DoorNode, mesh)
|
||||
clearDirty(id as AnyNodeId)
|
||||
|
||||
// Rebuild the parent wall so its cutout reflects the updated door geometry
|
||||
if ((node as DoorNode).parentId) {
|
||||
useScene.getState().dirtyNodes.add((node as DoorNode).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 disposeObject(object: THREE.Object3D) {
|
||||
object.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) child.geometry.dispose()
|
||||
})
|
||||
}
|
||||
|
||||
function updateDoorMesh(node: DoorNode, 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
|
||||
disposeObject(child)
|
||||
mesh.remove(child)
|
||||
}
|
||||
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
openingKind,
|
||||
frameThickness,
|
||||
frameDepth,
|
||||
threshold,
|
||||
thresholdHeight,
|
||||
segments,
|
||||
handle,
|
||||
handleHeight,
|
||||
handleSide,
|
||||
doorCloser,
|
||||
panicBar,
|
||||
panicBarHeight,
|
||||
contentPadding,
|
||||
hingesSide,
|
||||
swingDirection,
|
||||
swingAngle = 0,
|
||||
} = node
|
||||
const hasLeafContent = segments.some((seg) => seg.type !== 'empty')
|
||||
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle))
|
||||
|
||||
if (openingKind === 'opening') {
|
||||
syncDoorCutout(node, mesh)
|
||||
return
|
||||
}
|
||||
|
||||
// Leaf occupies the full opening (no bottom frame bar — door opens to floor)
|
||||
const leafW = width - 2 * frameThickness
|
||||
const leafH = height - frameThickness // only top frame
|
||||
const leafDepth = 0.04
|
||||
// Leaf center is shifted down from door center by half the top frame
|
||||
const leafCenterY = -frameThickness / 2
|
||||
const hingeX = hingesSide === 'right' ? leafW / 2 : -leafW / 2
|
||||
const swingDirectionSign = swingDirection === 'inward' ? 1 : -1
|
||||
const hingeDirectionSign = hingesSide === 'right' ? 1 : -1
|
||||
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
|
||||
const leafGroup = new THREE.Group()
|
||||
leafGroup.position.set(hingeX, 0, 0)
|
||||
leafGroup.rotation.y = leafSwingRotation
|
||||
mesh.add(leafGroup)
|
||||
const addLeafBox = (
|
||||
material: THREE.Material,
|
||||
w: number,
|
||||
h: number,
|
||||
d: number,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z)
|
||||
|
||||
// ── Frame members ──
|
||||
// Left post — full height
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
frameThickness,
|
||||
height,
|
||||
frameDepth,
|
||||
-width / 2 + frameThickness / 2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
// Right post — full height
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
frameThickness,
|
||||
height,
|
||||
frameDepth,
|
||||
width / 2 - frameThickness / 2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
// Head (top bar) — full width
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
width,
|
||||
frameThickness,
|
||||
frameDepth,
|
||||
0,
|
||||
height / 2 - frameThickness / 2,
|
||||
0,
|
||||
)
|
||||
|
||||
// ── Threshold (inside the frame) ──
|
||||
if (threshold) {
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
leafW,
|
||||
thresholdHeight,
|
||||
frameDepth,
|
||||
0,
|
||||
-height / 2 + thresholdHeight / 2,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Leaf — contentPadding border strips (no full backing; glass areas are open) ──
|
||||
const cpX = contentPadding[0]
|
||||
const cpY = contentPadding[1]
|
||||
if (hasLeafContent && cpY > 0) {
|
||||
// Top strip
|
||||
addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0)
|
||||
// Bottom strip
|
||||
addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0)
|
||||
}
|
||||
if (hasLeafContent && cpX > 0) {
|
||||
const innerH = leafH - 2 * cpY
|
||||
// Left strip
|
||||
addLeafBox(baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0)
|
||||
// Right strip
|
||||
addLeafBox(baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0)
|
||||
}
|
||||
|
||||
// Content area inside padding
|
||||
const contentW = leafW - 2 * cpX
|
||||
const contentH = leafH - 2 * cpY
|
||||
|
||||
// ── Segments (stacked top to bottom within content area) ──
|
||||
const totalRatio = segments.reduce((sum, s) => sum + s.heightRatio, 0)
|
||||
const contentTop = leafCenterY + contentH / 2
|
||||
|
||||
let segY = contentTop
|
||||
for (const seg of segments) {
|
||||
const segH = (seg.heightRatio / totalRatio) * contentH
|
||||
const segCenterY = segY - segH / 2
|
||||
|
||||
const numCols = seg.columnRatios.length
|
||||
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||
const usableW = contentW - (numCols - 1) * seg.dividerThickness
|
||||
const colWidths = seg.columnRatios.map((r) => (r / colSum) * usableW)
|
||||
|
||||
// Column x-centers (relative to mesh center)
|
||||
const colXCenters: number[] = []
|
||||
let cx = -contentW / 2
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
colXCenters.push(cx + colWidths[c]! / 2)
|
||||
cx += colWidths[c]!
|
||||
if (c < numCols - 1) cx += seg.dividerThickness
|
||||
}
|
||||
|
||||
// Column dividers within this segment
|
||||
if (seg.type !== 'empty') {
|
||||
cx = -contentW / 2
|
||||
for (let c = 0; c < numCols - 1; c++) {
|
||||
cx += colWidths[c]!
|
||||
addLeafBox(
|
||||
baseMaterial,
|
||||
seg.dividerThickness,
|
||||
segH,
|
||||
leafDepth + 0.001,
|
||||
cx + seg.dividerThickness / 2,
|
||||
segCenterY,
|
||||
0,
|
||||
)
|
||||
cx += seg.dividerThickness
|
||||
}
|
||||
}
|
||||
|
||||
// Segment content per column
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
const colW = colWidths[c]!
|
||||
const colX = colXCenters[c]!
|
||||
|
||||
if (seg.type === 'glass') {
|
||||
// Glass only — no opaque backing so it's truly transparent
|
||||
const glassDepth = Math.max(0.004, leafDepth * 0.15)
|
||||
addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0)
|
||||
} else if (seg.type === 'panel') {
|
||||
// Opaque leaf backing for this column
|
||||
addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0)
|
||||
// Raised panel detail
|
||||
const panelW = colW - 2 * seg.panelInset
|
||||
const panelH = segH - 2 * seg.panelInset
|
||||
if (panelW > 0.01 && panelH > 0.01) {
|
||||
const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth)
|
||||
const panelZ = leafDepth / 2 + effectiveDepth / 2
|
||||
addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ)
|
||||
}
|
||||
} else {
|
||||
// 'empty' leaves the opening unfilled
|
||||
}
|
||||
}
|
||||
|
||||
segY -= segH
|
||||
}
|
||||
|
||||
// ── Handle ──
|
||||
if (hasLeafContent && handle) {
|
||||
// Convert from floor-based height to mesh-center-based Y
|
||||
const handleY = handleHeight - height / 2
|
||||
// Handle grip sits on the front face (+Z) of the leaf
|
||||
const faceZ = leafDepth / 2
|
||||
|
||||
// X position: handleSide refers to which side the grip is on
|
||||
const handleX = handleSide === 'right' ? leafW / 2 - 0.045 : -leafW / 2 + 0.045
|
||||
|
||||
// Backplate
|
||||
addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005)
|
||||
// Grip lever
|
||||
addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025)
|
||||
}
|
||||
|
||||
// ── Door closer (commercial hardware at top) ──
|
||||
if (hasLeafContent && doorCloser) {
|
||||
const closerY = leafCenterY + leafH / 2 - 0.04
|
||||
// Body
|
||||
addLeafBox(baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03)
|
||||
// Arm (simplified as thin bar to frame side)
|
||||
addLeafBox(baseMaterial, 0.14, 0.015, 0.015, leafW / 4, closerY + 0.025, leafDepth / 2 + 0.015)
|
||||
}
|
||||
|
||||
// ── Panic bar ──
|
||||
if (hasLeafContent && panicBar) {
|
||||
const barY = panicBarHeight - height / 2
|
||||
addLeafBox(baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03)
|
||||
}
|
||||
|
||||
// ── Hinges (3 knuckle-style hinges on the hinge side) ──
|
||||
if (hasLeafContent) {
|
||||
const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012
|
||||
const hingeZ = 0 // centered in leaf depth
|
||||
const hingeH = 0.1
|
||||
const hingeW = 0.024
|
||||
const hingeD = leafDepth + 0.016
|
||||
// Bottom hinge ~0.25m from floor, middle hinge, top hinge ~0.25m from top
|
||||
const leafBottom = leafCenterY - leafH / 2
|
||||
const leafTop = leafCenterY + leafH / 2
|
||||
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafBottom + 0.25, hingeZ)
|
||||
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, (leafBottom + leafTop) / 2, hingeZ)
|
||||
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ)
|
||||
}
|
||||
|
||||
syncDoorCutout(node, mesh)
|
||||
}
|
||||
|
||||
function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
|
||||
// ── Cutout (for wall CSG) — always full door 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
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||
import type { AnyNodeId, FenceNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { getWallCurveFrameAt, getWallCurveLength } from '../wall/wall-curve'
|
||||
|
||||
type FencePart = {
|
||||
position: [number, number, number]
|
||||
rotationY?: number
|
||||
scale: [number, number, number]
|
||||
}
|
||||
|
||||
const MIN_CURVE_SEGMENT_LENGTH = 0.18
|
||||
|
||||
function createFencePartGeometry(part: FencePart) {
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
|
||||
if (part.rotationY) {
|
||||
geometry.rotateY(part.rotationY)
|
||||
}
|
||||
geometry.translate(part.position[0], part.position[1], part.position[2])
|
||||
applyFenceUVs(geometry)
|
||||
return geometry
|
||||
}
|
||||
|
||||
function getFencePointAt(fence: FenceNode, t: number) {
|
||||
const frame = getWallCurveFrameAt(fence, t)
|
||||
return {
|
||||
point: frame.point,
|
||||
tangentAngle: Math.atan2(frame.tangent.y, frame.tangent.x),
|
||||
}
|
||||
}
|
||||
|
||||
function createStraightFenceSpanPart(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
centerY: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
): FencePart | null {
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
const length = Math.hypot(dx, dz)
|
||||
if (length <= 1e-4) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
position: [(start[0] + end[0]) / 2, centerY, (start[1] + end[1]) / 2],
|
||||
rotationY: -Math.atan2(dz, dx),
|
||||
scale: [length, height, depth],
|
||||
}
|
||||
}
|
||||
|
||||
function createFenceCurveSpanParts(
|
||||
fence: FenceNode,
|
||||
startT: number,
|
||||
endT: number,
|
||||
centerY: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
): FencePart[] {
|
||||
const parts: FencePart[] = []
|
||||
const frameCount = Math.max(
|
||||
1,
|
||||
Math.ceil((getWallCurveLength(fence) * Math.max(1e-4, endT - startT)) / MIN_CURVE_SEGMENT_LENGTH),
|
||||
)
|
||||
|
||||
let previous = getFencePointAt(fence, startT)
|
||||
for (let index = 1; index <= frameCount; index += 1) {
|
||||
const t = startT + (endT - startT) * (index / frameCount)
|
||||
const current = getFencePointAt(fence, t)
|
||||
const segment = createStraightFenceSpanPart(
|
||||
[previous.point.x, previous.point.y],
|
||||
[current.point.x, current.point.y],
|
||||
centerY,
|
||||
height,
|
||||
depth,
|
||||
)
|
||||
if (segment) {
|
||||
parts.push(segment)
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
function applyFenceUVs(geometry: THREE.BufferGeometry) {
|
||||
const position = geometry.getAttribute('position')
|
||||
const normal = geometry.getAttribute('normal')
|
||||
|
||||
if (!(position && normal)) return
|
||||
|
||||
const uvs = new Float32Array(position.count * 2)
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let minZ = Number.POSITIVE_INFINITY
|
||||
|
||||
for (let index = 0; index < position.count; index += 1) {
|
||||
minX = Math.min(minX, position.getX(index))
|
||||
minY = Math.min(minY, position.getY(index))
|
||||
minZ = Math.min(minZ, position.getZ(index))
|
||||
}
|
||||
|
||||
for (let index = 0; index < position.count; index += 1) {
|
||||
const px = position.getX(index)
|
||||
const py = position.getY(index)
|
||||
const pz = position.getZ(index)
|
||||
const nx = Math.abs(normal.getX(index))
|
||||
const ny = Math.abs(normal.getY(index))
|
||||
const nz = Math.abs(normal.getZ(index))
|
||||
|
||||
let u = 0
|
||||
let v = 0
|
||||
|
||||
if (ny >= nx && ny >= nz) {
|
||||
u = px - minX
|
||||
v = pz - minZ
|
||||
} else if (nx >= nz) {
|
||||
u = pz - minZ
|
||||
v = py - minY
|
||||
} else {
|
||||
u = px - minX
|
||||
v = py - minY
|
||||
}
|
||||
|
||||
uvs[index * 2] = u
|
||||
uvs[index * 2 + 1] = v
|
||||
}
|
||||
|
||||
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||||
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2))
|
||||
}
|
||||
|
||||
function getStyleDefaults(style: FenceNode['style']) {
|
||||
if (style === 'privacy') {
|
||||
return { spacingFactor: 0.42, postFactor: 1.35, baseFactor: 1.2, topFactor: 1.2 }
|
||||
}
|
||||
|
||||
if (style === 'rail') {
|
||||
return { spacingFactor: 0.68, postFactor: 0.8, baseFactor: 0.85, topFactor: 0.85 }
|
||||
}
|
||||
|
||||
return { spacingFactor: 0.3, postFactor: 0.55, baseFactor: 1, topFactor: 0.75 }
|
||||
}
|
||||
|
||||
function createFenceParts(fence: FenceNode): FencePart[] {
|
||||
const parts: FencePart[] = []
|
||||
const length = Math.max(getWallCurveLength(fence), 0.01)
|
||||
const panelDepth = Math.max(fence.thickness, 0.03)
|
||||
const clearance = Math.max(fence.groundClearance, 0)
|
||||
const styleDefaults = getStyleDefaults(fence.style)
|
||||
const baseHeight = Math.max(fence.baseHeight * styleDefaults.baseFactor, 0.04)
|
||||
const topRailHeight = Math.max(fence.topRailHeight * styleDefaults.topFactor, 0.01)
|
||||
const verticalHeight = Math.max(fence.height - baseHeight - topRailHeight, 0.08)
|
||||
const postWidth = Math.max(fence.postSize * styleDefaults.postFactor, 0.01)
|
||||
const spacing = Math.max(fence.postSpacing * styleDefaults.spacingFactor, postWidth * 1.2)
|
||||
const edgeInset = Math.max(fence.edgeInset ?? 0.015, 0.005)
|
||||
const isFloating = fence.baseStyle === 'floating'
|
||||
const baseY = isFloating ? clearance : 0
|
||||
const effectiveBaseHeight = baseHeight
|
||||
const startInsetT = Math.min(0.499, edgeInset / length)
|
||||
const endInsetT = Math.max(0.501, 1 - edgeInset / length)
|
||||
|
||||
if (!isFloating) {
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight / 2,
|
||||
effectiveBaseHeight,
|
||||
panelDepth * 1.05,
|
||||
),
|
||||
)
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight + verticalHeight * 0.15,
|
||||
topRailHeight * 0.8,
|
||||
panelDepth * 0.35,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1)
|
||||
const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1))
|
||||
const frame = getFencePointAt(fence, t)
|
||||
const isEdgePost = index === 0 || index === count - 1
|
||||
const postHeight =
|
||||
isFloating && isEdgePost
|
||||
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
|
||||
: verticalHeight
|
||||
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY
|
||||
|
||||
parts.push({
|
||||
position: [frame.point.x, postY, frame.point.y],
|
||||
rotationY: -frame.tangentAngle,
|
||||
scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)],
|
||||
})
|
||||
}
|
||||
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2,
|
||||
topRailHeight,
|
||||
Math.max(panelDepth * 0.55, 0.018),
|
||||
),
|
||||
)
|
||||
|
||||
if (isFloating) {
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight + topRailHeight / 2,
|
||||
topRailHeight,
|
||||
Math.max(panelDepth * 0.55, 0.018),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
function generateFenceGeometry(fence: FenceNode) {
|
||||
const parts = createFenceParts(fence)
|
||||
const geometries = parts.map(createFencePartGeometry)
|
||||
|
||||
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
|
||||
geometries.forEach((geometry) => geometry.dispose())
|
||||
const mergedUv = merged.getAttribute('uv')
|
||||
if (mergedUv) {
|
||||
merged.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(mergedUv.array), 2))
|
||||
}
|
||||
merged.computeVertexNormals()
|
||||
return merged
|
||||
}
|
||||
|
||||
function updateFenceGeometry(fenceId: FenceNode['id']) {
|
||||
const node = useScene.getState().nodes[fenceId]
|
||||
if (!node || node.type !== 'fence') return
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Mesh | undefined
|
||||
if (!mesh) return
|
||||
|
||||
const newGeometry = generateFenceGeometry(node)
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = newGeometry
|
||||
mesh.position.set(0, 0, 0)
|
||||
mesh.rotation.set(0, 0, 0)
|
||||
}
|
||||
|
||||
export const FenceSystem = () => {
|
||||
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 !== 'fence') return
|
||||
updateFenceGeometry(id as FenceNode['id'])
|
||||
clearDirty(id as AnyNodeId)
|
||||
})
|
||||
}, 4)
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import type * as THREE from 'three'
|
||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||
import { type AnyNodeId, getScaledDimensions, type ItemNode, type WallNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
|
||||
// ============================================================================
|
||||
// ITEM SYSTEM
|
||||
// ============================================================================
|
||||
|
||||
export const ItemSystem = () => {
|
||||
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 !== 'item') return
|
||||
|
||||
const item = node as ItemNode
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D
|
||||
if (!mesh) return
|
||||
|
||||
if (item.asset.attachTo === 'wall-side') {
|
||||
// Wall-attached item: offset Z by half the parent wall's thickness
|
||||
const parentWall = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
|
||||
if (parentWall && parentWall.type === 'wall') {
|
||||
const wallThickness = (parentWall as WallNode).thickness ?? 0.1
|
||||
const side = item.side === 'front' ? 1 : -1
|
||||
mesh.position.z = (wallThickness / 2) * side
|
||||
}
|
||||
} else if (!item.asset.attachTo) {
|
||||
// If parented to another item (surface placement), R3F handles positioning via the hierarchy
|
||||
const parentNode = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
|
||||
if (parentNode?.type !== 'item') {
|
||||
// Floor item: elevate by slab height (using full footprint overlap)
|
||||
const levelId = resolveLevelId(item, nodes)
|
||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||
levelId,
|
||||
item.position,
|
||||
getScaledDimensions(item),
|
||||
item.rotation,
|
||||
)
|
||||
mesh.position.y = slabElevation + item.position[1]
|
||||
}
|
||||
}
|
||||
|
||||
clearDirty(id as AnyNodeId)
|
||||
})
|
||||
}, 2)
|
||||
|
||||
return null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,181 +0,0 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
|
||||
import type { AnyNodeId, SlabNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!uv) return
|
||||
|
||||
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SLAB SYSTEM
|
||||
// ============================================================================
|
||||
|
||||
export const SlabSystem = () => {
|
||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||
const clearDirty = useScene((state) => state.clearDirty)
|
||||
|
||||
useFrame(() => {
|
||||
if (dirtyNodes.size === 0) return
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
|
||||
// Process dirty slabs
|
||||
dirtyNodes.forEach((id) => {
|
||||
const node = nodes[id]
|
||||
if (!node || node.type !== 'slab') return
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
|
||||
if (mesh) {
|
||||
updateSlabGeometry(node as SlabNode, mesh)
|
||||
clearDirty(id as AnyNodeId)
|
||||
}
|
||||
// If mesh not found, keep it dirty for next frame
|
||||
})
|
||||
}, 1)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the geometry for a single slab
|
||||
*/
|
||||
function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) {
|
||||
const newGeo = generateSlabGeometry(node)
|
||||
ensureUv2Attribute(newGeo)
|
||||
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = newGeo
|
||||
|
||||
// For negative elevation, shift the mesh down so the top face sits at Y=elevation
|
||||
// rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level).
|
||||
const elevation = node.elevation ?? 0.05
|
||||
mesh.position.y = elevation < 0 ? elevation : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates extruded slab geometry from polygon
|
||||
*/
|
||||
export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
||||
const elevation = slabNode.elevation ?? 0.05
|
||||
return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard slab: flat extrusion upward from Y=0 by elevation thickness.
|
||||
*/
|
||||
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
||||
const polygon = getRenderableSlabPolygon(slabNode)
|
||||
const elevation = slabNode.elevation ?? 0.05
|
||||
|
||||
if (polygon.length < 3) return new THREE.BufferGeometry()
|
||||
|
||||
const shape = new THREE.Shape()
|
||||
shape.moveTo(polygon[0]![0], -polygon[0]![1])
|
||||
for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1])
|
||||
shape.closePath()
|
||||
|
||||
for (const holePolygon of slabNode.holes ?? []) {
|
||||
if (holePolygon.length < 3) continue
|
||||
const holePath = new THREE.Path()
|
||||
holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1])
|
||||
for (let i = 1; i < holePolygon.length; i++)
|
||||
holePath.lineTo(holePolygon[i]![0], -holePolygon[i]![1])
|
||||
holePath.closePath()
|
||||
shape.holes.push(holePath)
|
||||
}
|
||||
|
||||
const geometry = new THREE.ExtrudeGeometry(shape, { depth: elevation, bevelEnabled: false })
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeVertexNormals()
|
||||
return geometry
|
||||
}
|
||||
|
||||
/**
|
||||
* Pool / recessed slab: floor cap at Y=0 (local) + inner walls up to Y=|elevation|.
|
||||
* No top cap — the opening at ground level is handled by the ground occluder hole.
|
||||
* mesh.position.y must be set to elevation so the floor sits at the correct world Y.
|
||||
*
|
||||
* Geometry is built directly in 3D (Y-up) to avoid rotation confusion:
|
||||
* - floor in XZ plane at Y=0, normals pointing +Y (visible when looking down into pool)
|
||||
* - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool)
|
||||
*/
|
||||
function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
||||
const polygon = getRenderableSlabPolygon(slabNode)
|
||||
const depth = Math.abs(slabNode.elevation ?? 0.05)
|
||||
|
||||
if (polygon.length < 3) return new THREE.BufferGeometry()
|
||||
|
||||
const positions: number[] = []
|
||||
const uvs: number[] = []
|
||||
const indices: number[] = []
|
||||
const n = polygon.length
|
||||
const bounds = new THREE.Box2()
|
||||
|
||||
for (const [x, z] of polygon) {
|
||||
bounds.expandByPoint(new THREE.Vector2(x, z))
|
||||
}
|
||||
for (const hole of slabNode.holes ?? []) {
|
||||
for (const [x, z] of hole) {
|
||||
bounds.expandByPoint(new THREE.Vector2(x, z))
|
||||
}
|
||||
}
|
||||
|
||||
const floorWidth = Math.max(bounds.max.x - bounds.min.x, 0.001)
|
||||
const floorHeight = Math.max(bounds.max.y - bounds.min.y, 0.001)
|
||||
|
||||
const pushFloorVertex = (x: number, y: number, z: number) => {
|
||||
positions.push(x, y, z)
|
||||
uvs.push((x - bounds.min.x) / floorWidth, (z - bounds.min.y) / floorHeight)
|
||||
}
|
||||
|
||||
const pushWallVertex = (x: number, y: number, z: number, u: number, v: number) => {
|
||||
positions.push(x, y, z)
|
||||
uvs.push(u, v)
|
||||
}
|
||||
|
||||
// --- Floor at Y=0 ---
|
||||
for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!)
|
||||
|
||||
const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
|
||||
const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
|
||||
for (const hole of slabNode.holes ?? []) {
|
||||
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!)
|
||||
}
|
||||
|
||||
const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d)
|
||||
for (const tri of floorTris) {
|
||||
// Reversed winding → normals point +Y (upward) in XZ plane
|
||||
indices.push(tri[0]!, tri[2]!, tri[1]!)
|
||||
}
|
||||
|
||||
// --- Inner walls (no top cap at Y=depth) ---
|
||||
// Standard winding on a CCW polygon in XZ gives inward-facing normals.
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
const [x0, z0] = polygon[i]!
|
||||
const [x1, z1] = polygon[j]!
|
||||
const vBase = positions.length / 3
|
||||
const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001)
|
||||
|
||||
pushWallVertex(x0!, 0, z0!, 0, 0) // v0 — floor level
|
||||
pushWallVertex(x1!, 0, z1!, segmentLength, 0) // v1 — floor level
|
||||
pushWallVertex(x1!, depth, z1!, segmentLength, depth) // v2 — ground level
|
||||
pushWallVertex(x0!, depth, z0!, 0, depth) // v3 — ground level
|
||||
|
||||
indices.push(vBase, vBase + 1, vBase + 2)
|
||||
indices.push(vBase, vBase + 2, vBase + 3)
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry()
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||||
geo.setIndex(indices)
|
||||
geo.computeVertexNormals()
|
||||
return geo
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,792 +0,0 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
|
||||
import { computeBoundsTree } from 'three-mesh-bvh'
|
||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||
import type { AnyNode, AnyNodeId, DoorNode, WallNode, WindowNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
|
||||
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
|
||||
import {
|
||||
calculateLevelMiters,
|
||||
getAdjacentWallIds,
|
||||
getWallMiterBoundaryPoints,
|
||||
type Point2D,
|
||||
pointToKey,
|
||||
type WallMiterData,
|
||||
} from './wall-mitering'
|
||||
|
||||
// Reusable CSG evaluator for better performance
|
||||
const csgEvaluator = new Evaluator()
|
||||
const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015
|
||||
const WALL_FACE_NORMAL_Y_EPSILON = 0.6
|
||||
const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003
|
||||
|
||||
type WallBoundaryEdgeTag = 'front' | 'back' | 'base'
|
||||
|
||||
type TaggedWallBoundaryEdge = {
|
||||
start: THREE.Vector2
|
||||
end: THREE.Vector2
|
||||
tag: WallBoundaryEdgeTag
|
||||
}
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!uv) return
|
||||
|
||||
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
|
||||
}
|
||||
|
||||
function insetCurvedWallBoundaryPointsFor3D(
|
||||
wall: WallNode,
|
||||
boundaryPoints: ReturnType<typeof getWallMiterBoundaryPoints>,
|
||||
miterData: WallMiterData,
|
||||
) {
|
||||
if (!boundaryPoints || !isCurvedWall(wall)) {
|
||||
return boundaryPoints
|
||||
}
|
||||
|
||||
const insetDistance = Math.min(
|
||||
CURVED_WALL_3D_ENDPOINT_INSET,
|
||||
Math.max((wall.thickness ?? 0.1) * 0.01, 0.0005),
|
||||
)
|
||||
|
||||
if (insetDistance <= 0) {
|
||||
return boundaryPoints
|
||||
}
|
||||
|
||||
const next = { ...boundaryPoints }
|
||||
const startJunction = miterData.junctions.get(pointToKey({ x: wall.start[0], y: wall.start[1] }))
|
||||
const endJunction = miterData.junctions.get(pointToKey({ x: wall.end[0], y: wall.end[1] }))
|
||||
|
||||
if (startJunction && startJunction.connectedWalls.length > 1) {
|
||||
const frame = getWallCurveFrameAt(wall, 0)
|
||||
next.startLeft = {
|
||||
x: next.startLeft.x + frame.tangent.x * insetDistance,
|
||||
y: next.startLeft.y + frame.tangent.y * insetDistance,
|
||||
}
|
||||
next.startRight = {
|
||||
x: next.startRight.x + frame.tangent.x * insetDistance,
|
||||
y: next.startRight.y + frame.tangent.y * insetDistance,
|
||||
}
|
||||
}
|
||||
|
||||
if (endJunction && endJunction.connectedWalls.length > 1) {
|
||||
const frame = getWallCurveFrameAt(wall, 1)
|
||||
next.endLeft = {
|
||||
x: next.endLeft.x - frame.tangent.x * insetDistance,
|
||||
y: next.endLeft.y - frame.tangent.y * insetDistance,
|
||||
}
|
||||
next.endRight = {
|
||||
x: next.endRight.x - frame.tangent.x * insetDistance,
|
||||
y: next.endRight.y - frame.tangent.y * insetDistance,
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function addTaggedWallBoundaryEdge(
|
||||
edges: TaggedWallBoundaryEdge[],
|
||||
points: { x: number; z: number }[],
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
tag: WallBoundaryEdgeTag,
|
||||
) {
|
||||
const start = points[startIndex]
|
||||
const end = points[endIndex]
|
||||
if (!(start && end)) return
|
||||
if (Math.hypot(end.x - start.x, end.z - start.z) < 1e-6) return
|
||||
|
||||
edges.push({
|
||||
start: new THREE.Vector2(start.x, start.z),
|
||||
end: new THREE.Vector2(end.x, end.z),
|
||||
tag,
|
||||
})
|
||||
}
|
||||
|
||||
function buildTaggedWallBoundaryEdges(
|
||||
wall: WallNode,
|
||||
localPoints: { x: number; z: number }[],
|
||||
miterData: WallMiterData,
|
||||
): TaggedWallBoundaryEdge[] {
|
||||
if (localPoints.length < 2) return []
|
||||
|
||||
const edges: TaggedWallBoundaryEdge[] = []
|
||||
|
||||
if (isCurvedWall(wall)) {
|
||||
const sidePointCount = Math.floor(localPoints.length / 2)
|
||||
if (sidePointCount < 2) return edges
|
||||
|
||||
for (let index = 0; index < sidePointCount - 1; index += 1) {
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'back')
|
||||
}
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, sidePointCount - 1, sidePointCount, 'base')
|
||||
|
||||
for (let index = sidePointCount; index < localPoints.length - 1; index += 1) {
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'front')
|
||||
}
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base')
|
||||
return edges
|
||||
}
|
||||
|
||||
const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] })
|
||||
const startJunction = miterData.junctionData.get(startKey)?.get(wall.id)
|
||||
const startLeftIndex = startJunction ? localPoints.length - 2 : localPoints.length - 1
|
||||
const endLeftIndex = startJunction ? localPoints.length - 3 : localPoints.length - 2
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, 0, 1, 'back')
|
||||
|
||||
for (let index = 1; index < endLeftIndex; index += 1) {
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base')
|
||||
}
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, endLeftIndex, startLeftIndex, 'front')
|
||||
|
||||
for (let index = startLeftIndex; index < localPoints.length - 1; index += 1) {
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base')
|
||||
}
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base')
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
function distanceToWallBoundaryEdge(point: THREE.Vector2, edge: TaggedWallBoundaryEdge): number {
|
||||
const edgeDx = edge.end.x - edge.start.x
|
||||
const edgeDz = edge.end.y - edge.start.y
|
||||
const pointDx = point.x - edge.start.x
|
||||
const pointDz = point.y - edge.start.y
|
||||
const edgeLengthSq = edgeDx * edgeDx + edgeDz * edgeDz
|
||||
|
||||
if (edgeLengthSq < 1e-12) {
|
||||
return point.distanceTo(edge.start)
|
||||
}
|
||||
|
||||
const t = THREE.MathUtils.clamp((pointDx * edgeDx + pointDz * edgeDz) / edgeLengthSq, 0, 1)
|
||||
const closestX = edge.start.x + edgeDx * t
|
||||
const closestZ = edge.start.y + edgeDz * t
|
||||
|
||||
return Math.hypot(point.x - closestX, point.y - closestZ)
|
||||
}
|
||||
|
||||
function getWallFaceMaterialIndex(
|
||||
wall: Pick<WallNode, 'frontSide' | 'backSide'>,
|
||||
face: 'front' | 'back',
|
||||
): 0 | 1 | 2 {
|
||||
const semantic = face === 'front' ? wall.frontSide : wall.backSide
|
||||
const fallback = face === 'front' ? 1 : 2
|
||||
|
||||
if (semantic === 'interior') return 1
|
||||
if (semantic === 'exterior') return 2
|
||||
return fallback
|
||||
}
|
||||
|
||||
function assignWallMaterialGroups(
|
||||
geometry: THREE.BufferGeometry,
|
||||
wall: WallNode,
|
||||
boundaryEdges: TaggedWallBoundaryEdge[],
|
||||
) {
|
||||
const position = geometry.getAttribute('position')
|
||||
if (!position) return
|
||||
|
||||
const index = geometry.getIndex()
|
||||
const triangleCount = index ? Math.floor(index.count / 3) : Math.floor(position.count / 3)
|
||||
if (triangleCount === 0) {
|
||||
geometry.clearGroups()
|
||||
return
|
||||
}
|
||||
|
||||
const triangleMaterials = new Array<number>(triangleCount).fill(0)
|
||||
const a = new THREE.Vector3()
|
||||
const b = new THREE.Vector3()
|
||||
const c = new THREE.Vector3()
|
||||
const ab = new THREE.Vector3()
|
||||
const ac = new THREE.Vector3()
|
||||
const normal = new THREE.Vector3()
|
||||
const centroid = new THREE.Vector3()
|
||||
const projectedCentroid = new THREE.Vector2()
|
||||
const maxBoundaryDistance = Math.max(
|
||||
getWallThickness(wall) * 0.02,
|
||||
WALL_FACE_EDGE_DISTANCE_EPSILON,
|
||||
)
|
||||
|
||||
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
|
||||
const baseIndex = triangleIndex * 3
|
||||
const ia = index ? index.getX(baseIndex) : baseIndex
|
||||
const ib = index ? index.getX(baseIndex + 1) : baseIndex + 1
|
||||
const ic = index ? index.getX(baseIndex + 2) : baseIndex + 2
|
||||
|
||||
a.fromBufferAttribute(position, ia)
|
||||
b.fromBufferAttribute(position, ib)
|
||||
c.fromBufferAttribute(position, ic)
|
||||
|
||||
ab.subVectors(b, a)
|
||||
ac.subVectors(c, a)
|
||||
normal.crossVectors(ab, ac)
|
||||
|
||||
if (normal.lengthSq() < 1e-12) {
|
||||
triangleMaterials[triangleIndex] = 0
|
||||
continue
|
||||
}
|
||||
|
||||
normal.normalize()
|
||||
|
||||
if (Math.abs(normal.y) >= WALL_FACE_NORMAL_Y_EPSILON) {
|
||||
triangleMaterials[triangleIndex] = 0
|
||||
continue
|
||||
}
|
||||
|
||||
centroid
|
||||
.copy(a)
|
||||
.add(b)
|
||||
.add(c)
|
||||
.multiplyScalar(1 / 3)
|
||||
projectedCentroid.set(centroid.x, centroid.z)
|
||||
|
||||
let nearestTag: WallBoundaryEdgeTag | null = null
|
||||
let nearestDistance = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const edge of boundaryEdges) {
|
||||
const distance = distanceToWallBoundaryEdge(projectedCentroid, edge)
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance
|
||||
nearestTag = edge.tag
|
||||
}
|
||||
}
|
||||
|
||||
if (!nearestTag || nearestDistance > maxBoundaryDistance) {
|
||||
triangleMaterials[triangleIndex] = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (nearestTag === 'base') {
|
||||
triangleMaterials[triangleIndex] = 0
|
||||
continue
|
||||
}
|
||||
|
||||
triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag)
|
||||
}
|
||||
|
||||
geometry.clearGroups()
|
||||
|
||||
let currentMaterial = triangleMaterials[0] ?? 0
|
||||
let groupStart = 0
|
||||
|
||||
for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) {
|
||||
const materialIndex = triangleMaterials[triangleIndex] ?? 0
|
||||
if (materialIndex === currentMaterial) continue
|
||||
|
||||
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
|
||||
groupStart = triangleIndex
|
||||
currentMaterial = materialIndex
|
||||
}
|
||||
|
||||
geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WALL SYSTEM
|
||||
// ============================================================================
|
||||
|
||||
let useFrameNb = 0
|
||||
export const WallSystem = () => {
|
||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||
const clearDirty = useScene((state) => state.clearDirty)
|
||||
|
||||
useFrame(() => {
|
||||
if (dirtyNodes.size === 0) return
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
|
||||
// Collect dirty walls and their levels
|
||||
const dirtyWallsByLevel = new Map<string, Set<string>>()
|
||||
|
||||
useFrameNb += 1
|
||||
dirtyNodes.forEach((id) => {
|
||||
const node = nodes[id]
|
||||
if (!node || node.type !== 'wall') return
|
||||
|
||||
const levelId = node.parentId
|
||||
if (!levelId) return
|
||||
|
||||
if (!dirtyWallsByLevel.has(levelId)) {
|
||||
dirtyWallsByLevel.set(levelId, new Set())
|
||||
}
|
||||
dirtyWallsByLevel.get(levelId)?.add(id)
|
||||
})
|
||||
|
||||
// Process each level that has dirty walls
|
||||
for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) {
|
||||
const levelWalls = getLevelWalls(levelId)
|
||||
const miterData = calculateLevelMiters(levelWalls)
|
||||
|
||||
// Update dirty walls
|
||||
for (const wallId of dirtyWallIds) {
|
||||
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
|
||||
if (mesh) {
|
||||
updateWallGeometry(wallId, miterData)
|
||||
clearDirty(wallId as AnyNodeId)
|
||||
}
|
||||
// If mesh not found, keep it dirty for next frame
|
||||
}
|
||||
|
||||
// Update adjacent walls that share junctions
|
||||
const adjacentWallIds = getAdjacentWallIds(levelWalls, dirtyWallIds)
|
||||
for (const wallId of adjacentWallIds) {
|
||||
if (!dirtyWallIds.has(wallId)) {
|
||||
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
|
||||
if (mesh) {
|
||||
updateWallGeometry(wallId, miterData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 4)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all walls that belong to a level
|
||||
*/
|
||||
function getLevelWalls(levelId: string): WallNode[] {
|
||||
const { nodes } = useScene.getState()
|
||||
const level = nodes[levelId as AnyNodeId]
|
||||
|
||||
if (!level || level.type !== 'level') return []
|
||||
|
||||
const walls: WallNode[] = []
|
||||
for (const childId of level.children) {
|
||||
const child = nodes[childId]
|
||||
if (child?.type === 'wall') {
|
||||
walls.push(child as WallNode)
|
||||
}
|
||||
}
|
||||
|
||||
return walls
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the geometry for a single wall
|
||||
*/
|
||||
function updateWallGeometry(wallId: string, miterData: WallMiterData) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const node = nodes[wallId as WallNode['id']]
|
||||
if (!node || node.type !== 'wall') return
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
|
||||
if (!mesh) return
|
||||
|
||||
const levelId = resolveLevelId(node, nodes)
|
||||
const slabElevation = spatialGridManager.getSlabElevationForWall(levelId, node.start, node.end)
|
||||
|
||||
const childrenIds = node.children || []
|
||||
const childrenNodes = childrenIds
|
||||
.map((childId) => nodes[childId])
|
||||
.filter((n): n is AnyNode => n !== undefined)
|
||||
|
||||
const newGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation)
|
||||
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = newGeo
|
||||
// Update collision mesh
|
||||
const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh
|
||||
if (collisionMesh) {
|
||||
const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation)
|
||||
collisionMesh.geometry.dispose()
|
||||
collisionMesh.geometry = collisionGeo
|
||||
}
|
||||
|
||||
mesh.position.set(node.start[0], slabElevation, node.start[1])
|
||||
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
|
||||
mesh.rotation.y = -angle
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates extruded wall geometry with mitering and cutouts
|
||||
*
|
||||
* Key insight from demo: polygon is built in WORLD coordinates first,
|
||||
* then we transform to wall-local for the 3D mesh.
|
||||
*/
|
||||
export function generateExtrudedWall(
|
||||
wallNode: WallNode,
|
||||
childrenNodes: AnyNode[],
|
||||
miterData: WallMiterData,
|
||||
slabElevation = 0,
|
||||
) {
|
||||
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
|
||||
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
|
||||
// Positive slab: shift the whole wall up (full height preserved)
|
||||
// Negative slab: extend wall downward so top stays fixed at wallNode.height
|
||||
const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT
|
||||
const height = slabElevation > 0 ? wallHeight : wallHeight - slabElevation
|
||||
|
||||
const thickness = getWallThickness(wallNode)
|
||||
|
||||
// Wall direction and normal (exactly like demo)
|
||||
const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y }
|
||||
const L = Math.sqrt(v.x * v.x + v.y * v.y)
|
||||
if (L < 1e-9) {
|
||||
return new THREE.BufferGeometry()
|
||||
}
|
||||
const boundaryPoints = getWallMiterBoundaryPoints(wallNode, miterData)
|
||||
const polyPoints = isCurvedWall(wallNode)
|
||||
? getWallSurfacePolygon(
|
||||
wallNode,
|
||||
24,
|
||||
insetCurvedWallBoundaryPointsFor3D(wallNode, boundaryPoints, miterData) ?? undefined,
|
||||
)
|
||||
: getWallPlanFootprint(wallNode, miterData)
|
||||
if (polyPoints.length < 3) {
|
||||
return new THREE.BufferGeometry()
|
||||
}
|
||||
|
||||
// Transform world coordinates to wall-local coordinates
|
||||
// Wall-local: x along wall, z perpendicular (thickness direction)
|
||||
const wallAngle = Math.atan2(v.y, v.x)
|
||||
const cosA = Math.cos(-wallAngle)
|
||||
const sinA = Math.sin(-wallAngle)
|
||||
|
||||
const worldToLocal = (worldPt: Point2D): { x: number; z: number } => {
|
||||
const dx = worldPt.x - wallStart.x
|
||||
const dy = worldPt.y - wallStart.y
|
||||
return {
|
||||
x: dx * cosA - dy * sinA,
|
||||
z: dx * sinA + dy * cosA,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert polygon to local coordinates
|
||||
const localPoints = polyPoints.map(worldToLocal)
|
||||
const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData)
|
||||
|
||||
// Build THREE.js shape
|
||||
// Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z
|
||||
// The negation is needed because after rotateX(-PI/2), shape.y becomes -geometry.z
|
||||
const footprint = new THREE.Shape()
|
||||
footprint.moveTo(localPoints[0]!.x, -localPoints[0]!.z)
|
||||
for (let i = 1; i < localPoints.length; i++) {
|
||||
footprint.lineTo(localPoints[i]!.x, -localPoints[i]!.z)
|
||||
}
|
||||
footprint.closePath()
|
||||
|
||||
// Extrude along Z by height
|
||||
const geometry = new THREE.ExtrudeGeometry(footprint, {
|
||||
depth: height,
|
||||
bevelEnabled: false,
|
||||
})
|
||||
|
||||
// Rotate so extrusion direction (Z) becomes height direction (Y)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(geometry, wallNode, boundaryEdges)
|
||||
ensureUv2Attribute(geometry)
|
||||
|
||||
// Apply CSG subtraction for cutouts (doors/windows)
|
||||
const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness)
|
||||
if (cutoutBrushes.length === 0) {
|
||||
return geometry
|
||||
}
|
||||
|
||||
// Create wall brush from geometry
|
||||
// Pre-compute BVH with new API to avoid deprecation warning
|
||||
geometry.computeBoundsTree = computeBoundsTree
|
||||
geometry.computeBoundsTree({ maxLeafSize: 10 })
|
||||
|
||||
const wallBrush = new Brush(geometry)
|
||||
wallBrush.updateMatrixWorld()
|
||||
|
||||
// Subtract each cutout from the wall
|
||||
let resultBrush = wallBrush
|
||||
for (const cutoutBrush of cutoutBrushes) {
|
||||
cutoutBrush.updateMatrixWorld()
|
||||
const newResult = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION)
|
||||
if (resultBrush !== wallBrush) {
|
||||
resultBrush.geometry.dispose()
|
||||
}
|
||||
resultBrush = newResult
|
||||
}
|
||||
|
||||
// Clean up
|
||||
wallBrush.geometry.dispose()
|
||||
for (const brush of cutoutBrushes) {
|
||||
brush.geometry.dispose()
|
||||
}
|
||||
|
||||
const resultGeometry = resultBrush.geometry
|
||||
resultGeometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges)
|
||||
ensureUv2Attribute(resultGeometry)
|
||||
|
||||
return resultGeometry
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects cutout brushes from child items for CSG subtraction
|
||||
* The cutout mesh is a plane, so we extrude it into a box that goes through the wall
|
||||
*/
|
||||
function collectCutoutBrushes(
|
||||
wallNode: WallNode,
|
||||
childrenNodes: AnyNode[],
|
||||
wallThickness: number,
|
||||
): Brush[] {
|
||||
const brushes: Brush[] = []
|
||||
const wallMesh = sceneRegistry.nodes.get(wallNode.id) as THREE.Mesh
|
||||
if (!wallMesh) return brushes
|
||||
|
||||
// Get wall's world matrix inverse to transform cutouts to wall-local space
|
||||
wallMesh.updateMatrixWorld()
|
||||
const wallMatrixInverse = wallMesh.matrixWorld.clone().invert()
|
||||
|
||||
for (const child of childrenNodes) {
|
||||
if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue
|
||||
|
||||
if (
|
||||
(child.type === 'door' && child.openingKind === 'opening') ||
|
||||
(child.type === 'window' && child.openingKind === 'opening')
|
||||
) {
|
||||
brushes.push(createShapedOpeningCutoutBrush(child, wallThickness))
|
||||
continue
|
||||
}
|
||||
|
||||
const childMesh = sceneRegistry.nodes.get(child.id)
|
||||
if (!childMesh) continue
|
||||
|
||||
const cutoutMesh = childMesh.getObjectByName('cutout') as THREE.Mesh
|
||||
if (!cutoutMesh) continue
|
||||
|
||||
// Get the cutout's bounding box in world space
|
||||
cutoutMesh.updateMatrixWorld()
|
||||
const positions = cutoutMesh.geometry?.attributes?.position
|
||||
if (!positions) continue
|
||||
|
||||
// Calculate bounds in wall-local space
|
||||
const v3 = new THREE.Vector3()
|
||||
let minX = Number.POSITIVE_INFINITY,
|
||||
maxX = Number.NEGATIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY,
|
||||
maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
v3.fromBufferAttribute(positions, i)
|
||||
v3.applyMatrix4(cutoutMesh.matrixWorld)
|
||||
v3.applyMatrix4(wallMatrixInverse)
|
||||
|
||||
minX = Math.min(minX, v3.x)
|
||||
maxX = Math.max(maxX, v3.x)
|
||||
minY = Math.min(minY, v3.y)
|
||||
maxY = Math.max(maxY, v3.y)
|
||||
}
|
||||
|
||||
if (!Number.isFinite(minX)) continue
|
||||
|
||||
// Create a box geometry that extends through the wall thickness
|
||||
const width = maxX - minX
|
||||
const height = maxY - minY
|
||||
const depth = wallThickness * 2 // Extend beyond wall to ensure clean cut
|
||||
|
||||
const boxGeo = new THREE.BoxGeometry(width, height, depth)
|
||||
// Position box at the center of the cutout
|
||||
boxGeo.translate(
|
||||
minX + width / 2,
|
||||
minY + height / 2,
|
||||
0, // Center on Z axis (wall thickness direction)
|
||||
)
|
||||
|
||||
// Pre-compute BVH with new API to avoid deprecation warning
|
||||
boxGeo.computeBoundsTree = computeBoundsTree
|
||||
boxGeo.computeBoundsTree({ maxLeafSize: 10 })
|
||||
|
||||
const brush = new Brush(boxGeo)
|
||||
brushes.push(brush)
|
||||
}
|
||||
|
||||
return brushes
|
||||
}
|
||||
|
||||
type ShapedOpeningNode = DoorNode | WindowNode
|
||||
type CornerRadii = {
|
||||
topLeft: number
|
||||
topRight: number
|
||||
bottomRight: number
|
||||
bottomLeft: number
|
||||
}
|
||||
|
||||
function createShapedOpeningCutoutBrush(opening: ShapedOpeningNode, wallThickness: number): Brush {
|
||||
const shape = createShapedOpeningCutoutShape(opening)
|
||||
const depth = wallThickness * 2
|
||||
const bevelSize =
|
||||
opening.openingShape === 'rounded'
|
||||
? Math.min(
|
||||
Math.max(opening.openingRevealRadius ?? 0.025, 0),
|
||||
Math.max(wallThickness * 0.45, 0.001),
|
||||
Math.max((opening.cornerRadius ?? 0.15) * 0.45, 0.001),
|
||||
)
|
||||
: 0
|
||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||
depth,
|
||||
bevelEnabled: bevelSize > 0,
|
||||
bevelSegments: bevelSize > 0 ? 8 : 0,
|
||||
bevelSize,
|
||||
bevelThickness: bevelSize,
|
||||
curveSegments: 24,
|
||||
})
|
||||
|
||||
geometry.translate(0, 0, -depth / 2)
|
||||
geometry.computeBoundsTree = computeBoundsTree
|
||||
geometry.computeBoundsTree({ maxLeafSize: 10 })
|
||||
|
||||
return new Brush(geometry)
|
||||
}
|
||||
|
||||
function createShapedOpeningCutoutShape(opening: ShapedOpeningNode): THREE.Shape {
|
||||
const halfWidth = opening.width / 2
|
||||
const bottom = opening.position[1] - opening.height / 2
|
||||
const top = opening.position[1] + opening.height / 2
|
||||
const centerX = opening.position[0]
|
||||
const left = centerX - halfWidth
|
||||
const right = centerX + halfWidth
|
||||
const width = Math.max(opening.width, 1e-6)
|
||||
const height = Math.max(opening.height, 1e-6)
|
||||
const shape = new THREE.Shape()
|
||||
|
||||
if (opening.openingShape === 'arch') {
|
||||
const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height)
|
||||
const springY = top - archHeight
|
||||
|
||||
shape.moveTo(left, bottom)
|
||||
shape.lineTo(right, bottom)
|
||||
shape.lineTo(right, springY)
|
||||
shape.quadraticCurveTo(centerX, top, left, springY)
|
||||
shape.lineTo(left, bottom)
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
if (opening.openingShape === 'rounded') {
|
||||
const radii = getRoundedOpeningRadii(opening, width, height)
|
||||
applyRoundedOpeningShape(shape, left, right, bottom, top, radii)
|
||||
return shape
|
||||
}
|
||||
|
||||
shape.moveTo(left, bottom)
|
||||
shape.lineTo(right, bottom)
|
||||
shape.lineTo(right, top)
|
||||
shape.lineTo(left, top)
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
function getRoundedOpeningRadii(
|
||||
opening: ShapedOpeningNode,
|
||||
width: number,
|
||||
height: number,
|
||||
): CornerRadii {
|
||||
if (opening.type !== 'window') {
|
||||
if (opening.openingRadiusMode === 'individual') {
|
||||
const [topLeft = 0, topRight = 0] = opening.openingTopRadii ?? [0.15, 0.15]
|
||||
|
||||
return normalizeCornerRadii(
|
||||
{
|
||||
topLeft: Math.max(topLeft, 0),
|
||||
topRight: Math.max(topRight, 0),
|
||||
bottomRight: 0,
|
||||
bottomLeft: 0,
|
||||
},
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
const maxRadius = Math.min(width / 2, height)
|
||||
const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius)
|
||||
return { topLeft: radius, topRight: radius, bottomRight: 0, bottomLeft: 0 }
|
||||
}
|
||||
|
||||
if (opening.openingRadiusMode === 'individual') {
|
||||
const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] =
|
||||
opening.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(opening.cornerRadius ?? 0.15, 0), maxRadius)
|
||||
return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius }
|
||||
}
|
||||
|
||||
function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii {
|
||||
const next = { ...radii }
|
||||
const maxScale = 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 (maxScale < 1) {
|
||||
next.topLeft *= maxScale
|
||||
next.topRight *= maxScale
|
||||
next.bottomRight *= maxScale
|
||||
next.bottomLeft *= maxScale
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function applyRoundedOpeningShape(
|
||||
shape: THREE.Shape,
|
||||
left: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
top: number,
|
||||
radii: CornerRadii,
|
||||
) {
|
||||
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()
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||
import { baseMaterial, glassMaterial } from '../../materials'
|
||||
import type { AnyNodeId, WindowNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user