Move render systems out of core

This commit is contained in:
sudhir
2026-05-01 15:07:47 +05:30
parent eee7af9e25
commit 23eec44714
18 changed files with 126 additions and 109 deletions
@@ -1,8 +1,6 @@
import {
type AnimationEffect,
type AnyNodeId,
baseMaterial,
glassMaterial,
type Interactive,
type ItemNode,
type LightEffect,
@@ -21,6 +19,7 @@ import { positionLocal, smoothstep, time } from 'three/tsl'
import { MeshStandardNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveCdnUrl } from '../../../lib/asset-url'
import { baseMaterial, glassMaterial } from '../../../lib/materials'
import { useItemLightPool } from '../../../store/use-item-light-pool'
import {
requestItemMeshMetadataSync,
@@ -1,27 +1,25 @@
'use client'
import {
CeilingSystem,
DoorSystem,
FenceSystem,
ItemSystem,
RoofSystem,
SlabSystem,
StairSystem,
WallSystem,
WindowSystem,
} from '@pascal-app/core'
import { Bvh } from '@react-three/drei'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu'
import useViewer from '../../store/use-viewer'
import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
import { DoorSystem } from '../../systems/door/door-system'
import { FenceSystem } from '../../systems/fence/fence-system'
import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemSystem } from '../../systems/item/item-system'
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
import { ItemMeshMetadataSystem } from '../../systems/item-mesh-metadata/item-mesh-metadata-system'
import { LevelSystem } from '../../systems/level/level-system'
import { RoofSystem } from '../../systems/roof/roof-system'
import { ScanSystem } from '../../systems/scan/scan-system'
import { SlabSystem } from '../../systems/slab/slab-system'
import { StairSystem } from '../../systems/stair/stair-system'
import { WallCutout } from '../../systems/wall/wall-cutout'
import { WallSystem } from '../../systems/wall/wall-system'
import { WindowSystem } from '../../systems/window/window-system'
import { ZoneSystem } from '../../systems/zone/zone-system'
import { ErrorBoundary } from '../error-boundary'
import { SceneRenderer } from '../renderers/scene-renderer'
+16
View File
@@ -7,6 +7,22 @@ import {
resolveMaterial,
} from '@pascal-app/core'
import * as THREE from 'three'
import { MeshStandardNodeMaterial } from 'three/webgpu'
export const baseMaterial = new MeshStandardNodeMaterial({
color: '#f2f0ed',
roughness: 0.5,
metalness: 0.0,
})
export const glassMaterial = new MeshStandardNodeMaterial({
color: '#e0f2fe',
roughness: 0.05,
metalness: 0.0,
transparent: true,
opacity: 0.35,
side: THREE.DoubleSide,
})
const sideMap: Record<MaterialProperties['side'], THREE.Side> = {
front: THREE.FrontSide,
@@ -0,0 +1,111 @@
import { useFrame } from '@react-three/fiber'
import { type AnyNodeId, type CeilingNode, sceneRegistry, useScene } from '@pascal-app/core'
import * as THREE from 'three'
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
}
@@ -0,0 +1,332 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
type DoorNode,
sceneRegistry,
useScene,
} 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 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
}
@@ -0,0 +1,287 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
type FenceNode,
getWallCurveFrameAt,
getWallCurveLength,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
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
}
@@ -0,0 +1,63 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
getScaledDimensions,
type ItemNode,
resolveLevelId,
sceneRegistry,
spatialGridManager,
useScene,
type WallNode,
} from '@pascal-app/core'
import type * as THREE from 'three'
// ============================================================================
// 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
@@ -0,0 +1,184 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
getRenderableSlabPolygon,
sceneRegistry,
type SlabNode,
useScene,
} from '@pascal-app/core'
import * as THREE from 'three'
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,5 +1,4 @@
import {
baseMaterial,
getEffectiveWallSurfaceMaterial,
getMaterialPresetByRef,
getWallSurfaceMaterialSignature,
@@ -10,7 +9,7 @@ import {
import { Color, type Material } from 'three'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { MeshStandardNodeMaterial } from 'three/webgpu'
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
const DEFAULT_WALL_COLOR = '#f2f0ed'
@@ -0,0 +1,802 @@
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 {
calculateLevelMiters,
type AnyNode,
type AnyNodeId,
type DoorNode,
getAdjacentWallIds,
DEFAULT_WALL_HEIGHT,
getWallCurveFrameAt,
getWallMiterBoundaryPoints,
getWallPlanFootprint,
getWallSurfacePolygon,
getWallThickness,
isCurvedWall,
type Point2D,
pointToKey,
resolveLevelId,
sceneRegistry,
spatialGridManager,
useScene,
type WallNode,
type WallMiterData,
type WindowNode,
} from '@pascal-app/core'
// 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
function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) {
;(geometry as any).computeBoundsTree = computeBoundsTree
;(geometry as any).computeBoundsTree({ maxLeafSize: 10 })
}
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
computeGeometryBoundsTree(geometry)
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
computeGeometryBoundsTree(boxGeo)
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)
computeGeometryBoundsTree(geometry)
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()
}
@@ -0,0 +1,257 @@
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
}