360 lines
12 KiB
TypeScript

import { type ChimneyNode, getActiveRoofHeight, type RoofSegmentNode } from '@pascal-app/core'
import {
Brush,
csgEvaluator,
csgGeometry,
prepareBrushForCSG,
SUBTRACTION,
} from '@pascal-app/viewer'
import * as THREE from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { flueXPositions } from './geometry'
const dummyMat = new THREE.MeshBasicMaterial()
/**
* Carve the top openings the chimney needs:
* - smoke shaft cavity in the body (one chimney-wide hole, or one
* bore per flue when the flues are hollow)
* - matching holes punched through the cap
* - inner bore subtracted from each flue tube
*
* Mirrors the v1 roof-system pipeline. Lives next to `roof-trim.ts` so
* the CSG deps (three-bvh-csg / three-mesh-bvh) stay inside the
* chimney folder; `geometry.ts` itself stays pure.
*/
export function carveChimneyHoles(
body: THREE.BufferGeometry,
cap: THREE.BufferGeometry | null,
flues: THREE.BufferGeometry | null,
node: ChimneyNode,
segment: RoofSegmentNode,
): {
body: THREE.BufferGeometry
cap: THREE.BufferGeometry | null
flues: THREE.BufferGeometry | null
} {
const peakY = segment.wallHeight + getActiveRoofHeight(segment)
const topY = peakY + node.heightAboveRidge
const capPresent = !!cap && node.cap && node.capShape !== 'none'
const capTopY = topY + (capPresent ? node.capThickness : 0)
const flueCount = Math.max(0, Math.min(4, node.flueCount ?? 0))
const flueDiameter = Math.max(0.02, node.flueDiameter ?? 0.22)
const flueWallT = Math.max(0, node.flueWallThickness ?? 0.02)
const flueInner = flueDiameter - 2 * flueWallT
const useFlueHoles = flueCount > 0 && flueWallT > 0 && flueInner > 0.02
const cavityDepth = Math.max(0, node.bodyHollowDepth ?? 0.6)
const hollowMargin = Math.max(0, node.bodyHollowMargin ?? 0.08)
const isRound = (node.bodyShape ?? 'square') === 'round'
type CutterSpec = {
shape: 'round' | 'square'
sizeX: number
sizeZ: number
xCenter: number
}
const specs: CutterSpec[] = []
if (cavityDepth > 0.01) {
if (useFlueHoles) {
const xs = flueXPositions(flueCount, node.width, flueDiameter, node.flueSpacing)
const flueShape = node.flueShape ?? 'round'
for (const x of xs) {
specs.push({ shape: flueShape, sizeX: flueInner, sizeZ: flueInner, xCenter: x })
}
} else if (hollowMargin > 0) {
if (isRound) {
const r = node.width / 2 - hollowMargin
if (r > 0.02) {
specs.push({ shape: 'round', sizeX: 2 * r, sizeZ: 2 * r, xCenter: 0 })
}
} else {
const cw = node.width - 2 * hollowMargin
const cd = node.depth - 2 * hollowMargin
if (cw > 0.04 && cd > 0.04) {
specs.push({ shape: 'square', sizeX: cw, sizeZ: cd, xCenter: 0 })
}
}
}
}
const flueHeight = Math.max(0.02, node.flueHeight ?? 0.3)
const yCavityBot = topY - cavityDepth
const yCapTop = capTopY + 0.02
const subtractFrom = (
base: THREE.BufferGeometry,
yBot: number,
yTop: number,
): THREE.BufferGeometry => {
if (specs.length === 0) return base
const cutters = specs.map((spec) => buildCutter(node, spec, yBot, yTop))
const result = subtractCutters(base, cutters)
for (const cutter of cutters) cutter.geometry.dispose()
return result
}
let newBody = subtractFrom(body, yCavityBot, yCapTop)
const newCap = cap ? subtractFrom(cap, yCavityBot, yCapTop) : null
// Decorative inset panels — carve a shallow rectangle out of each
// vertical face. Square bodies only (round bodies have no flat
// faces). Same CSG pipeline as the cavity cutters above.
const wantPanels =
node.panelStyle !== 'none' && !isRound && node.panelDepth > 0 && node.panelHeight > 0.01
if (wantPanels) {
const panelCutters = buildPanelCutters(node, topY)
if (panelCutters.length > 0) {
newBody = subtractCutters(newBody, panelCutters)
for (const cutter of panelCutters) cutter.geometry.dispose()
}
}
// Hollow each flue tube by punching its inner bore through.
let newFlues = flues
if (flues && useFlueHoles) {
const xs = flueXPositions(flueCount, node.width, flueDiameter, node.flueSpacing)
const flueShape = node.flueShape ?? 'round'
const cutters = xs.map((x) =>
buildCutter(
node,
{ shape: flueShape, sizeX: flueInner, sizeZ: flueInner, xCenter: x },
capTopY - 0.02,
capTopY + flueHeight + 0.02,
),
)
newFlues = subtractCutters(flues, cutters)
for (const cutter of cutters) cutter.geometry.dispose()
}
// Partition each surface so the very top face becomes its own material
// group (index 1 → top material, index 0 → body material). Matches v1's
// surface-array assignment.
partitionTopFaceGroups(newBody, topY - 0.05)
if (newCap) partitionTopFaceGroups(newCap, capTopY - 0.005)
if (newFlues) partitionTopFaceGroups(newFlues, capTopY + flueHeight - 0.005)
return { body: newBody, cap: newCap, flues: newFlues }
}
/**
* Split the index buffer into two groups:
* - group 0: every triangle whose normal is NOT roughly up, or that
* sits below `topYMin`. Receives the body material.
* - group 1: the top face triangles. Receives the top material.
*
* Mirrors the v1 `partitionTopFaceGroups` in
* `packages/viewer/src/systems/chimney/chimney-geometry.ts`. Operates in
* place — the geometry is re-indexed and its `groups` array rewritten.
*/
export function partitionTopFaceGroups(geo: THREE.BufferGeometry, topYMin: number) {
// CSG paths return indexed geometry; the pure-builder path doesn't. If
// we don't have an index, build one so the partitioning logic has
// something to reorder.
if (!geo.getIndex()) {
const merged = mergeVertices(geo, 1e-4)
if (merged.getIndex()) {
const idx = merged.getIndex()!
geo.setIndex(idx)
geo.setAttribute('position', merged.getAttribute('position'))
if (merged.getAttribute('uv')) geo.setAttribute('uv', merged.getAttribute('uv'))
if (merged.getAttribute('normal')) geo.setAttribute('normal', merged.getAttribute('normal'))
}
}
const positions = geo.getAttribute('position')
let normals = geo.getAttribute('normal')
if (!normals) {
geo.computeVertexNormals()
normals = geo.getAttribute('normal')
}
const index = geo.getIndex()
if (!(positions && normals && index)) {
geo.clearGroups()
geo.addGroup(0, index?.count ?? positions.count, 0)
return
}
const idxArr = index.array as ArrayLike<number>
const topTris: number[] = []
const otherTris: number[] = []
const yEps = 0.02
for (let i = 0; i < idxArr.length; i += 3) {
const a = idxArr[i] as number
const b = idxArr[i + 1] as number
const c = idxArr[i + 2] as number
const ny = (normals.getY(a) + normals.getY(b) + normals.getY(c)) / 3
const py = (positions.getY(a) + positions.getY(b) + positions.getY(c)) / 3
if (ny > 0.95 && py >= topYMin - yEps) {
topTris.push(a, b, c)
} else {
otherTris.push(a, b, c)
}
}
const total = otherTris.length + topTris.length
const useUint32 = (positions.count ?? 0) > 0xff_ff
const newArr = useUint32 ? new Uint32Array(total) : new Uint16Array(total)
for (let i = 0; i < otherTris.length; i++) newArr[i] = otherTris[i] as number
for (let i = 0; i < topTris.length; i++) newArr[otherTris.length + i] = topTris[i] as number
geo.setIndex(new THREE.BufferAttribute(newArr, 1))
geo.clearGroups()
if (otherTris.length > 0) geo.addGroup(0, otherTris.length, 0)
if (topTris.length > 0) geo.addGroup(otherTris.length, topTris.length, 1)
}
/**
* Build one cutter brush per vertical face for the inset-panel feature.
* Each cutter is a thin box flush against the body face; CSG-subtracted
* from the body it leaves a recessed rectangular panel — same shape as
* v1's `buildPanelCutterBrush`.
*/
function buildPanelCutters(node: ChimneyNode, topY: number): Brush[] {
const w = node.width
const d = node.depth
const margin = Math.max(0, node.panelMargin)
const recess = Math.max(0.005, node.panelDepth)
const panelHeight = Math.max(0.05, node.panelHeight)
const offsetTop = Math.max(0, node.panelOffsetTop)
const yTop = topY - offsetTop
const yBot = yTop - panelHeight
const eps = 0.002
const faces: Array<{
sizeX: number
sizeZ: number
cx: number
cz: number
}> = []
const panelW = w - 2 * margin
const panelD = d - 2 * margin
if (panelW > 0.05) {
// frontZ
faces.push({
sizeX: panelW,
sizeZ: recess + 2 * eps,
cx: 0,
cz: d / 2 - recess / 2 + eps,
})
// backZ
faces.push({
sizeX: panelW,
sizeZ: recess + 2 * eps,
cx: 0,
cz: -d / 2 + recess / 2 - eps,
})
}
if (panelD > 0.05) {
// rightX
faces.push({
sizeX: recess + 2 * eps,
sizeZ: panelD,
cx: w / 2 - recess / 2 + eps,
cz: 0,
})
// leftX
faces.push({
sizeX: recess + 2 * eps,
sizeZ: panelD,
cx: -w / 2 + recess / 2 - eps,
cz: 0,
})
}
const h = Math.max(0.02, yTop - yBot)
const midY = (yTop + yBot) / 2
const brushes: Brush[] = []
for (const f of faces) {
const geo = new THREE.BoxGeometry(f.sizeX, h, f.sizeZ)
geo.translate(f.cx, midY, f.cz)
// Body geometry is in chimney-local frame (node.position/rotation are
// applied by the renderer's nested ref'd group, not baked into the
// buffer geometry), so cutters need to stay in chimney-local too.
const idx = geo.getIndex()?.count ?? 0
geo.clearGroups()
if (idx > 0) geo.addGroup(0, idx, 0)
const brush = new Brush(geo, dummyMat as unknown as THREE.MeshStandardMaterial)
brush.updateMatrixWorld()
prepareBrushForCSG(brush)
brushes.push(brush)
}
return brushes
}
function buildCutter(
node: ChimneyNode,
spec: { shape: 'round' | 'square'; sizeX: number; sizeZ: number; xCenter: number },
yBot: number,
yTop: number,
): Brush {
const h = Math.max(0.02, yTop - yBot)
const midY = (yTop + yBot) / 2
const geo: THREE.BufferGeometry =
spec.shape === 'round'
? new THREE.CylinderGeometry(spec.sizeX / 2, spec.sizeX / 2, h, 24, 1, false)
: new THREE.BoxGeometry(spec.sizeX, h, spec.sizeZ)
geo.translate(spec.xCenter, midY, 0)
// Cutter stays in chimney-local frame to match the body/cap/flue
// geometry (node.position/rotation are applied via the renderer's
// nested ref'd group, not baked into the buffer geometry).
const idx = geo.getIndex()?.count ?? 0
geo.clearGroups()
if (idx > 0) geo.addGroup(0, idx, 0)
const brush = new Brush(geo, dummyMat as unknown as THREE.MeshStandardMaterial)
brush.updateMatrixWorld()
prepareBrushForCSG(brush)
return brush
}
function subtractCutters(base: THREE.BufferGeometry, cutters: Brush[]): THREE.BufferGeometry {
if (cutters.length === 0) return base
const indexed = mergeVertices(base, 1e-4)
if (!indexed.getAttribute('normal')) indexed.computeVertexNormals()
const ic = indexed.getIndex()?.count ?? 0
indexed.clearGroups()
if (ic > 0) indexed.addGroup(0, ic, 0)
const baseBrush = new Brush(indexed, dummyMat as unknown as THREE.MeshStandardMaterial)
baseBrush.updateMatrixWorld()
prepareBrushForCSG(baseBrush)
let current: Brush = baseBrush
const intermediates: Brush[] = []
try {
for (const cutter of cutters) {
const next = csgEvaluator.evaluate(current, cutter, SUBTRACTION) as Brush
prepareBrushForCSG(next)
if (current !== baseBrush) intermediates.push(current)
current = next
}
const out = csgGeometry(current).clone()
const idx = out.getIndex()?.count ?? 0
out.clearGroups()
if (idx > 0) out.addGroup(0, idx, 0)
else out.addGroup(0, out.getAttribute('position').count, 0)
out.computeVertexNormals()
base.dispose()
indexed.dispose()
for (const b of intermediates) b.geometry.dispose()
if (current !== baseBrush) current.geometry.dispose()
return out
} catch (e) {
// eslint-disable-next-line no-console
console.error('[chimney] hole carve CSG failed:', e)
indexed.dispose()
for (const b of intermediates) b.geometry.dispose()
if (current !== baseBrush) current.geometry.dispose()
return base
}
}