792 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}