* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * fix wall treatment miter geometry * fix cabinet group resizing and corner alignment * fix cabinet corner depth resizing * fix(cabinet): stabilize modular preset changes * fix(cabinet): stabilize corner resizing and wall alignment * fix(nodes): stabilize wall cabinet depth resizing * fix(editor): hide cabinet arrows for module selection * feat(cabinet): add individual width resize handles * feat(cabinet): improve wall cabinet editing * fix(cabinet): harden wall cabinet resizing * fix(cabinet): correct wall drag and depth handles * fix(cabinet): refine individual depth resizing * fix(cabinet): preserve context-aware corner depth behavior * fix(editor): respect snapping modes for resize handles * fix(editor): harden cabinet resize interactions --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Aymeric Rabot <aymeric.rabot@gmail.com>
668 lines
19 KiB
TypeScript
668 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
getWallCurveFrameAt,
|
|
getWallMiterBoundaryPoints,
|
|
getWallThickness,
|
|
isCurvedWall,
|
|
type SceneMaterial,
|
|
type SceneMaterialId,
|
|
WALL_CHAIR_RAIL_DEFAULT,
|
|
WALL_CROWN_DEFAULT,
|
|
WALL_SKIRTING_DEFAULT,
|
|
WALL_SURFACE_SLOT_DEFAULTS,
|
|
type WallNode,
|
|
type WallSurfaceSlotId,
|
|
type WallTrimConfig,
|
|
type WallTrimProfile,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
baseMaterial,
|
|
createMaterialFromPresetRef,
|
|
type RenderShading,
|
|
resolveMaterialRef,
|
|
} from '@pascal-app/viewer'
|
|
import { memo, useEffect, useMemo } from 'react'
|
|
import * as THREE from 'three'
|
|
import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
import { treatmentMiterDataForProud, type WallTreatmentLevelData } from './treatment-level-data'
|
|
|
|
const CURVE_SEGMENTS = 24
|
|
const MIN_SLICE_PROUD = 0.0005
|
|
const EPS = 1e-6
|
|
|
|
type OpeningLike = {
|
|
type: string
|
|
width?: number
|
|
height?: number
|
|
position?: [number, number, number]
|
|
}
|
|
|
|
type TrimKind = 'skirting' | 'crown' | 'chairRail'
|
|
type WallSide = 'interior' | 'exterior'
|
|
type SceneMaterials = Record<SceneMaterialId, SceneMaterial>
|
|
type Point2 = { x: number; z: number }
|
|
type WallTreatmentSlotId =
|
|
| 'skirtingInterior'
|
|
| 'skirtingExterior'
|
|
| 'crownInterior'
|
|
| 'crownExterior'
|
|
| 'chairRailInterior'
|
|
| 'chairRailExterior'
|
|
type TrimProfileDefinition = {
|
|
samples: number
|
|
proudAt: (t: number) => number
|
|
}
|
|
|
|
function clamp01(value: number) {
|
|
return Math.max(0, Math.min(1, value))
|
|
}
|
|
|
|
function eased(value: number) {
|
|
const t = clamp01(value)
|
|
return t * t * (3 - 2 * t)
|
|
}
|
|
|
|
const TRIM_PROFILES: Record<TrimKind, Partial<Record<WallTrimProfile, TrimProfileDefinition>>> = {
|
|
skirting: {
|
|
flat: {
|
|
samples: 8,
|
|
proudAt: (t) => (t < 0.72 ? 0.62 : 0.9),
|
|
},
|
|
bevel: {
|
|
samples: 10,
|
|
proudAt: (t) => 0.48 + 0.42 * eased(t),
|
|
},
|
|
triangle: {
|
|
samples: 10,
|
|
proudAt: (t) => 0.32 + 0.68 * (1 - t),
|
|
},
|
|
cove: {
|
|
samples: 12,
|
|
proudAt: (t) => 0.45 + 0.42 * Math.sin(t * Math.PI * 0.5),
|
|
},
|
|
bullnose: {
|
|
samples: 12,
|
|
proudAt: (t) => 0.35 + 0.65 * Math.sin(Math.PI * t),
|
|
},
|
|
'base-modern': {
|
|
samples: 10,
|
|
proudAt: (t) => {
|
|
if (t < 0.16) return 0.85
|
|
if (t < 0.72) return 0.58
|
|
if (t < 0.9) return 1
|
|
return 0.62
|
|
},
|
|
},
|
|
'base-colonial': {
|
|
samples: 14,
|
|
proudAt: (t) => {
|
|
if (t < 0.16) return 0.82
|
|
if (t < 0.55) return 0.52
|
|
const capT = (t - 0.55) / 0.45
|
|
return 0.58 + 0.4 * Math.sin(capT * Math.PI)
|
|
},
|
|
},
|
|
'base-shoe': {
|
|
samples: 14,
|
|
proudAt: (t) => {
|
|
const quarterRound = Math.sqrt(Math.max(0, 1 - t * t))
|
|
return 0.28 + 0.72 * quarterRound
|
|
},
|
|
},
|
|
'base-ogee': {
|
|
samples: 16,
|
|
proudAt: (t) => {
|
|
const ogee = 0.5 - 0.5 * Math.cos(Math.PI * t)
|
|
const bead = 0.16 * Math.sin(2 * Math.PI * t)
|
|
return clamp01(0.42 + 0.5 * ogee + bead)
|
|
},
|
|
},
|
|
},
|
|
crown: {
|
|
flat: {
|
|
samples: 8,
|
|
proudAt: (t) => (t < 0.2 ? 0.52 : t < 0.82 ? 0.78 : 1),
|
|
},
|
|
bevel: {
|
|
samples: 10,
|
|
proudAt: (t) => 0.45 + 0.55 * eased(t),
|
|
},
|
|
triangle: {
|
|
samples: 10,
|
|
proudAt: (t) => 0.35 + 0.65 * t,
|
|
},
|
|
cove: {
|
|
samples: 14,
|
|
proudAt: (t) => 0.46 + 0.45 * Math.sin(t * Math.PI * 0.5),
|
|
},
|
|
bullnose: {
|
|
samples: 14,
|
|
proudAt: (t) => 0.38 + 0.62 * Math.sin(Math.PI * t * 0.5),
|
|
},
|
|
'crown-cove': {
|
|
samples: 16,
|
|
proudAt: (t) => {
|
|
if (t < 0.12) return 0.48
|
|
if (t > 0.9) return 1
|
|
return 0.46 + 0.48 * Math.sin(((t - 0.12) / 0.78) * Math.PI * 0.5)
|
|
},
|
|
},
|
|
'crown-ogee': {
|
|
samples: 18,
|
|
proudAt: (t) => {
|
|
const sCurve = 0.5 - 0.5 * Math.cos(Math.PI * t)
|
|
const reverse = 0.18 * Math.sin(2 * Math.PI * (t - 0.12))
|
|
return clamp01(0.42 + 0.55 * sCurve + reverse)
|
|
},
|
|
},
|
|
'crown-craftsman': {
|
|
samples: 10,
|
|
proudAt: (t) => {
|
|
if (t < 0.16) return 0.48
|
|
if (t < 0.4) return 0.88
|
|
if (t < 0.78) return 0.64
|
|
return 1
|
|
},
|
|
},
|
|
'crown-layered': {
|
|
samples: 14,
|
|
proudAt: (t) => {
|
|
if (t < 0.12) return 0.46
|
|
if (t < 0.28) return 0.82
|
|
if (t < 0.48) return 0.56
|
|
if (t < 0.72) return 0.9
|
|
return 1
|
|
},
|
|
},
|
|
},
|
|
chairRail: {
|
|
flat: {
|
|
samples: 8,
|
|
proudAt: (t) => (t < 0.18 || t > 0.82 ? 0.58 : 0.95),
|
|
},
|
|
bevel: {
|
|
samples: 10,
|
|
proudAt: (t) => 0.45 + 0.45 * Math.sin(Math.PI * t),
|
|
},
|
|
triangle: {
|
|
samples: 10,
|
|
proudAt: (t) => 0.35 + 0.65 * (1 - Math.abs(2 * t - 1)),
|
|
},
|
|
cove: {
|
|
samples: 12,
|
|
proudAt: (t) => 0.42 + 0.42 * Math.sin(Math.PI * t),
|
|
},
|
|
bullnose: {
|
|
samples: 14,
|
|
proudAt: (t) => 0.34 + 0.66 * Math.sin(Math.PI * t),
|
|
},
|
|
'rail-rounded': {
|
|
samples: 14,
|
|
proudAt: (t) => 0.34 + 0.66 * Math.sin(Math.PI * t),
|
|
},
|
|
'rail-ogee': {
|
|
samples: 16,
|
|
proudAt: (t) => {
|
|
const center = Math.sin(Math.PI * t)
|
|
const twist = 0.14 * Math.sin(2 * Math.PI * t)
|
|
return clamp01(0.36 + 0.58 * center + twist)
|
|
},
|
|
},
|
|
'rail-picture': {
|
|
samples: 12,
|
|
proudAt: (t) => {
|
|
if (t < 0.18) return 0.48
|
|
if (t < 0.4) return 0.92
|
|
if (t < 0.74) return 0.58
|
|
return 1
|
|
},
|
|
},
|
|
'rail-stepped': {
|
|
samples: 10,
|
|
proudAt: (t) => {
|
|
if (t < 0.22) return 0.55
|
|
if (t < 0.78) return 1
|
|
return 0.62
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
const TRIM_KIND_CONFIG: Record<
|
|
TrimKind,
|
|
{
|
|
defaultConfig: WallTrimConfig
|
|
slots: Record<WallSide, WallTreatmentSlotId>
|
|
}
|
|
> = {
|
|
skirting: {
|
|
defaultConfig: WALL_SKIRTING_DEFAULT,
|
|
slots: { interior: 'skirtingInterior', exterior: 'skirtingExterior' },
|
|
},
|
|
crown: {
|
|
defaultConfig: WALL_CROWN_DEFAULT,
|
|
slots: { interior: 'crownInterior', exterior: 'crownExterior' },
|
|
},
|
|
chairRail: {
|
|
defaultConfig: WALL_CHAIR_RAIL_DEFAULT,
|
|
slots: { interior: 'chairRailInterior', exterior: 'chairRailExterior' },
|
|
},
|
|
}
|
|
|
|
function resolveTrimProfile(kind: TrimKind, trim: WallTrimConfig) {
|
|
const defaultProfile = TRIM_KIND_CONFIG[kind].defaultConfig.profile
|
|
return (
|
|
TRIM_PROFILES[kind][trim.profile] ??
|
|
TRIM_PROFILES[kind][defaultProfile] ??
|
|
TRIM_PROFILES[kind].flat
|
|
)
|
|
}
|
|
|
|
export function wallTreatmentProudOffsets(node: WallNode): number[] {
|
|
const offsets = new Set<number>()
|
|
const configs: Array<[TrimKind, WallTrimConfig | undefined]> = [
|
|
['skirting', node.skirting],
|
|
['crown', node.crown],
|
|
['chairRail', node.chairRail],
|
|
]
|
|
|
|
for (const [kind, rawConfig] of configs) {
|
|
const trim = { ...TRIM_KIND_CONFIG[kind].defaultConfig, ...(rawConfig ?? {}) }
|
|
if (!trim.enabled) continue
|
|
const profile = resolveTrimProfile(kind, trim)
|
|
if (!profile) continue
|
|
for (let index = 0; index < profile.samples; index += 1) {
|
|
const t = (index + 0.5) / profile.samples
|
|
offsets.add(Math.max(MIN_SLICE_PROUD, trim.proud * profile.proudAt(t)))
|
|
}
|
|
}
|
|
|
|
return [...offsets]
|
|
}
|
|
|
|
function resolveTreatmentSideSign(node: WallNode, side: WallSide) {
|
|
if (side === 'interior') {
|
|
if (node.frontSide === 'interior') return 1
|
|
if (node.backSide === 'interior') return -1
|
|
return 1
|
|
}
|
|
if (node.frontSide === 'exterior') return 1
|
|
if (node.backSide === 'exterior') return -1
|
|
return -1
|
|
}
|
|
|
|
function wallToLocalTransform(node: WallNode) {
|
|
const dx = node.end[0] - node.start[0]
|
|
const dz = node.end[1] - node.start[1]
|
|
const angle = Math.atan2(dz, dx)
|
|
const cosA = Math.cos(-angle)
|
|
const sinA = Math.sin(-angle)
|
|
return (worldX: number, worldZ: number): Point2 => {
|
|
const px = worldX - node.start[0]
|
|
const pz = worldZ - node.start[1]
|
|
return {
|
|
x: px * cosA - pz * sinA,
|
|
z: px * sinA + pz * cosA,
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildSidePolyline(node: WallNode, side: WallSide, offset: number): Point2[] {
|
|
const sideSign = resolveTreatmentSideSign(node, side)
|
|
const toLocal = wallToLocalTransform(node)
|
|
const sampleCount = isCurvedWall(node) ? CURVE_SEGMENTS : 1
|
|
const points: Point2[] = []
|
|
|
|
for (let index = 0; index <= sampleCount; index += 1) {
|
|
const frame = getWallCurveFrameAt(node, index / sampleCount)
|
|
const worldX = frame.point.x + frame.normal.x * offset * sideSign
|
|
const worldZ = frame.point.y + frame.normal.y * offset * sideSign
|
|
points.push(toLocal(worldX, worldZ))
|
|
}
|
|
|
|
return points
|
|
}
|
|
|
|
function buildMiteredSidePolyline(
|
|
node: WallNode,
|
|
levelData: WallTreatmentLevelData,
|
|
side: WallSide,
|
|
offset: number,
|
|
): Point2[] {
|
|
if (isCurvedWall(node)) return buildSidePolyline(node, side, offset)
|
|
|
|
const sideSign = resolveTreatmentSideSign(node, side)
|
|
const toLocal = wallToLocalTransform(node)
|
|
const proud = offset - getWallThickness(node) / 2
|
|
const boundarySource = treatmentMiterDataForProud(levelData, proud)
|
|
if (!boundarySource) return buildSidePolyline(node, side, offset)
|
|
const boundary = getWallMiterBoundaryPoints({ ...node, thickness: offset * 2 }, boundarySource)
|
|
|
|
if (!boundary) return buildSidePolyline(node, side, offset)
|
|
|
|
const start = sideSign > 0 ? boundary.startLeft : boundary.startRight
|
|
const end = sideSign > 0 ? boundary.endLeft : boundary.endRight
|
|
return [toLocal(start.x, start.y), toLocal(end.x, end.y)]
|
|
}
|
|
|
|
function clipPolyline(points: Point2[], x0?: number, x1?: number): Point2[] {
|
|
if (points.length < 2 || (x0 !== undefined && x1 !== undefined && x1 - x0 <= EPS)) return []
|
|
const out: Point2[] = []
|
|
for (let index = 0; index < points.length - 1; index += 1) {
|
|
const a = points[index]
|
|
const b = points[index + 1]
|
|
if (!(a && b)) continue
|
|
const minX = Math.min(a.x, b.x)
|
|
const maxX = Math.max(a.x, b.x)
|
|
if ((x0 !== undefined && maxX < x0 - EPS) || (x1 !== undefined && minX > x1 + EPS)) {
|
|
continue
|
|
}
|
|
|
|
const pushPointAt = (x: number) => {
|
|
if (Math.abs(b.x - a.x) <= EPS) {
|
|
return { x, z: a.z }
|
|
}
|
|
const t = (x - a.x) / (b.x - a.x)
|
|
return {
|
|
x,
|
|
z: a.z + (b.z - a.z) * t,
|
|
}
|
|
}
|
|
|
|
const start = x0 !== undefined && minX < x0 ? pushPointAt(x0) : a
|
|
const end = x1 !== undefined && maxX > x1 ? pushPointAt(x1) : b
|
|
if (
|
|
out.length === 0 ||
|
|
Math.hypot(out[out.length - 1]!.x - start.x, out[out.length - 1]!.z - start.z) > EPS
|
|
) {
|
|
out.push(start)
|
|
}
|
|
out.push(end)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function subtractOpeningRanges(ranges: Array<[number, number]>, openings: Array<[number, number]>) {
|
|
let next = ranges.slice()
|
|
for (const [gap0, gap1] of openings) {
|
|
const updated: Array<[number, number]> = []
|
|
for (const [a, b] of next) {
|
|
const start = Math.max(a, gap0)
|
|
const end = Math.min(b, gap1)
|
|
if (end - start <= EPS) {
|
|
updated.push([a, b])
|
|
continue
|
|
}
|
|
if (start - a > EPS) updated.push([a, start])
|
|
if (b - end > EPS) updated.push([end, b])
|
|
}
|
|
next = updated
|
|
}
|
|
return next
|
|
}
|
|
|
|
function trimOpeningRanges(
|
|
node: WallNode,
|
|
childrenNodes: OpeningLike[],
|
|
yBottom: number,
|
|
height: number,
|
|
) {
|
|
const yTop = yBottom + height
|
|
return childrenNodes
|
|
.filter((child) => child.type === 'door' || child.type === 'window')
|
|
.flatMap((child) => {
|
|
const width = child.width ?? 0
|
|
const childHeight = child.height ?? 0
|
|
const position = child.position ?? [0, 0, 0]
|
|
const childBottom = position[1] - childHeight / 2
|
|
const childTop = childBottom + childHeight
|
|
if (childTop <= yBottom + EPS || childBottom >= yTop - EPS) return []
|
|
return [[position[0] - width / 2, position[0] + width / 2] as [number, number]]
|
|
})
|
|
}
|
|
|
|
function buildPlanPolygon(outer: Point2[], inner: Point2[]) {
|
|
if (outer.length < 2 || inner.length < 2) return null
|
|
const shape = new THREE.Shape()
|
|
shape.moveTo(outer[0]!.x, -outer[0]!.z)
|
|
for (let index = 1; index < outer.length; index += 1) {
|
|
shape.lineTo(outer[index]!.x, -outer[index]!.z)
|
|
}
|
|
for (let index = inner.length - 1; index >= 0; index -= 1) {
|
|
shape.lineTo(inner[index]!.x, -inner[index]!.z)
|
|
}
|
|
shape.closePath()
|
|
return shape
|
|
}
|
|
|
|
function applyWorldScaleUvs(geometry: THREE.BufferGeometry) {
|
|
const position = geometry.getAttribute('position') as THREE.BufferAttribute | undefined
|
|
if (!position) return
|
|
const normal = geometry.getAttribute('normal') as THREE.BufferAttribute | undefined
|
|
const uv = new Float32Array(position.count * 2)
|
|
|
|
for (let index = 0; index < position.count; index += 1) {
|
|
const x = position.getX(index)
|
|
const y = position.getY(index)
|
|
const z = position.getZ(index)
|
|
const nx = Math.abs(normal?.getX(index) ?? 0)
|
|
const ny = Math.abs(normal?.getY(index) ?? 0)
|
|
const nz = Math.abs(normal?.getZ(index) ?? 0)
|
|
const offset = index * 2
|
|
|
|
if (ny >= nx && ny >= nz) {
|
|
uv[offset] = x
|
|
uv[offset + 1] = z
|
|
} else if (nz >= nx) {
|
|
uv[offset] = x
|
|
uv[offset + 1] = y
|
|
} else {
|
|
uv[offset] = z
|
|
uv[offset + 1] = y
|
|
}
|
|
}
|
|
|
|
geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
|
|
}
|
|
|
|
function buildTrimSliceGeometry(
|
|
outer: Point2[],
|
|
inner: Point2[],
|
|
extrudeHeight: number,
|
|
translateY: number,
|
|
) {
|
|
const shape = buildPlanPolygon(outer, inner)
|
|
if (!shape) return null
|
|
const geometry = new THREE.ExtrudeGeometry(shape, {
|
|
depth: extrudeHeight,
|
|
bevelEnabled: false,
|
|
steps: 1,
|
|
curveSegments: 1,
|
|
})
|
|
geometry.rotateX(-Math.PI / 2)
|
|
geometry.translate(0, translateY, 0)
|
|
geometry.computeVertexNormals()
|
|
applyWorldScaleUvs(geometry)
|
|
return geometry
|
|
}
|
|
|
|
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
|
|
if (geometries.length === 0) return null
|
|
const merged = mergeBufferGeometries(geometries, false)
|
|
if (merged) return merged
|
|
return null
|
|
}
|
|
|
|
export function buildTrimGeometry(
|
|
node: WallNode,
|
|
side: WallSide,
|
|
trim: WallTrimConfig,
|
|
kind: TrimKind,
|
|
childrenNodes: OpeningLike[],
|
|
levelData: WallTreatmentLevelData,
|
|
) {
|
|
const wallHeight = node.height ?? 2.5
|
|
const height = trim.height
|
|
const yBottom =
|
|
kind === 'crown'
|
|
? Math.max(0, wallHeight - height)
|
|
: kind === 'chairRail'
|
|
? Math.max(
|
|
0,
|
|
Math.min(wallHeight - height, trim.offsetY ?? WALL_CHAIR_RAIL_DEFAULT.offsetY ?? 0.9),
|
|
)
|
|
: 0
|
|
|
|
const thickness = getWallThickness(node)
|
|
const inner = buildMiteredSidePolyline(node, levelData, side, thickness / 2)
|
|
if (inner.length < 2) return null
|
|
|
|
const wallLength = Math.hypot(node.end[0] - node.start[0], node.end[1] - node.start[1])
|
|
const openingRanges = trimOpeningRanges(node, childrenNodes, yBottom, height)
|
|
const fullRanges: Array<[number, number]> = [[0, wallLength]]
|
|
const runs = subtractOpeningRanges(fullRanges, openingRanges)
|
|
if (runs.length === 0) return null
|
|
|
|
const slices: THREE.BufferGeometry[] = []
|
|
const profile = resolveTrimProfile(kind, trim)
|
|
if (!profile) return null
|
|
const sliceHeight = height / profile.samples
|
|
|
|
for (const [runStart, runEnd] of runs) {
|
|
const clipStart = runStart > EPS ? runStart : undefined
|
|
const clipEnd = runEnd < wallLength - EPS ? runEnd : undefined
|
|
const innerRun = clipPolyline(inner, clipStart, clipEnd)
|
|
if (innerRun.length < 2) continue
|
|
for (let index = 0; index < profile.samples; index += 1) {
|
|
const t = (index + 0.5) / profile.samples
|
|
const proud = Math.max(MIN_SLICE_PROUD, trim.proud * profile.proudAt(t))
|
|
const outerRun = buildMiteredSidePolyline(node, levelData, side, thickness / 2 + proud)
|
|
const outerClipped = clipPolyline(outerRun, clipStart, clipEnd)
|
|
if (outerClipped.length < 2) continue
|
|
const slice = buildTrimSliceGeometry(
|
|
outerClipped,
|
|
innerRun,
|
|
sliceHeight,
|
|
yBottom + index * sliceHeight,
|
|
)
|
|
if (slice) slices.push(slice)
|
|
}
|
|
}
|
|
|
|
if (slices.length === 0) return null
|
|
const merged = mergeGeometries(slices)
|
|
for (const slice of slices) slice.dispose()
|
|
return merged
|
|
}
|
|
|
|
function resolveWallSlotMaterial(
|
|
node: WallNode,
|
|
slotId: WallSurfaceSlotId,
|
|
shading: RenderShading,
|
|
sceneMaterials: SceneMaterials,
|
|
) {
|
|
const ref = node.slots?.[slotId]
|
|
if (ref) {
|
|
return resolveMaterialRef(ref, sceneMaterials, shading) ?? baseMaterial(shading)
|
|
}
|
|
return (
|
|
createMaterialFromPresetRef(WALL_SURFACE_SLOT_DEFAULTS[slotId], shading) ??
|
|
baseMaterial(shading)
|
|
)
|
|
}
|
|
|
|
export function createWallExtraSlotMaterials(
|
|
node: WallNode,
|
|
shading: RenderShading,
|
|
sceneMaterials: SceneMaterials,
|
|
) {
|
|
return {
|
|
skirtingInterior: resolveWallSlotMaterial(node, 'skirtingInterior', shading, sceneMaterials),
|
|
skirtingExterior: resolveWallSlotMaterial(node, 'skirtingExterior', shading, sceneMaterials),
|
|
crownInterior: resolveWallSlotMaterial(node, 'crownInterior', shading, sceneMaterials),
|
|
crownExterior: resolveWallSlotMaterial(node, 'crownExterior', shading, sceneMaterials),
|
|
chairRailInterior: resolveWallSlotMaterial(node, 'chairRailInterior', shading, sceneMaterials),
|
|
chairRailExterior: resolveWallSlotMaterial(node, 'chairRailExterior', shading, sceneMaterials),
|
|
} satisfies Record<WallTreatmentSlotId, THREE.Material>
|
|
}
|
|
|
|
export const WallTreatments = memo(function WallTreatments({
|
|
node,
|
|
childrenNodes,
|
|
levelData,
|
|
materials,
|
|
}: {
|
|
node: WallNode
|
|
childrenNodes: OpeningLike[]
|
|
levelData: WallTreatmentLevelData
|
|
materials: Record<WallTreatmentSlotId, THREE.Material>
|
|
}) {
|
|
const fallbackMaterial =
|
|
materials.skirtingInterior ??
|
|
materials.skirtingExterior ??
|
|
materials.crownInterior ??
|
|
materials.crownExterior ??
|
|
materials.chairRailInterior ??
|
|
materials.chairRailExterior
|
|
|
|
const trimEntries = useMemo(() => {
|
|
const out: Array<{
|
|
key: string
|
|
slotId: WallTreatmentSlotId
|
|
geometry: THREE.BufferGeometry
|
|
material: THREE.Material
|
|
}> = []
|
|
|
|
const configs: Array<[TrimKind, WallTrimConfig | undefined]> = [
|
|
['skirting', node.skirting],
|
|
['crown', node.crown],
|
|
['chairRail', node.chairRail],
|
|
]
|
|
|
|
for (const [kind, rawConfig] of configs) {
|
|
const trim = { ...TRIM_KIND_CONFIG[kind].defaultConfig, ...(rawConfig ?? {}) }
|
|
if (!trim.enabled) continue
|
|
const sides =
|
|
trim.sides === 'both'
|
|
? (['interior', 'exterior'] as WallSide[])
|
|
: ([trim.sides] as WallSide[])
|
|
for (const side of sides) {
|
|
const geometry = buildTrimGeometry(node, side, trim, kind, childrenNodes, levelData)
|
|
if (!geometry) continue
|
|
const slotId = TRIM_KIND_CONFIG[kind].slots[side]
|
|
out.push({
|
|
key: `${kind}-${side}`,
|
|
slotId,
|
|
geometry,
|
|
material: materials[slotId] ?? fallbackMaterial,
|
|
})
|
|
}
|
|
}
|
|
|
|
return out
|
|
}, [childrenNodes, fallbackMaterial, levelData, materials, node])
|
|
|
|
useEffect(
|
|
() => () => {
|
|
for (const entry of trimEntries) entry.geometry.dispose()
|
|
},
|
|
[trimEntries],
|
|
)
|
|
|
|
return (
|
|
<>
|
|
{trimEntries.map((entry) => (
|
|
<mesh
|
|
castShadow
|
|
geometry={entry.geometry}
|
|
key={entry.key}
|
|
material={entry.material}
|
|
receiveShadow
|
|
userData={{ slotId: entry.slotId }}
|
|
/>
|
|
))}
|
|
</>
|
|
)
|
|
})
|