Merge pull request #292 from sudhir9297/feat/mon-4-feat-fix

feat: column node with door/window nodes improvement
This commit is contained in:
Wassim SAMAD
2026-05-05 16:39:21 -04:00
committed by GitHub
45 changed files with 5710 additions and 217 deletions
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@
import { type AnyNode, useScene } from '@pascal-app/core'
import { BuildingRenderer } from './building/building-renderer'
import { CeilingRenderer } from './ceiling/ceiling-renderer'
import { ColumnRenderer } from './column/column-renderer'
import { DoorRenderer } from './door/door-renderer'
import { FenceRenderer } from './fence/fence-renderer'
import { GuideRenderer } from './guide/guide-renderer'
@@ -30,6 +31,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
{node.type === 'site' && <SiteRenderer node={node} />}
{node.type === 'building' && <BuildingRenderer node={node} />}
{node.type === 'ceiling' && <CeilingRenderer node={node} />}
{node.type === 'column' && <ColumnRenderer node={node} />}
{node.type === 'level' && <LevelRenderer node={node} />}
{node.type === 'item' && <ItemRenderer node={node} />}
{node.type === 'slab' && <SlabRenderer node={node} />}
@@ -4,6 +4,7 @@ import {
type AnyNode,
type AnyNodeId,
type BuildingNode,
type ColumnNode,
emitter,
type ItemNode,
type LevelNode,
@@ -32,6 +33,7 @@ type SelectableNodeType =
| 'fence'
| 'window'
| 'door'
| 'column'
| 'item'
| 'slab'
| 'ceiling'
@@ -132,6 +134,11 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean =
return pointInPolygonWithTolerance(item.position[0], item.position[2], zone.polygon)
}
if (node.type === 'column') {
const column = node as ColumnNode
return pointInPolygonWithTolerance(column.position[0], column.position[2], zone.polygon)
}
if (node.type === 'wall') {
const wall = node as WallNode
const startIn = pointInPolygonWithTolerance(wall.start[0], wall.start[1], zone.polygon)
@@ -227,9 +234,20 @@ const getStrategy = (): SelectionStrategy | null => {
}
}
// Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors)
// Zone selected -> can select/hover contents (walls, items, columns, slabs, ceilings, roofs, windows, doors)
return {
types: ['wall', 'fence', 'item', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'],
types: [
'wall',
'fence',
'item',
'column',
'slab',
'ceiling',
'roof',
'roof-segment',
'window',
'door',
],
handleClick: (node, nativeEvent) => {
let nodeToSelect = node
if (node.type === 'roof-segment' && node.parentId) {
@@ -258,6 +276,7 @@ const getStrategy = (): SelectionStrategy | null => {
'wall',
'fence',
'item',
'column',
'slab',
'ceiling',
'roof',
@@ -318,6 +337,7 @@ export const SelectionManager = () => {
'wall',
'fence',
'item',
'column',
'slab',
'ceiling',
'roof',
@@ -3,6 +3,8 @@ import {
type BuildingNode,
type CeilingEvent,
type CeilingNode,
type ColumnEvent,
type ColumnNode,
type DoorEvent,
type DoorNode,
type EventSuffix,
@@ -48,6 +50,7 @@ type NodeConfig = {
slab: { node: SlabNode; event: SlabEvent }
spawn: { node: SpawnNode; event: SpawnEvent }
ceiling: { node: CeilingNode; event: CeilingEvent }
column: { node: ColumnNode; event: ColumnEvent }
roof: { node: RoofNode; event: RoofEvent }
'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent }
stair: { node: StairNode; event: StairEvent }
@@ -0,0 +1,186 @@
import {
BoxGeometry,
type BufferGeometry,
CylinderGeometry,
Float32BufferAttribute,
SphereGeometry,
TorusGeometry,
} from 'three'
import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'
const COLUMN_UV_SCALE = 1
function setUvAttributes(geometry: BufferGeometry, uvs: number[]) {
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
geometry.setAttribute('uv2', new Float32BufferAttribute(uvs.slice(), 2))
return geometry
}
function toUvReadyGeometry(geometry: BufferGeometry) {
return geometry.index ? geometry.toNonIndexed() : geometry
}
function applyPlanarColumnUvs(geometry: BufferGeometry) {
const mappedGeometry = toUvReadyGeometry(geometry)
const positions = mappedGeometry.getAttribute('position')
const normals = mappedGeometry.getAttribute('normal')
const uvs: number[] = []
for (let index = 0; index < positions.count; index += 1) {
const x = positions.getX(index)
const y = positions.getY(index)
const z = positions.getZ(index)
const normalX = normals ? Math.abs(normals.getX(index)) : 0
const normalY = normals ? Math.abs(normals.getY(index)) : 1
const normalZ = normals ? Math.abs(normals.getZ(index)) : 0
if (normalY >= normalX && normalY >= normalZ) {
uvs.push(x * COLUMN_UV_SCALE, z * COLUMN_UV_SCALE)
} else if (normalX >= normalZ) {
uvs.push(z * COLUMN_UV_SCALE, y * COLUMN_UV_SCALE)
} else {
uvs.push(x * COLUMN_UV_SCALE, y * COLUMN_UV_SCALE)
}
}
return setUvAttributes(mappedGeometry, uvs)
}
function ellipseCircumference(radiusX: number, radiusZ: number) {
const a = Math.max(0.001, Math.abs(radiusX))
const b = Math.max(0.001, Math.abs(radiusZ))
return Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b)))
}
function applyCylindricalColumnUvs(
geometry: BufferGeometry,
sideCircumference: number,
height: number,
) {
const mappedGeometry = toUvReadyGeometry(geometry)
const positions = mappedGeometry.getAttribute('position')
const normals = mappedGeometry.getAttribute('normal')
const defaultUvs = mappedGeometry.getAttribute('uv')
const halfHeight = height / 2
const uvs: number[] = []
for (let index = 0; index < positions.count; index += 1) {
const x = positions.getX(index)
const y = positions.getY(index)
const z = positions.getZ(index)
const normalY = normals ? Math.abs(normals.getY(index)) : 0
if (normalY > 0.65) {
uvs.push(x * COLUMN_UV_SCALE, z * COLUMN_UV_SCALE)
} else {
const defaultU = defaultUvs ? defaultUvs.getX(index) : 0
uvs.push(defaultU * sideCircumference * COLUMN_UV_SCALE, (y + halfHeight) * COLUMN_UV_SCALE)
}
}
return setUvAttributes(mappedGeometry, uvs)
}
function applySphericalColumnUvs(geometry: BufferGeometry, radius: number) {
const mappedGeometry = toUvReadyGeometry(geometry)
const defaultUvs = mappedGeometry.getAttribute('uv')
if (!defaultUvs) return mappedGeometry
const uvs: number[] = []
const circumference = Math.PI * 2 * radius
const arcHeight = Math.PI * radius
for (let index = 0; index < defaultUvs.count; index += 1) {
uvs.push(
defaultUvs.getX(index) * circumference * COLUMN_UV_SCALE,
defaultUvs.getY(index) * arcHeight * COLUMN_UV_SCALE,
)
}
return setUvAttributes(mappedGeometry, uvs)
}
function applyTorusColumnUvs(geometry: BufferGeometry, ringRadius: number, tubeRadius: number) {
const mappedGeometry = toUvReadyGeometry(geometry)
const defaultUvs = mappedGeometry.getAttribute('uv')
if (!defaultUvs) return mappedGeometry
const uvs: number[] = []
const ringLength = Math.PI * 2 * Math.max(0.001, ringRadius)
const tubeLength = Math.PI * 2 * Math.max(0.001, tubeRadius)
for (let index = 0; index < defaultUvs.count; index += 1) {
uvs.push(
defaultUvs.getX(index) * ringLength * COLUMN_UV_SCALE,
defaultUvs.getY(index) * tubeLength * COLUMN_UV_SCALE,
)
}
return setUvAttributes(mappedGeometry, uvs)
}
export function createColumnBoxGeometry(
width: number,
height: number,
depth: number,
bevelRadius = 0,
) {
const geometry =
bevelRadius > 0.001
? new RoundedBoxGeometry(width, height, depth, 3, bevelRadius)
: new BoxGeometry(width, height, depth)
return applyPlanarColumnUvs(geometry)
}
export function createColumnCylinderGeometry({
height,
radiusBottom,
radiusTop = radiusBottom,
radiusX = 1,
radiusZ = 1,
segments = 32,
}: {
height: number
radiusBottom: number
radiusTop?: number
radiusX?: number
radiusZ?: number
segments?: number
}) {
const geometry = new CylinderGeometry(radiusTop, radiusBottom, height, segments)
geometry.scale(radiusX, 1, radiusZ)
const sideRadius = Math.max(radiusTop, radiusBottom)
return applyCylindricalColumnUvs(
geometry,
ellipseCircumference(sideRadius * radiusX, sideRadius * radiusZ),
height,
)
}
export function createColumnSphereGeometry(radius: number, widthSegments = 10, heightSegments = 8) {
return applySphericalColumnUvs(new SphereGeometry(radius, widthSegments, heightSegments), radius)
}
export function createColumnTorusGeometry({
arc = Math.PI * 2,
radialSegments = 10,
ringRadius,
scaleX = ringRadius,
scaleY = ringRadius,
scaleZ = 1,
tubeRadius,
tubularSegments = 24,
}: {
arc?: number
radialSegments?: number
ringRadius: number
scaleX?: number
scaleY?: number
scaleZ?: number
tubeRadius: number
tubularSegments?: number
}) {
const geometry = new TorusGeometry(1, 0.18, radialSegments, tubularSegments, arc)
geometry.scale(scaleX, scaleY, scaleZ)
return applyTorusColumnUvs(geometry, ringRadius, tubeRadius)
}
+621 -60
View File
@@ -1,10 +1,5 @@
import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core'
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'
@@ -55,6 +50,300 @@ function addBox(
parent.add(m)
}
function addShape(
parent: THREE.Object3D,
material: THREE.Material,
shape: THREE.Shape,
depth: number,
) {
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: false,
curveSegments: 24,
})
geometry.translate(0, 0, -depth / 2)
const mesh = new THREE.Mesh(geometry, material)
parent.add(mesh)
}
function getClampedArchHeight(width: number, height: number, archHeight: number | undefined) {
return Math.min(Math.max(archHeight ?? width / 2, 0.01), Math.max(height, 0.01))
}
function createArchShape(
left: number,
right: number,
bottom: number,
top: number,
archHeight: number,
) {
const centerX = (left + right) / 2
const halfWidth = (right - left) / 2
const clampedArchHeight = getClampedArchHeight(right - left, top - bottom, archHeight)
const springY = top - clampedArchHeight
const shape = new THREE.Shape()
const segments = 32
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, springY)
for (let index = 1; index <= segments; index += 1) {
const x = right + (left - right) * (index / segments)
shape.lineTo(x, getArchBoundaryY(x - centerX, halfWidth, springY, clampedArchHeight))
}
shape.lineTo(left, bottom)
shape.closePath()
return shape
}
function getArchBoundaryY(x: number, halfWidth: number, springY: number, archHeight: number) {
if (halfWidth <= 1e-6) return springY
const t = Math.min(Math.abs(x) / halfWidth, 1)
return springY + archHeight * Math.sqrt(Math.max(1 - t * t, 0))
}
function createArchBandShape(
width: number,
outerSpringY: number,
outerTopY: number,
innerSpringY: number,
innerTopY: number,
insetX: number,
) {
const halfWidth = width / 2
const innerHalfWidth = Math.max(halfWidth - insetX, 0)
const outerArchHeight = Math.max(outerTopY - outerSpringY, 0)
const safeInnerTopY = Math.min(innerTopY, outerTopY - 0.001)
const safeInnerSpringY = Math.min(innerSpringY, safeInnerTopY - 0.001)
const innerArchHeight = Math.max(safeInnerTopY - safeInnerSpringY, 0)
const shape = new THREE.Shape()
const segments = 32
const getSafeInnerBoundaryY = (x: number) =>
Math.min(
getArchBoundaryY(x, innerHalfWidth, safeInnerSpringY, innerArchHeight),
getArchBoundaryY(x, halfWidth, outerSpringY, outerArchHeight) - 0.001,
)
shape.moveTo(-halfWidth, outerSpringY)
for (let index = 1; index <= segments; index += 1) {
const x = -halfWidth + width * (index / segments)
shape.lineTo(x, getArchBoundaryY(x, halfWidth, outerSpringY, outerArchHeight))
}
if (innerHalfWidth <= 0.001 || safeInnerTopY <= safeInnerSpringY + 0.001) {
shape.lineTo(halfWidth, outerSpringY)
shape.closePath()
return shape
}
shape.lineTo(innerHalfWidth, outerSpringY)
shape.lineTo(innerHalfWidth, getSafeInnerBoundaryY(innerHalfWidth))
for (let index = segments - 1; index >= 0; index -= 1) {
const x = -innerHalfWidth + innerHalfWidth * 2 * (index / segments)
shape.lineTo(x, getSafeInnerBoundaryY(x))
}
shape.lineTo(-innerHalfWidth, outerSpringY)
shape.lineTo(-halfWidth, outerSpringY)
shape.closePath()
return shape
}
function createArchHeadBarShape(width: number, bottomY: number, springY: number, topY: number) {
const halfWidth = width / 2
const archHeight = Math.max(topY - springY, 0)
const shape = new THREE.Shape()
const segments = 32
shape.moveTo(-halfWidth, bottomY)
shape.lineTo(halfWidth, bottomY)
shape.lineTo(halfWidth, springY)
for (let index = 1; index <= segments; index += 1) {
const x = halfWidth - width * (index / segments)
shape.lineTo(x, getArchBoundaryY(x, halfWidth, springY, archHeight))
}
shape.lineTo(-halfWidth, bottomY)
shape.closePath()
return shape
}
type TopCornerRadii = {
topLeft: number
topRight: number
}
function normalizeTopCornerRadii(
radii: TopCornerRadii,
width: number,
height: number,
): TopCornerRadii {
const next = { ...radii }
const scale = Math.min(
1,
width / Math.max(next.topLeft + next.topRight, 1e-6),
height / Math.max(next.topLeft, 1e-6),
height / Math.max(next.topRight, 1e-6),
)
if (scale < 1) {
next.topLeft *= scale
next.topRight *= scale
}
return next
}
function getDoorTopRadii(node: DoorNode, width: number, height: number): TopCornerRadii {
if (node.openingRadiusMode === 'individual') {
const [topLeft = 0, topRight = 0] = node.openingTopRadii ?? [0.15, 0.15]
return normalizeTopCornerRadii(
{
topLeft: Math.max(topLeft, 0),
topRight: Math.max(topRight, 0),
},
width,
height,
)
}
const maxRadius = Math.min(width / 2, height)
const radius = Math.min(Math.max(node.cornerRadius ?? 0.15, 0), maxRadius)
return { topLeft: radius, topRight: radius }
}
function createRoundedTopShape(
left: number,
right: number,
bottom: number,
top: number,
radii: TopCornerRadii,
) {
const shape = new THREE.Shape()
const { topLeft, topRight } = normalizeTopCornerRadii(radii, right - left, top - bottom)
shape.moveTo(left, bottom)
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)
shape.closePath()
return shape
}
function createRoundedDoorFrameShape(
width: number,
height: number,
frameThickness: number,
radii: TopCornerRadii,
) {
const halfWidth = width / 2
const bottom = -height / 2
const top = height / 2
const outerRadii = normalizeTopCornerRadii(radii, width, height)
const outer = createRoundedTopShape(-halfWidth, halfWidth, bottom, top, outerRadii)
const inset = Math.min(frameThickness, width / 2 - 0.005, height - 0.005)
if (inset <= 0.001) return outer
const innerLeft = -halfWidth + inset
const innerRight = halfWidth - inset
const innerTop = top - inset
const innerRadii = normalizeTopCornerRadii(
{
topLeft: Math.max(outerRadii.topLeft - inset, 0),
topRight: Math.max(outerRadii.topRight - inset, 0),
},
innerRight - innerLeft,
innerTop - bottom,
)
const holeShape = createRoundedTopShape(innerLeft, innerRight, bottom, innerTop, innerRadii)
const hole = new THREE.Path(holeShape.getPoints(32).reverse())
outer.holes.push(hole)
return outer
}
function shapeToReversedPath(shape: THREE.Shape) {
return new THREE.Path(shape.getPoints(40).reverse())
}
function createRoundedLeafFrameShape(
width: number,
bottom: number,
top: number,
radii: TopCornerRadii,
insetX: number,
insetY: number,
) {
const halfWidth = width / 2
const outerRadii = normalizeTopCornerRadii(radii, width, top - bottom)
const outer = createRoundedTopShape(-halfWidth, halfWidth, bottom, top, outerRadii)
const innerLeft = -halfWidth + insetX
const innerRight = halfWidth - insetX
const innerBottom = bottom + insetY
const innerTop = top - insetY
if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer
const innerRadii = normalizeTopCornerRadii(
{
topLeft: Math.max(outerRadii.topLeft - Math.max(insetX, insetY), 0),
topRight: Math.max(outerRadii.topRight - Math.max(insetX, insetY), 0),
},
innerRight - innerLeft,
innerTop - innerBottom,
)
outer.holes.push(
shapeToReversedPath(
createRoundedTopShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii),
),
)
return outer
}
function createTopClippedRectShape(
left: number,
right: number,
bottom: number,
top: number,
getBoundaryY: (x: number) => number,
) {
const segments = 20
const points: { x: number; y: number }[] = []
for (let index = 0; index <= segments; index += 1) {
const t = index / segments
const x = right + (left - right) * t
const y = Math.min(top, getBoundaryY(x))
if (y > bottom + 0.001) points.push({ x, y })
}
if (points.length < 2) return null
const shape = new THREE.Shape()
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
for (const point of points) {
shape.lineTo(point.x, point.y)
}
shape.closePath()
return shape
}
function disposeObject(object: THREE.Object3D) {
object.traverse((child) => {
if (child instanceof THREE.Mesh) child.geometry.dispose()
@@ -82,6 +371,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
width,
height,
openingKind,
openingShape,
frameThickness,
frameDepth,
threshold,
@@ -129,41 +419,111 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
y: number,
z: number,
) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z)
const addLeafShape = (shape: THREE.Shape, material: THREE.Material, depth: number, z = 0) => {
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: false,
curveSegments: 24,
})
geometry.translate(-hingeX, 0, -depth / 2 + z)
const leafMesh = new THREE.Mesh(geometry, material)
leafGroup.add(leafMesh)
}
// ── 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,
)
if (openingShape === 'arch') {
const frameBottom = -height / 2
const frameTop = height / 2
const frameArchHeight = getClampedArchHeight(width, height, node.archHeight)
const frameSpringY = frameTop - frameArchHeight
const frameInnerTopY = frameTop - frameThickness
const frameInnerSpringY = Math.min(frameSpringY + frameThickness, frameInnerTopY)
const useShallowHeadBar = frameArchHeight <= frameThickness * 2
const frameHeadBottomY = useShallowHeadBar ? frameSpringY - frameThickness : frameSpringY
const postHeight = Math.max(frameHeadBottomY - frameBottom, 0.01)
addBox(
mesh,
baseMaterial,
frameThickness,
postHeight,
frameDepth,
-width / 2 + frameThickness / 2,
frameBottom + postHeight / 2,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
postHeight,
frameDepth,
width / 2 - frameThickness / 2,
frameBottom + postHeight / 2,
0,
)
addShape(
mesh,
baseMaterial,
useShallowHeadBar
? createArchHeadBarShape(width, frameHeadBottomY, frameSpringY, frameTop)
: createArchBandShape(
width,
frameSpringY,
frameTop,
frameInnerSpringY,
frameInnerTopY,
frameThickness,
),
frameDepth,
)
} else if (openingShape === 'rounded') {
addShape(
mesh,
baseMaterial,
createRoundedDoorFrameShape(
width,
height,
frameThickness,
getDoorTopRadii(node, width, height),
),
frameDepth,
)
} else {
// 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) {
@@ -179,16 +539,139 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
)
}
// ── Leaf — contentPadding border strips (no full backing; glass areas are open) ──
const usesShapedLeaf = openingShape === 'arch' || openingShape === 'rounded'
const leafBottom = leafCenterY - leafH / 2
const leafTop = leafCenterY + leafH / 2
const leafArchHeight = getClampedArchHeight(
leafW,
leafH,
Math.max((node.archHeight ?? leafW / 2) - frameThickness, 0.01),
)
const leafArchSpringY = leafTop - leafArchHeight
const frameRadii = getDoorTopRadii(node, width, height)
const leafTopRadii = normalizeTopCornerRadii(
{
topLeft: Math.max(frameRadii.topLeft - frameThickness, 0),
topRight: Math.max(frameRadii.topRight - frameThickness, 0),
},
leafW,
leafH,
)
const cpX = contentPadding[0]
const cpY = contentPadding[1]
if (hasLeafContent && cpY > 0) {
const useShallowLeafHeadBar = openingShape === 'arch' && cpY > 0 && leafArchHeight <= cpY * 2
const shallowLeafHeadBottomY = leafArchSpringY - cpY
const getLeafBoundaryY = (x: number) => {
if (openingShape === 'arch') {
if (useShallowLeafHeadBar) return shallowLeafHeadBottomY
const innerTop = leafTop - cpY
const innerSpringY = Math.min(Math.max(leafArchSpringY + cpY, leafBottom + cpY), innerTop)
const innerArchHeight = Math.max(innerTop - innerSpringY, 0.001)
const halfContentW = Math.max((leafW - 2 * cpX) / 2, 0.001)
const outerBoundaryY = getArchBoundaryY(x, leafW / 2, leafArchSpringY, leafArchHeight)
return Math.min(
getArchBoundaryY(x, halfContentW, innerSpringY, innerArchHeight),
outerBoundaryY - 0.001,
)
}
if (openingShape === 'rounded') {
const left = -leafW / 2 + cpX
const right = leafW / 2 - cpX
const top = leafTop - cpY
const innerRadii = normalizeTopCornerRadii(
{
topLeft: Math.max(leafTopRadii.topLeft - Math.max(cpX, cpY), 0),
topRight: Math.max(leafTopRadii.topRight - Math.max(cpX, cpY), 0),
},
right - left,
top - (leafBottom + cpY),
)
if (innerRadii.topLeft > 1e-6 && x < left + innerRadii.topLeft) {
const centerX = left + innerRadii.topLeft
const centerY = top - innerRadii.topLeft
const dx = x - centerX
return centerY + Math.sqrt(Math.max(innerRadii.topLeft * innerRadii.topLeft - dx * dx, 0))
}
if (innerRadii.topRight > 1e-6 && x > right - innerRadii.topRight) {
const centerX = right - innerRadii.topRight
const centerY = top - innerRadii.topRight
const dx = x - centerX
return centerY + Math.sqrt(Math.max(innerRadii.topRight * innerRadii.topRight - dx * dx, 0))
}
return top
}
return leafTop
}
const createLeafCellShape = (left: number, right: number, bottom: number, top: number) =>
createTopClippedRectShape(left, right, bottom, top, getLeafBoundaryY)
// ── Leaf — contentPadding border strips (no full backing; glass areas are open) ──
if (hasLeafContent && openingShape === 'arch') {
const leafInnerTopY = leafTop - cpY
const leafInnerSpringY = Math.min(
Math.max(leafArchSpringY + cpY, leafBottom + cpY),
leafInnerTopY,
)
const sideBottom = leafBottom + cpY
const sideTop = useShallowLeafHeadBar ? shallowLeafHeadBottomY : leafArchSpringY
const sideHeight = Math.max(sideTop - sideBottom, 0)
if (cpY > 0) {
addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafBottom + cpY / 2, 0)
}
if (cpX > 0 && sideHeight > 0.01) {
addLeafBox(
baseMaterial,
cpX,
sideHeight,
leafDepth,
-leafW / 2 + cpX / 2,
sideBottom + sideHeight / 2,
0,
)
addLeafBox(
baseMaterial,
cpX,
sideHeight,
leafDepth,
leafW / 2 - cpX / 2,
sideBottom + sideHeight / 2,
0,
)
}
addLeafShape(
useShallowLeafHeadBar
? createArchHeadBarShape(leafW, shallowLeafHeadBottomY, leafArchSpringY, leafTop)
: createArchBandShape(
leafW,
leafArchSpringY,
leafTop,
leafInnerSpringY,
leafInnerTopY,
cpX,
),
baseMaterial,
leafDepth,
)
} else if (hasLeafContent && openingShape === 'rounded') {
addLeafShape(
createRoundedLeafFrameShape(leafW, leafBottom, leafTop, leafTopRadii, cpX, cpY),
baseMaterial,
leafDepth,
)
} else 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) {
if (hasLeafContent && !usesShapedLeaf && cpX > 0) {
const innerH = leafH - 2 * cpY
// Left strip
addLeafBox(baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0)
@@ -205,9 +688,12 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
const contentTop = leafCenterY + contentH / 2
let segY = contentTop
for (const seg of segments) {
for (let segIndex = 0; segIndex < segments.length; segIndex += 1) {
const seg = segments[segIndex]!
const segH = (seg.heightRatio / totalRatio) * contentH
const segCenterY = segY - segH / 2
const segTop = segY
const segBottom = segY - segH
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
@@ -228,15 +714,24 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
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,
)
if (usesShapedLeaf) {
const dividerLeft = cx
const dividerRight = cx + seg.dividerThickness
const dividerShape = createLeafCellShape(dividerLeft, dividerRight, segBottom, segTop)
if (dividerShape) {
addLeafShape(dividerShape, baseMaterial, 0.012, leafDepth / 2 + 0.006)
}
} else {
addLeafBox(
baseMaterial,
seg.dividerThickness,
segH,
leafDepth + 0.001,
cx + seg.dividerThickness / 2,
segCenterY,
0,
)
}
cx += seg.dividerThickness
}
}
@@ -245,27 +740,61 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
for (let c = 0; c < numCols; c++) {
const colW = colWidths[c]!
const colX = colXCenters[c]!
const cellLeft = colX - colW / 2
const cellRight = colX + colW / 2
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)
if (usesShapedLeaf) {
const shape = createLeafCellShape(cellLeft, cellRight, segBottom, segTop)
if (shape)
addLeafShape(shape, glassMaterial, glassDepth, leafDepth / 2 + glassDepth / 2 + 0.004)
} else {
// Glass only — no opaque backing so it's truly transparent
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)
if (usesShapedLeaf) {
const shape = createLeafCellShape(cellLeft, cellRight, segBottom, segTop)
if (shape) addLeafShape(shape, baseMaterial, leafDepth)
} else {
// 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)
if (usesShapedLeaf) {
const shape = createLeafCellShape(
colX - panelW / 2,
colX + panelW / 2,
segCenterY - panelH / 2,
segCenterY + panelH / 2,
)
if (shape) addLeafShape(shape, baseMaterial, effectiveDepth, panelZ)
} else {
addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ)
}
}
} else {
// 'empty' leaves the opening unfilled
}
}
if (usesShapedLeaf && segIndex < segments.length - 1) {
const railThickness = Math.min(Math.max(cpY, 0.02), Math.max(segH * 0.35, 0.02))
const railShape = createLeafCellShape(
-contentW / 2,
contentW / 2,
segBottom - railThickness / 2,
segBottom + railThickness / 2,
)
if (railShape) addLeafShape(railShape, baseMaterial, 0.012, leafDepth / 2 + 0.006)
}
segY -= segH
}
@@ -308,8 +837,6 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
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)
@@ -327,6 +854,40 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
mesh.add(cutout)
}
cutout.geometry.dispose()
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
if (node.openingShape === 'arch') {
cutout.geometry = new THREE.ExtrudeGeometry(
createArchShape(
-node.width / 2,
node.width / 2,
-node.height / 2,
node.height / 2,
getClampedArchHeight(node.width, node.height, node.archHeight),
),
{
depth: 1,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
} else if (node.openingShape === 'rounded') {
cutout.geometry = new THREE.ExtrudeGeometry(
createRoundedTopShape(
-node.width / 2,
node.width / 2,
-node.height / 2,
node.height / 2,
getDoorTopRadii(node, node.width, node.height),
),
{
depth: 1,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
} else {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
}
cutout.visible = false
}
@@ -1,14 +1,10 @@
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,
calculateLevelMiters,
DEFAULT_WALL_HEIGHT,
type DoorNode,
getAdjacentWallIds,
DEFAULT_WALL_HEIGHT,
getWallCurveFrameAt,
getWallMiterBoundaryPoints,
getWallPlanFootprint,
@@ -21,10 +17,14 @@ import {
sceneRegistry,
spatialGridManager,
useScene,
type WallNode,
type WallMiterData,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
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'
// Reusable CSG evaluator for better performance
const csgEvaluator = new Evaluator()
@@ -560,7 +560,13 @@ function collectCutoutBrushes(
if (
(child.type === 'door' && child.openingKind === 'opening') ||
(child.type === 'window' && child.openingKind === 'opening')
(child.type === 'door' &&
child.openingKind === 'door' &&
(child.openingShape === 'arch' || child.openingShape === 'rounded')) ||
(child.type === 'window' && child.openingKind === 'opening') ||
(child.type === 'window' &&
child.openingKind === 'window' &&
(child.openingShape === 'arch' || child.openingShape === 'rounded'))
) {
brushes.push(createShapedOpeningCutoutBrush(child, wallThickness))
continue
@@ -668,11 +674,17 @@ function createShapedOpeningCutoutShape(opening: ShapedOpeningNode): THREE.Shape
if (opening.openingShape === 'arch') {
const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height)
const springY = top - archHeight
const segments = 32
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, springY)
shape.quadraticCurveTo(centerX, top, left, springY)
for (let index = 1; index <= segments; index += 1) {
const x = right + (left - right) * (index / segments)
const normalizedX = Math.min(Math.abs((x - centerX) / halfWidth), 1)
const y = springY + archHeight * Math.sqrt(Math.max(1 - normalizedX * normalizedX, 0))
shape.lineTo(x, y)
}
shape.lineTo(left, bottom)
shape.closePath()
return shape
@@ -1,10 +1,5 @@
import { type AnyNodeId, sceneRegistry, useScene, type WindowNode } from '@pascal-app/core'
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'
@@ -55,6 +50,521 @@ function addBox(
parent.add(m)
}
function addShape(
parent: THREE.Object3D,
material: THREE.Material,
shape: THREE.Shape,
depth: number,
z = 0,
) {
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: false,
curveSegments: 24,
})
geometry.translate(0, 0, -depth / 2 + z)
const mesh = new THREE.Mesh(geometry, material)
parent.add(mesh)
}
function createRectShape(left: number, right: number, bottom: number, top: number) {
const shape = new THREE.Shape()
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, top)
shape.lineTo(left, top)
shape.closePath()
return shape
}
type CornerRadii = {
topLeft: number
topRight: number
bottomRight: number
bottomLeft: number
}
function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii {
const next = { ...radii }
const scale = 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 (scale < 1) {
next.topLeft *= scale
next.topRight *= scale
next.bottomRight *= scale
next.bottomLeft *= scale
}
return next
}
function getWindowRoundedRadii(node: WindowNode, width: number, height: number): CornerRadii {
if (node.openingRadiusMode === 'individual') {
const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] =
node.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(node.cornerRadius ?? 0.15, 0), maxRadius)
return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius }
}
function insetCornerRadii(radii: CornerRadii, inset: number, width: number, height: number) {
return normalizeCornerRadii(
{
topLeft: Math.max(radii.topLeft - inset, 0),
topRight: Math.max(radii.topRight - inset, 0),
bottomRight: Math.max(radii.bottomRight - inset, 0),
bottomLeft: Math.max(radii.bottomLeft - inset, 0),
},
width,
height,
)
}
function createRoundedShape(
left: number,
right: number,
bottom: number,
top: number,
radii: CornerRadii,
) {
const shape = new THREE.Shape()
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()
return shape
}
function createRoundedFrameShape(
width: number,
height: number,
frameThickness: number,
outerRadii: CornerRadii,
) {
const halfWidth = width / 2
const bottom = -height / 2
const top = height / 2
const outer = createRoundedShape(-halfWidth, halfWidth, bottom, top, outerRadii)
const inset = Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005)
if (inset <= 0.001) return outer
const innerLeft = -halfWidth + inset
const innerRight = halfWidth - inset
const innerBottom = bottom + inset
const innerTop = top - inset
const innerRadii = insetCornerRadii(
outerRadii,
inset,
innerRight - innerLeft,
innerTop - innerBottom,
)
const holeShape = createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii)
const hole = new THREE.Path(holeShape.getPoints(32).reverse())
outer.holes.push(hole)
return outer
}
function getClampedArchHeight(width: number, height: number, archHeight: number | undefined) {
return Math.min(Math.max(archHeight ?? width / 2, 0.01), Math.max(height, 0.01))
}
function createArchShape(
left: number,
right: number,
bottom: number,
top: number,
archHeight: number,
) {
const centerX = (left + right) / 2
const halfWidth = (right - left) / 2
const clampedArchHeight = getClampedArchHeight(right - left, top - bottom, archHeight)
const springY = top - clampedArchHeight
const shape = new THREE.Shape()
const segments = 32
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, springY)
for (let index = 1; index <= segments; index += 1) {
const x = right + (left - right) * (index / segments)
shape.lineTo(x, getArchBoundaryY(x - centerX, halfWidth, springY, clampedArchHeight))
}
shape.lineTo(left, bottom)
shape.closePath()
return shape
}
function createArchedFrameShape(
width: number,
height: number,
archHeight: number,
frameThickness: number,
) {
const halfWidth = width / 2
const bottom = -height / 2
const top = height / 2
const outer = createArchShape(-halfWidth, halfWidth, bottom, top, archHeight)
const inset = Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005)
if (inset <= 0.001) return outer
const innerLeft = -halfWidth + inset
const innerRight = halfWidth - inset
const innerBottom = bottom + inset
const innerTop = top - inset
const innerArchHeight = getClampedArchHeight(
innerRight - innerLeft,
innerTop - innerBottom,
archHeight - inset,
)
const hole = new THREE.Path(
createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight)
.getPoints(32)
.reverse(),
)
outer.holes.push(hole)
return outer
}
function getArchBoundaryY(x: number, halfWidth: number, springY: number, archHeight: number) {
if (halfWidth <= 1e-6) return springY
const t = Math.min(Math.abs(x) / halfWidth, 1)
return springY + archHeight * Math.sqrt(Math.max(1 - t * t, 0))
}
function getArchedOpeningHalfWidthAtY(
y: number,
halfWidth: number,
springY: number,
archHeight: number,
) {
if (y <= springY || archHeight <= 1e-6) return halfWidth
const normalizedY = Math.min(Math.max((y - springY) / archHeight, 0), 1)
return halfWidth * Math.sqrt(Math.max(1 - normalizedY * normalizedY, 0))
}
function getRoundedBoundaryYAtX(
x: number,
left: number,
right: number,
top: number,
radii: CornerRadii,
) {
if (radii.topLeft > 1e-6 && x < left + radii.topLeft) {
const centerX = left + radii.topLeft
const centerY = top - radii.topLeft
const dx = x - centerX
return centerY + Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dx * dx, 0))
}
if (radii.topRight > 1e-6 && x > right - radii.topRight) {
const centerX = right - radii.topRight
const centerY = top - radii.topRight
const dx = x - centerX
return centerY + Math.sqrt(Math.max(radii.topRight * radii.topRight - dx * dx, 0))
}
return top
}
function getRoundedHorizontalBoundsAtY(
y: number,
left: number,
right: number,
top: number,
radii: CornerRadii,
) {
let minX = left
let maxX = right
if (radii.topLeft > 1e-6 && y > top - radii.topLeft) {
const centerX = left + radii.topLeft
const centerY = top - radii.topLeft
const dy = y - centerY
minX = centerX - Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dy * dy, 0))
}
if (radii.topRight > 1e-6 && y > top - radii.topRight) {
const centerX = right - radii.topRight
const centerY = top - radii.topRight
const dy = y - centerY
maxX = centerX + Math.sqrt(Math.max(radii.topRight * radii.topRight - dy * dy, 0))
}
return { minX, maxX }
}
function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const {
width,
height,
frameDepth,
frameThickness,
columnRatios,
rowRatios,
columnDividerThickness,
rowDividerThickness,
sill,
sillDepth,
sillThickness,
} = node
const halfWidth = width / 2
const bottom = -height / 2
const top = height / 2
const outerRadii = getWindowRoundedRadii(node, width, height)
const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005))
const innerLeft = -halfWidth + inset
const innerRight = halfWidth - inset
const innerBottom = bottom + inset
const innerTop = top - inset
const innerW = innerRight - innerLeft
const innerH = innerTop - innerBottom
const innerRadii = insetCornerRadii(outerRadii, inset, innerW, innerH)
addShape(
mesh,
baseMaterial,
createRoundedFrameShape(width, height, inset, outerRadii),
frameDepth,
)
if (innerW > 0.01 && innerH > 0.01) {
const glassDepth = Math.max(0.004, frameDepth * 0.08)
addShape(
mesh,
glassMaterial,
createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii),
glassDepth,
)
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)
let x = innerLeft
for (let c = 0; c < numCols - 1; c++) {
x += colWidths[c]!
const x1 = x
const x2 = x + columnDividerThickness
const dividerTop = Math.min(
getRoundedBoundaryYAtX(x1, innerLeft, innerRight, innerTop, innerRadii),
getRoundedBoundaryYAtX(x2, innerLeft, innerRight, innerTop, innerRadii),
)
if (dividerTop > innerBottom + 0.01) {
addShape(
mesh,
baseMaterial,
createRectShape(x1, x2, innerBottom, dividerTop),
frameDepth + 0.001,
)
}
x += columnDividerThickness
}
let y = innerTop
for (let r = 0; r < numRows - 1; r++) {
y -= rowHeights[r]!
const yTop = y
const yBottom = y - rowDividerThickness
const { minX, maxX } = getRoundedHorizontalBoundsAtY(
yTop,
innerLeft,
innerRight,
innerTop,
innerRadii,
)
if (maxX - minX > 0.01 && yTop > innerBottom) {
addShape(
mesh,
baseMaterial,
createRectShape(minX, maxX, Math.max(yBottom, innerBottom), yTop),
frameDepth + 0.001,
)
}
y -= rowDividerThickness
}
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const {
width,
height,
frameDepth,
frameThickness,
columnRatios,
rowRatios,
columnDividerThickness,
rowDividerThickness,
sill,
sillDepth,
sillThickness,
} = node
const halfWidth = width / 2
const bottom = -height / 2
const top = height / 2
const archHeight = getClampedArchHeight(width, height, node.archHeight)
const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005))
const innerLeft = -halfWidth + inset
const innerRight = halfWidth - inset
const innerBottom = bottom + inset
const innerTop = top - inset
const innerW = innerRight - innerLeft
const innerH = innerTop - innerBottom
const innerArchHeight = getClampedArchHeight(innerW, innerH, archHeight - inset)
const innerSpringY = innerTop - innerArchHeight
addShape(mesh, baseMaterial, createArchedFrameShape(width, height, archHeight, inset), frameDepth)
if (innerW > 0.01 && innerH > 0.01) {
const glassDepth = Math.max(0.004, frameDepth * 0.08)
addShape(
mesh,
glassMaterial,
createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight),
glassDepth,
)
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)
const innerHalfWidth = innerW / 2
let x = innerLeft
for (let c = 0; c < numCols - 1; c++) {
x += colWidths[c]!
const x1 = x
const x2 = x + columnDividerThickness
const dividerTop = Math.min(
getArchBoundaryY(x1, innerHalfWidth, innerSpringY, innerArchHeight),
getArchBoundaryY(x2, innerHalfWidth, innerSpringY, innerArchHeight),
)
if (dividerTop > innerBottom + 0.01) {
addShape(
mesh,
baseMaterial,
createRectShape(x1, x2, innerBottom, dividerTop),
frameDepth + 0.001,
)
}
x += columnDividerThickness
}
let y = innerTop
for (let r = 0; r < numRows - 1; r++) {
y -= rowHeights[r]!
const yTop = y
const yBottom = y - rowDividerThickness
const halfAtTop = getArchedOpeningHalfWidthAtY(
yTop,
innerHalfWidth,
innerSpringY,
innerArchHeight,
)
const x1 = -halfAtTop
const x2 = halfAtTop
if (x2 - x1 > 0.01 && yTop > innerBottom) {
addShape(
mesh,
baseMaterial,
createRectShape(x1, x2, Math.max(yBottom, innerBottom), yTop),
frameDepth + 0.001,
)
}
y -= rowDividerThickness
}
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
// Root mesh is an invisible hitbox; all visuals live in child meshes
mesh.geometry.dispose()
@@ -85,6 +595,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
sillDepth,
sillThickness,
openingKind,
openingShape,
} = node
if (openingKind === 'opening') {
@@ -92,6 +603,18 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
return
}
if (openingShape === 'arch') {
addArchedWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)
return
}
if (openingShape === 'rounded') {
addRoundedWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)
return
}
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
@@ -252,6 +775,40 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
mesh.add(cutout)
}
cutout.geometry.dispose()
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
if (node.openingShape === 'arch') {
cutout.geometry = new THREE.ExtrudeGeometry(
createArchShape(
-node.width / 2,
node.width / 2,
-node.height / 2,
node.height / 2,
getClampedArchHeight(node.width, node.height, node.archHeight),
),
{
depth: 1,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
} else if (node.openingShape === 'rounded') {
cutout.geometry = new THREE.ExtrudeGeometry(
createRoundedShape(
-node.width / 2,
node.width / 2,
-node.height / 2,
node.height / 2,
getWindowRoundedRadii(node, node.width, node.height),
),
{
depth: 1,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
} else {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
}
cutout.visible = false
}