792 lines
25 KiB
TypeScript
792 lines
25 KiB
TypeScript
import {
|
||
getDutchRoofMetrics,
|
||
getRidgeVentLinesForSegment,
|
||
normalizeRoofSegmentTrim,
|
||
type RidgeVentNode,
|
||
type RoofSegmentNode,
|
||
} from '@pascal-app/core'
|
||
import * as THREE from 'three'
|
||
import { getRoofTopSurfaceY } from '../shared/roof-surface'
|
||
|
||
const ARC_SEGS = 16
|
||
const SHINGLED_TAB_SIZE = 0.3
|
||
const DEFAULT_RIDGE_VENT_LENGTH = 2
|
||
const DEFAULT_RIDGE_VENT_WIDTH = 0.3
|
||
const DEFAULT_RIDGE_VENT_HEIGHT = 0.1
|
||
type ProfilePoint = [z: number, capY: number]
|
||
type RidgeVentGeometryVertex = {
|
||
x: number
|
||
y: number
|
||
z: number
|
||
nx: number
|
||
ny: number
|
||
nz: number
|
||
u: number
|
||
v: number
|
||
}
|
||
type SegmentTrimClipPlane = {
|
||
signedDistance: (segmentX: number, segmentZ: number) => number
|
||
}
|
||
|
||
type RidgeVentSupportLine = {
|
||
startX: number
|
||
endX: number
|
||
name: string
|
||
taperAtStart: boolean
|
||
taperAtEnd: boolean
|
||
}
|
||
type RidgeVentEndTaper = {
|
||
taperAtStart: boolean
|
||
taperAtEnd: boolean
|
||
taperLength: number
|
||
tipHalfWidth: number
|
||
}
|
||
|
||
/**
|
||
* Pure builder for the ridge vent mesh. Each style is a peaked **band** of
|
||
* constant thickness `t` that drapes over the ridge like a real ridge cap:
|
||
* a shaped top surface, a parallel underside offset down by `t`, visible
|
||
* eave thickness faces along both edges, and end caps.
|
||
*
|
||
* This is the middle ground between the two earlier extremes — the original
|
||
* was a paper-thin shell (no perceptible thickness), then a flat-bottomed
|
||
* solid (read as a closed box). The band keeps the V / arched cap silhouette
|
||
* and the open underside (so it sits astride the ridge) while showing real
|
||
* thickness at the eaves and ends.
|
||
*
|
||
* - `standard`: smooth rounded arch
|
||
* - `shingled`: angular peak with raised shingle-course ridges across the top
|
||
* - `metal`: bent-metal cap with a wide flat seam and drip lips
|
||
*
|
||
* `endCaps` closes both ends. Pure: no React, no scene access, no mutation.
|
||
*/
|
||
export function buildRidgeVentGeometry(
|
||
node: RidgeVentNode,
|
||
segment?: RoofSegmentNode,
|
||
): THREE.BufferGeometry {
|
||
const length = finitePositive(node.length, DEFAULT_RIDGE_VENT_LENGTH)
|
||
const width = finitePositive(node.width, DEFAULT_RIDGE_VENT_WIDTH)
|
||
const h = finitePositive(node.height, DEFAULT_RIDGE_VENT_HEIGHT)
|
||
const halfLen = length / 2
|
||
const halfW = width / 2
|
||
// Band thickness. Generous enough to read as a solid cap; the eave faces
|
||
// are `t` tall, which is the depth the user actually sees from the side.
|
||
const t = Math.max(0.02, h * 0.4)
|
||
|
||
const centerX = finiteNumber(node.position?.[0], 0)
|
||
const centerZ = finiteNumber(node.position?.[2], 0)
|
||
const rotationY = finiteNumber(node.rotation, 0)
|
||
const sinR = Math.sin(rotationY)
|
||
const cosR = Math.cos(rotationY)
|
||
const dutchTopRidgeSupport = getDutchTopRidgeSupport(segment, centerX, centerZ, rotationY)
|
||
const surfaceYAt = (x: number, z: number) => {
|
||
if (!segment) return 0
|
||
let sampleX = centerX + x * cosR + z * sinR
|
||
let sampleZ = centerZ - x * sinR + z * cosR
|
||
if (dutchTopRidgeSupport) {
|
||
if (dutchTopRidgeSupport.axis === 'x') {
|
||
sampleX = clamp(
|
||
sampleX,
|
||
-dutchTopRidgeSupport.innerHalfSpan,
|
||
dutchTopRidgeSupport.innerHalfSpan,
|
||
)
|
||
} else {
|
||
sampleZ = clamp(
|
||
sampleZ,
|
||
-dutchTopRidgeSupport.innerHalfSpan,
|
||
dutchTopRidgeSupport.innerHalfSpan,
|
||
)
|
||
}
|
||
}
|
||
return getRoofTopSurfaceY(sampleX, sampleZ, segment)
|
||
}
|
||
const ridgeY = surfaceYAt(0, 0)
|
||
const seatYAt = (x: number, z: number) => (segment ? surfaceYAt(x, z) - ridgeY : 0)
|
||
|
||
const top =
|
||
node.style === 'metal'
|
||
? metalTop(halfW, h, t)
|
||
: node.style === 'shingled'
|
||
? shingledTop(halfW, h, t)
|
||
: standardTop(halfW, h, t)
|
||
const supportLine = segment ? getSupportLineForVent(segment, node) : null
|
||
const endTaper = getRidgeVentEndTaper(supportLine, width, h)
|
||
|
||
const positions: number[] = []
|
||
const normals: number[] = []
|
||
const uvs: number[] = []
|
||
|
||
buildBand(positions, normals, uvs, top, seatYAt, -halfLen, halfLen, node.endCaps, endTaper)
|
||
|
||
if (node.style === 'shingled') {
|
||
addShingledTabs(positions, normals, uvs, -halfLen, halfLen, top, h, seatYAt, endTaper)
|
||
}
|
||
|
||
const geometry = buildBufferGeometry(positions, normals, uvs)
|
||
if (!segment) return geometry
|
||
|
||
const clipped = clipRidgeVentGeometryToSegmentTrim(geometry, node, segment)
|
||
if (clipped !== geometry) geometry.dispose()
|
||
return clipped
|
||
}
|
||
|
||
function finiteNumber(value: unknown, fallback: number): number {
|
||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||
}
|
||
|
||
function finitePositive(value: unknown, fallback: number): number {
|
||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
|
||
}
|
||
|
||
function clamp(value: number, min: number, max: number): number {
|
||
return Math.min(max, Math.max(min, value))
|
||
}
|
||
|
||
function getRidgeVentEndTaper(
|
||
supportLine: RidgeVentSupportLine | null,
|
||
width: number,
|
||
height: number,
|
||
): RidgeVentEndTaper | null {
|
||
if (
|
||
!supportLine ||
|
||
(supportLine.name !== 'Hip Ridge Vent' && supportLine.name !== 'Slope Ridge Vent')
|
||
) {
|
||
return null
|
||
}
|
||
|
||
const lineLength = supportLine.endX - supportLine.startX
|
||
const taperLength = Math.min(
|
||
Math.max(0.07, width * 0.45, height * 0.9),
|
||
Math.max(0, lineLength / 2 - 0.02),
|
||
)
|
||
if (!(taperLength > 0.001)) return null
|
||
|
||
return {
|
||
taperAtStart: supportLine.taperAtStart,
|
||
taperAtEnd: supportLine.taperAtEnd,
|
||
taperLength,
|
||
tipHalfWidth: Math.min(width * 0.34, Math.max(0.04, width * 0.26)),
|
||
}
|
||
}
|
||
|
||
function getDutchTopRidgeSupport(
|
||
segment: RoofSegmentNode | undefined,
|
||
centerX: number,
|
||
centerZ: number,
|
||
rotationY: number,
|
||
): { axis: 'x' | 'z'; innerHalfSpan: number } | null {
|
||
if (segment?.roofType !== 'dutch') return null
|
||
|
||
const metrics = getDutchRoofMetrics(segment)
|
||
const onWidthAxisTopRidge =
|
||
metrics.axis === 'x' && Math.abs(centerZ) <= 1e-4 && Math.abs(Math.sin(rotationY)) <= 1e-4
|
||
const onDepthAxisTopRidge =
|
||
metrics.axis === 'z' && Math.abs(centerX) <= 1e-4 && Math.abs(Math.cos(rotationY)) <= 1e-4
|
||
|
||
if (onWidthAxisTopRidge) {
|
||
return { axis: 'x', innerHalfSpan: metrics.waistHalfX }
|
||
}
|
||
if (onDepthAxisTopRidge) {
|
||
return { axis: 'z', innerHalfSpan: metrics.waistHalfZ }
|
||
}
|
||
return null
|
||
}
|
||
|
||
// ─── Top profiles (open polylines eave → peak → eave, in [z, y]) ─────────
|
||
// Eaves sit at y = t so that the underside (top − t) lands on y = 0 at the
|
||
// eaves, seating the cap on the roof while leaving a peaked void beneath.
|
||
|
||
// Smooth rounded arch.
|
||
function standardTop(halfW: number, h: number, t: number): ProfilePoint[] {
|
||
const pts: ProfilePoint[] = []
|
||
for (let i = 0; i <= ARC_SEGS; i++) {
|
||
const frac = i / ARC_SEGS
|
||
const z = -halfW + frac * 2 * halfW
|
||
const y = t + (h - t) * Math.sin(frac * Math.PI)
|
||
pts.push([z, y])
|
||
}
|
||
return pts
|
||
}
|
||
|
||
// Angular peak with a narrow flat ridge at the top.
|
||
function shingledTop(halfW: number, h: number, t: number): ProfilePoint[] {
|
||
const peakHalf = halfW * 0.12
|
||
return [
|
||
[-halfW, t],
|
||
[-peakHalf, h],
|
||
[peakHalf, h],
|
||
[halfW, t],
|
||
]
|
||
}
|
||
|
||
// Bent-metal cap: steep folds up to a wide flat standing seam.
|
||
function metalTop(halfW: number, h: number, t: number): ProfilePoint[] {
|
||
const seamHalf = halfW * 0.5
|
||
const shoulderY = t + (h - t) * 0.5
|
||
return [
|
||
[-halfW, t],
|
||
[-halfW * 0.82, shoulderY],
|
||
[-seamHalf, h],
|
||
[seamHalf, h],
|
||
[halfW * 0.82, shoulderY],
|
||
[halfW, t],
|
||
]
|
||
}
|
||
|
||
// ─── Band assembly ───────────────────────────────────────────────────────
|
||
|
||
function buildBand(
|
||
positions: number[],
|
||
normals: number[],
|
||
uvs: number[],
|
||
top: ProfilePoint[],
|
||
seatYAt: (x: number, z: number) => number,
|
||
startX: number,
|
||
endX: number,
|
||
withCaps: boolean,
|
||
endTaper: RidgeVentEndTaper | null = null,
|
||
): void {
|
||
const n = top.length
|
||
const halfWidth = getProfileHalfWidth(top)
|
||
const stations = getRidgeVentSweepStations(startX, endX, endTaper)
|
||
const scaledZAt = (x: number, z: number): number =>
|
||
z * getProfileScaleAtX(x, startX, endX, halfWidth, endTaper)
|
||
const seatAt = (x: number, z: number): number => seatYAt(x, scaledZAt(x, z))
|
||
const topAt = (x: number, z: number, capY: number): number => seatAt(x, z) + capY
|
||
|
||
// Top surface + underside, swept along the ridge length.
|
||
for (let station = 0; station < stations.length - 1; station += 1) {
|
||
const x0 = stations[station]!
|
||
const x1 = stations[station + 1]!
|
||
for (let i = 0; i < n - 1; i++) {
|
||
const [z0, capY0] = top[i]!
|
||
const [z1, capY1] = top[i + 1]!
|
||
const x0z0 = scaledZAt(x0, z0)
|
||
const x1z0 = scaledZAt(x1, z0)
|
||
const x1z1 = scaledZAt(x1, z1)
|
||
const x0z1 = scaledZAt(x0, z1)
|
||
pushQuad(
|
||
positions,
|
||
normals,
|
||
uvs,
|
||
[x0, topAt(x0, z0, capY0), x0z0],
|
||
[x1, topAt(x1, z0, capY0), x1z0],
|
||
[x1, topAt(x1, z1, capY1), x1z1],
|
||
[x0, topAt(x0, z1, capY1), x0z1],
|
||
[0, 1, 0],
|
||
)
|
||
pushQuad(
|
||
positions,
|
||
normals,
|
||
uvs,
|
||
[x0, seatAt(x0, z0), x0z0],
|
||
[x1, seatAt(x1, z0), x1z0],
|
||
[x1, seatAt(x1, z1), x1z1],
|
||
[x0, seatAt(x0, z1), x0z1],
|
||
[0, -1, 0],
|
||
)
|
||
}
|
||
}
|
||
|
||
// Eave thickness faces (the visible depth along each long edge).
|
||
for (let station = 0; station < stations.length - 1; station += 1) {
|
||
const x0 = stations[station]!
|
||
const x1 = stations[station + 1]!
|
||
for (const idx of [0, n - 1]) {
|
||
const [z, capY] = top[idx]!
|
||
const x0z = scaledZAt(x0, z)
|
||
const x1z = scaledZAt(x1, z)
|
||
const hint: [number, number, number] = [0, 0, z < 0 ? -1 : 1]
|
||
pushQuad(
|
||
positions,
|
||
normals,
|
||
uvs,
|
||
[x0, seatAt(x0, z), x0z],
|
||
[x1, seatAt(x1, z), x1z],
|
||
[x1, topAt(x1, z, capY), x1z],
|
||
[x0, topAt(x0, z, capY), x0z],
|
||
hint,
|
||
)
|
||
}
|
||
}
|
||
|
||
// End caps: the band's cross-section ring at each end.
|
||
if (withCaps) {
|
||
for (const [x, sign] of [
|
||
[startX, -1],
|
||
[endX, 1],
|
||
] as const) {
|
||
const hint: [number, number, number] = [sign, 0, 0]
|
||
for (let i = 0; i < n - 1; i++) {
|
||
const [z0, capY0] = top[i]!
|
||
const [z1, capY1] = top[i + 1]!
|
||
const scaledZ0 = scaledZAt(x, z0)
|
||
const scaledZ1 = scaledZAt(x, z1)
|
||
pushQuad(
|
||
positions,
|
||
normals,
|
||
uvs,
|
||
[x, topAt(x, z0, capY0), scaledZ0],
|
||
[x, topAt(x, z1, capY1), scaledZ1],
|
||
[x, seatAt(x, z1), scaledZ1],
|
||
[x, seatAt(x, z0), scaledZ0],
|
||
hint,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function getProfileHalfWidth(top: ProfilePoint[]): number {
|
||
return top.reduce((halfWidth, [z]) => Math.max(halfWidth, Math.abs(z)), 0)
|
||
}
|
||
|
||
function getRidgeVentSweepStations(
|
||
startX: number,
|
||
endX: number,
|
||
endTaper: RidgeVentEndTaper | null,
|
||
): number[] {
|
||
const stations = [startX, endX]
|
||
if (endTaper?.taperAtStart) stations.push(startX + endTaper.taperLength)
|
||
if (endTaper?.taperAtEnd) stations.push(endX - endTaper.taperLength)
|
||
return stations
|
||
.filter((x) => x >= startX && x <= endX)
|
||
.sort((a, b) => a - b)
|
||
.filter((x, index, sorted) => index === 0 || Math.abs(x - sorted[index - 1]!) > 1e-5)
|
||
}
|
||
|
||
function getProfileScaleAtX(
|
||
x: number,
|
||
startX: number,
|
||
endX: number,
|
||
halfWidth: number,
|
||
endTaper: RidgeVentEndTaper | null,
|
||
): number {
|
||
if (!endTaper || !(halfWidth > 0.0001)) return 1
|
||
|
||
const tipScale = clamp(endTaper.tipHalfWidth / halfWidth, 0, 1)
|
||
if (endTaper.taperAtStart && x <= startX + endTaper.taperLength) {
|
||
const progress = clamp((x - startX) / endTaper.taperLength, 0, 1)
|
||
return lerp(tipScale, 1, progress)
|
||
}
|
||
if (endTaper.taperAtEnd && x >= endX - endTaper.taperLength) {
|
||
const progress = clamp((endX - x) / endTaper.taperLength, 0, 1)
|
||
return lerp(tipScale, 1, progress)
|
||
}
|
||
return 1
|
||
}
|
||
|
||
// ─── Shingled course ridges ──────────────────────────────────────────────
|
||
// Thin raised lines running across the cap at intervals, suggesting
|
||
// overlapping shingle courses. Sit on the top profile edges.
|
||
|
||
function addShingledTabs(
|
||
positions: number[],
|
||
normals: number[],
|
||
uvs: number[],
|
||
startX: number,
|
||
endX: number,
|
||
top: ProfilePoint[],
|
||
h: number,
|
||
seatYAt: (x: number, z: number) => number,
|
||
endTaper: RidgeVentEndTaper | null = null,
|
||
): void {
|
||
const totalLen = endX - startX
|
||
const numTabs = Math.max(2, Math.round(totalLen / SHINGLED_TAB_SIZE))
|
||
const tabLen = totalLen / numTabs
|
||
const ridgeH = h * 0.06
|
||
const ridgeD = Math.min(0.01, tabLen * 0.15)
|
||
|
||
for (let tab = 1; tab < numTabs; tab++) {
|
||
const x = startX + tab * tabLen
|
||
if (isInsideEndTaper(x, startX, endX, endTaper)) continue
|
||
for (let i = 0; i < top.length - 1; i++) {
|
||
const [z0, capY0] = top[i]!
|
||
const [z1, capY1] = top[i + 1]!
|
||
const y0 = seatYAt(x, z0) + capY0
|
||
const y1 = seatYAt(x, z1) + capY1
|
||
const dz = z1 - z0
|
||
const dy = y1 - y0
|
||
const len = Math.sqrt(dz * dz + dy * dy) || 1
|
||
const nz = -dy / len
|
||
const ny = dz / len
|
||
const r0y = y0 + ny * ridgeH
|
||
const r0z = z0 + nz * ridgeH
|
||
const r1y = y1 + ny * ridgeH
|
||
const r1z = z1 + nz * ridgeH
|
||
const backX = x - ridgeD
|
||
const by0 = seatYAt(backX, z0) + capY0
|
||
const by1 = seatYAt(backX, z1) + capY1
|
||
const br0y = by0 + ny * ridgeH
|
||
const br1y = by1 + ny * ridgeH
|
||
pushQuad(
|
||
positions,
|
||
normals,
|
||
uvs,
|
||
[x, r0y, r0z],
|
||
[x, r1y, r1z],
|
||
[x, y1, z1],
|
||
[x, y0, z0],
|
||
[1, 0, 0],
|
||
)
|
||
pushQuad(
|
||
positions,
|
||
normals,
|
||
uvs,
|
||
[backX, br0y, r0z],
|
||
[backX, br1y, r1z],
|
||
[backX, by1, z1],
|
||
[backX, by0, z0],
|
||
[-1, 0, 0],
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
function isInsideEndTaper(
|
||
x: number,
|
||
startX: number,
|
||
endX: number,
|
||
endTaper: RidgeVentEndTaper | null,
|
||
): boolean {
|
||
if (!endTaper) return false
|
||
return (
|
||
(endTaper.taperAtStart && x <= startX + endTaper.taperLength) ||
|
||
(endTaper.taperAtEnd && x >= endX - endTaper.taperLength)
|
||
)
|
||
}
|
||
|
||
// ─── Geometry plumbing ───────────────────────────────────────────────────
|
||
|
||
function buildBufferGeometry(
|
||
positions: number[],
|
||
normals: number[],
|
||
uvs: number[],
|
||
): THREE.BufferGeometry {
|
||
const geo = new THREE.BufferGeometry()
|
||
if (positions.length === 0) {
|
||
geo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||
geo.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
||
geo.computeBoundingSphere()
|
||
return geo
|
||
}
|
||
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||
geo.computeBoundingSphere()
|
||
return geo
|
||
}
|
||
|
||
function clipRidgeVentGeometryToSegmentTrim(
|
||
geometry: THREE.BufferGeometry,
|
||
node: RidgeVentNode,
|
||
segment: RoofSegmentNode,
|
||
): THREE.BufferGeometry {
|
||
const planes = getSegmentTrimClipPlanes(segment)
|
||
if (planes.length === 0) return geometry
|
||
|
||
const position = geometry.getAttribute('position')
|
||
const normal = geometry.getAttribute('normal')
|
||
const uv = geometry.getAttribute('uv')
|
||
if (!position || !normal || !uv) return geometry
|
||
|
||
const positions: number[] = []
|
||
const normals: number[] = []
|
||
const uvs: number[] = []
|
||
|
||
for (let i = 0; i < position.count; i += 3) {
|
||
let polygon: RidgeVentGeometryVertex[] = [
|
||
readGeometryVertex(position, normal, uv, i),
|
||
readGeometryVertex(position, normal, uv, i + 1),
|
||
readGeometryVertex(position, normal, uv, i + 2),
|
||
]
|
||
|
||
for (const plane of planes) {
|
||
polygon = clipPolygonToSegmentTrimPlane(polygon, plane, node)
|
||
if (polygon.length < 3) break
|
||
}
|
||
|
||
if (polygon.length < 3) continue
|
||
for (let j = 1; j < polygon.length - 1; j += 1) {
|
||
pushGeometryVertex(positions, normals, uvs, polygon[0]!)
|
||
pushGeometryVertex(positions, normals, uvs, polygon[j]!)
|
||
pushGeometryVertex(positions, normals, uvs, polygon[j + 1]!)
|
||
}
|
||
}
|
||
|
||
return buildBufferGeometry(positions, normals, uvs)
|
||
}
|
||
|
||
function getSupportLineForVent(
|
||
segment: RoofSegmentNode,
|
||
node: RidgeVentNode,
|
||
): RidgeVentSupportLine | null {
|
||
const lines = getRidgeVentLinesForSegment(segment)
|
||
if (lines.length === 0) return null
|
||
|
||
const centerX = finiteNumber(node.position?.[0], 0)
|
||
const centerZ = finiteNumber(node.position?.[2], 0)
|
||
const rotationY = finiteNumber(node.rotation, 0)
|
||
const dirX = Math.cos(rotationY)
|
||
const dirZ = -Math.sin(rotationY)
|
||
|
||
let best: RidgeVentSupportLine | null = null
|
||
let bestScore = Number.POSITIVE_INFINITY
|
||
|
||
for (const line of lines) {
|
||
const [sx, sz] = line.start
|
||
const [ex, ez] = line.end
|
||
const lineDx = ex - sx
|
||
const lineDz = ez - sz
|
||
const lineLength = Math.hypot(lineDx, lineDz)
|
||
if (!(lineLength > 1e-4)) continue
|
||
|
||
const unitX = lineDx / lineLength
|
||
const unitZ = lineDz / lineLength
|
||
const yawPenalty = 1 - Math.abs(unitX * dirX + unitZ * dirZ)
|
||
const centerOffsetX = centerX - sx
|
||
const centerOffsetZ = centerZ - sz
|
||
const t = Math.max(0, Math.min(lineLength, centerOffsetX * unitX + centerOffsetZ * unitZ))
|
||
const nearestX = sx + unitX * t
|
||
const nearestZ = sz + unitZ * t
|
||
const distanceSq = (centerX - nearestX) ** 2 + (centerZ - nearestZ) ** 2
|
||
const score = distanceSq + yawPenalty * 6
|
||
|
||
if (score < bestScore) {
|
||
bestScore = score
|
||
const startLocalX = (sx - centerX) * dirX + (sz - centerZ) * dirZ
|
||
const endLocalX = (ex - centerX) * dirX + (ez - centerZ) * dirZ
|
||
const startRadiusSq = sx * sx + sz * sz
|
||
const endRadiusSq = ex * ex + ez * ez
|
||
const outerIsStart = startRadiusSq > endRadiusSq
|
||
const minIsStart = startLocalX <= endLocalX
|
||
best = {
|
||
startX: Math.min(startLocalX, endLocalX),
|
||
endX: Math.max(startLocalX, endLocalX),
|
||
name: line.name,
|
||
taperAtStart: minIsStart ? outerIsStart : !outerIsStart,
|
||
taperAtEnd: minIsStart ? !outerIsStart : outerIsStart,
|
||
}
|
||
}
|
||
}
|
||
|
||
return best
|
||
}
|
||
|
||
function readGeometryVertex(
|
||
position: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
|
||
normal: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
|
||
uv: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
|
||
index: number,
|
||
): RidgeVentGeometryVertex {
|
||
return {
|
||
x: position.getX(index),
|
||
y: position.getY(index),
|
||
z: position.getZ(index),
|
||
nx: normal.getX(index),
|
||
ny: normal.getY(index),
|
||
nz: normal.getZ(index),
|
||
u: uv.getX(index),
|
||
v: uv.getY(index),
|
||
}
|
||
}
|
||
|
||
function pushGeometryVertex(
|
||
positions: number[],
|
||
normals: number[],
|
||
uvs: number[],
|
||
vertex: RidgeVentGeometryVertex,
|
||
) {
|
||
positions.push(vertex.x, vertex.y, vertex.z)
|
||
normals.push(vertex.nx, vertex.ny, vertex.nz)
|
||
uvs.push(vertex.u, vertex.v)
|
||
}
|
||
|
||
function clipPolygonToSegmentTrimPlane(
|
||
polygon: RidgeVentGeometryVertex[],
|
||
plane: SegmentTrimClipPlane,
|
||
node: RidgeVentNode,
|
||
): RidgeVentGeometryVertex[] {
|
||
const next: RidgeVentGeometryVertex[] = []
|
||
let previous = polygon[polygon.length - 1]!
|
||
let previousDistance = getTrimClipDistance(previous, plane, node)
|
||
let previousInside = previousDistance <= 1e-6
|
||
|
||
for (const current of polygon) {
|
||
const currentDistance = getTrimClipDistance(current, plane, node)
|
||
const currentInside = currentDistance <= 1e-6
|
||
|
||
if (currentInside) {
|
||
if (!previousInside) {
|
||
next.push(interpolateGeometryVertex(previous, current, previousDistance, currentDistance))
|
||
}
|
||
next.push(current)
|
||
} else if (previousInside) {
|
||
next.push(interpolateGeometryVertex(previous, current, previousDistance, currentDistance))
|
||
}
|
||
|
||
previous = current
|
||
previousDistance = currentDistance
|
||
previousInside = currentInside
|
||
}
|
||
|
||
return next
|
||
}
|
||
|
||
function getTrimClipDistance(
|
||
vertex: RidgeVentGeometryVertex,
|
||
plane: SegmentTrimClipPlane,
|
||
node: RidgeVentNode,
|
||
): number {
|
||
const centerX = finiteNumber(node.position?.[0], 0)
|
||
const centerZ = finiteNumber(node.position?.[2], 0)
|
||
const rotationY = finiteNumber(node.rotation, 0)
|
||
const segmentX = centerX + vertex.x * Math.cos(rotationY) + vertex.z * Math.sin(rotationY)
|
||
const segmentZ = centerZ - vertex.x * Math.sin(rotationY) + vertex.z * Math.cos(rotationY)
|
||
return plane.signedDistance(segmentX, segmentZ)
|
||
}
|
||
|
||
function interpolateGeometryVertex(
|
||
a: RidgeVentGeometryVertex,
|
||
b: RidgeVentGeometryVertex,
|
||
distanceA: number,
|
||
distanceB: number,
|
||
): RidgeVentGeometryVertex {
|
||
const t = distanceA / (distanceA - distanceB || 1)
|
||
return {
|
||
x: lerp(a.x, b.x, t),
|
||
y: lerp(a.y, b.y, t),
|
||
z: lerp(a.z, b.z, t),
|
||
nx: lerp(a.nx, b.nx, t),
|
||
ny: lerp(a.ny, b.ny, t),
|
||
nz: lerp(a.nz, b.nz, t),
|
||
u: lerp(a.u, b.u, t),
|
||
v: lerp(a.v, b.v, t),
|
||
}
|
||
}
|
||
|
||
function lerp(a: number, b: number, t: number): number {
|
||
return a + (b - a) * t
|
||
}
|
||
|
||
function getSegmentTrimClipPlanes(segment: RoofSegmentNode): SegmentTrimClipPlane[] {
|
||
const trim = normalizeRoofSegmentTrim(segment)
|
||
const planes: SegmentTrimClipPlane[] = []
|
||
const leftX = -segment.width / 2 + trim.left
|
||
const rightX = segment.width / 2 - trim.right
|
||
const frontZ = segment.depth / 2 - trim.front
|
||
const backZ = -segment.depth / 2 + trim.back
|
||
|
||
if (trim.left > 0) planes.push({ signedDistance: (x) => leftX - x })
|
||
if (trim.right > 0) planes.push({ signedDistance: (x) => x - rightX })
|
||
if (trim.front > 0) planes.push({ signedDistance: (_x, z) => z - frontZ })
|
||
if (trim.back > 0) planes.push({ signedDistance: (_x, z) => backZ - z })
|
||
|
||
const diagonalPlane = (
|
||
lineA: readonly [number, number],
|
||
lineB: readonly [number, number],
|
||
outsidePoint: readonly [number, number],
|
||
): SegmentTrimClipPlane | null => {
|
||
const dx = lineB[0] - lineA[0]
|
||
const dz = lineB[1] - lineA[1]
|
||
const length = Math.hypot(dx, dz)
|
||
if (!(length > 0)) return null
|
||
let nx = -dz / length
|
||
let nz = dx / length
|
||
const midX = (lineA[0] + lineB[0]) / 2
|
||
const midZ = (lineA[1] + lineB[1]) / 2
|
||
if (nx * (outsidePoint[0] - midX) + nz * (outsidePoint[1] - midZ) < 0) {
|
||
nx *= -1
|
||
nz *= -1
|
||
}
|
||
return {
|
||
signedDistance: (x, z) => nx * (x - midX) + nz * (z - midZ),
|
||
}
|
||
}
|
||
|
||
const pushDiagonalPlane = (
|
||
lineA: readonly [number, number],
|
||
lineB: readonly [number, number],
|
||
outsidePoint: readonly [number, number],
|
||
) => {
|
||
const plane = diagonalPlane(lineA, lineB, outsidePoint)
|
||
if (plane) planes.push(plane)
|
||
}
|
||
|
||
if (trim.frontLeftX > 0 && trim.frontLeftZ > 0) {
|
||
pushDiagonalPlane(
|
||
[leftX + trim.frontLeftX, frontZ],
|
||
[leftX, frontZ - trim.frontLeftZ],
|
||
[leftX - 1, frontZ + 1],
|
||
)
|
||
}
|
||
if (trim.frontRightX > 0 && trim.frontRightZ > 0) {
|
||
pushDiagonalPlane(
|
||
[rightX, frontZ - trim.frontRightZ],
|
||
[rightX - trim.frontRightX, frontZ],
|
||
[rightX + 1, frontZ + 1],
|
||
)
|
||
}
|
||
if (trim.backLeftX > 0 && trim.backLeftZ > 0) {
|
||
pushDiagonalPlane(
|
||
[leftX, backZ + trim.backLeftZ],
|
||
[leftX + trim.backLeftX, backZ],
|
||
[leftX - 1, backZ - 1],
|
||
)
|
||
}
|
||
if (trim.backRightX > 0 && trim.backRightZ > 0) {
|
||
pushDiagonalPlane(
|
||
[rightX - trim.backRightX, backZ],
|
||
[rightX, backZ + trim.backRightZ],
|
||
[rightX + 1, backZ - 1],
|
||
)
|
||
}
|
||
|
||
return planes
|
||
}
|
||
|
||
// Winding-safe quad: triangulates (a,b,c,d) and orients both triangles so
|
||
// the shared flat normal points toward `hint`. UVs are dimension-based so
|
||
// painted presets tile at world scale across the ridge length and the cap.
|
||
function pushQuad(
|
||
positions: number[],
|
||
normals: number[],
|
||
uvs: number[],
|
||
a: number[],
|
||
b: number[],
|
||
c: number[],
|
||
d: number[],
|
||
hint: number[],
|
||
) {
|
||
let nx = (c[1]! - a[1]!) * (b[2]! - a[2]!) - (c[2]! - a[2]!) * (b[1]! - a[1]!)
|
||
let ny = (c[2]! - a[2]!) * (b[0]! - a[0]!) - (c[0]! - a[0]!) * (b[2]! - a[2]!)
|
||
let nz = (c[0]! - a[0]!) * (b[1]! - a[1]!) - (c[1]! - a[1]!) * (b[0]! - a[0]!)
|
||
const flip = nx * hint[0]! + ny * hint[1]! + nz * hint[2]! < 0
|
||
if (flip) {
|
||
nx = -nx
|
||
ny = -ny
|
||
nz = -nz
|
||
}
|
||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1
|
||
nx /= len
|
||
ny /= len
|
||
nz /= len
|
||
|
||
const u = Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!)
|
||
const v = Math.hypot(d[0]! - a[0]!, d[1]! - a[1]!, d[2]! - a[2]!)
|
||
|
||
if (flip) {
|
||
positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!)
|
||
uvs.push(0, 0, u, 0, u, v)
|
||
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, d[0]!, d[1]!, d[2]!)
|
||
uvs.push(0, 0, u, v, 0, v)
|
||
} else {
|
||
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!)
|
||
uvs.push(0, 0, u, v, u, 0)
|
||
positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!)
|
||
uvs.push(0, 0, 0, v, u, v)
|
||
}
|
||
for (let i = 0; i < 6; i++) normals.push(nx, ny, nz)
|
||
}
|