Merge pull request #292 from sudhir9297/feat/mon-4-feat-fix
feat: column node with door/window nodes improvement
This commit is contained in:
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user