Merge branch 'main' into feat/door-improvement
# Conflicts: # packages/core/src/events/bus.ts # packages/viewer/src/systems/door/door-system.tsx
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import {
|
||||
clampDoorOperationState,
|
||||
type AnyNodeId,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
useInteractive,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { baseMaterial, glassMaterial } from '../../lib/materials'
|
||||
|
||||
@@ -93,6 +93,300 @@ function addBoxWithRotation(
|
||||
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()
|
||||
@@ -1128,6 +1422,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
width,
|
||||
height,
|
||||
openingKind,
|
||||
openingShape,
|
||||
frameThickness,
|
||||
frameDepth,
|
||||
threshold,
|
||||
@@ -1166,39 +1461,99 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
const swingDirectionSign = swingDirection === 'inward' ? 1 : -1
|
||||
|
||||
// ── 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) {
|
||||
@@ -1384,6 +1739,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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user