476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
import type { EyebrowVentNode } from '@pascal-app/core'
|
||
import * as THREE from 'three'
|
||
|
||
/**
|
||
* Pure builder for the eyebrow-vent mesh. Three styles, all seated directly on
|
||
* the roof at y=0 and facing +Z (downslope):
|
||
*
|
||
* - `scoop` — a rounded louvered opening at the front that sweeps back
|
||
* and tapers to nothing (the classic dormer "eyebrow"). Front
|
||
* = a half-ellipse of horizontal louvers; the body is a lofted
|
||
* half-cone with a rounded nose.
|
||
* - `half-round` — a D-shaped half-round vent: a constant half-ellipse cross
|
||
* section extruded a short depth, flat louvered front face,
|
||
* capped back, curved top.
|
||
* - `slant-box` — a low box with a slanted top (tall front, lower back) and a
|
||
* framed front face holding recessed louvers + a screen.
|
||
*
|
||
* Every face is emitted through a winding-safe oriented quad/tri, the louvers
|
||
* are extruded into solid slabs, and the whole mesh is double-sided at the end
|
||
* (see `doubleSide`) so it reads correctly from any angle.
|
||
*
|
||
* Pure: no React, no scene access, no store mutation. Safe for unit tests, the
|
||
* placement preview, and the move-tool ghost.
|
||
*/
|
||
export function buildEyebrowVentGeometry(node: EyebrowVentNode): THREE.BufferGeometry {
|
||
const w = Math.max(0.15, node.width)
|
||
const d = Math.max(0.15, node.depth)
|
||
const h = Math.max(0.06, node.height)
|
||
const slats = Math.max(0, Math.min(8, Math.round(node.louverCount ?? 3)))
|
||
// slant-box: the low rear edge as a fraction of the tall front edge.
|
||
const backRatio = Math.max(0.15, Math.min(1, node.backRatio ?? 0.5))
|
||
|
||
const p: number[] = []
|
||
const n: number[] = []
|
||
const uv: number[] = []
|
||
|
||
// The hood seats directly on the roof at y=0 — no flashing plate.
|
||
if (node.style === 'half-round') {
|
||
addHalfRound(p, n, uv, w, d, h, 0, slats)
|
||
} else if (node.style === 'slant-box') {
|
||
addSlantBox(p, n, uv, w, d, h, 0, slats, backRatio)
|
||
} else {
|
||
addScoop(p, n, uv, w, d, h, 0, slats)
|
||
}
|
||
|
||
// Double-side the whole mesh at the geometry level: append a back-facing
|
||
// copy of every triangle (reversed winding + negated normals). The open
|
||
// shells (scoop hood, half-round dome, slant-box pocket) then read as solid
|
||
// from inside too, lit correctly from both sides — without a `DoubleSide`
|
||
// material, which poisons the MRT scene pass (see the ridge-vent renderer
|
||
// note). Only one of each coplanar pair front-faces any camera, so there's
|
||
// no z-fighting.
|
||
doubleSide(p, n, uv)
|
||
|
||
const geo = new THREE.BufferGeometry()
|
||
geo.setAttribute('position', new THREE.Float32BufferAttribute(p, 3))
|
||
geo.setAttribute('normal', new THREE.Float32BufferAttribute(n, 3))
|
||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2))
|
||
geo.computeBoundingSphere()
|
||
return geo
|
||
}
|
||
|
||
// ─── Style: scoop (the eyebrow) ───────────────────────────────────────────
|
||
|
||
function addScoop(
|
||
p: number[],
|
||
n: number[],
|
||
uv: number[],
|
||
w: number,
|
||
d: number,
|
||
h: number,
|
||
yB: number,
|
||
slats: number,
|
||
): void {
|
||
const a = w / 2
|
||
const b = h
|
||
const zF = d / 2
|
||
const NZ = 20
|
||
const NF = 18
|
||
|
||
// Lofted half-ellipse, full at the front (z = zF) tapering to a rounded
|
||
// nose at the back. `cos(v·π/2)` gives a smooth falloff to zero.
|
||
const rings: number[][][] = []
|
||
for (let i = 0; i <= NZ; i++) {
|
||
const v = i / NZ
|
||
const scale = Math.cos((v * Math.PI) / 2)
|
||
const z = zF - v * d
|
||
rings.push(halfRing(a, b, yB, z, scale, NF))
|
||
}
|
||
for (let i = 0; i < NZ; i++) {
|
||
addBand(p, n, uv, rings[i + 1]!, rings[i]!, NF, (qa, qb, qc, qd) => {
|
||
const mx = (qa[0]! + qb[0]! + qc[0]! + qd[0]!) / 4
|
||
const my = (qa[1]! + qb[1]! + qc[1]! + qd[1]!) / 4
|
||
return [mx, my - yB, 0] // radial-out from the spine
|
||
})
|
||
}
|
||
|
||
// Horizontal louvers filling the front half-ellipse opening.
|
||
addArchLouvers(p, n, uv, a, b, yB, zF - d * 0.04, slats)
|
||
}
|
||
|
||
// ─── Style: half-round (D-shaped louver vent) ─────────────────────────────
|
||
|
||
function addHalfRound(
|
||
p: number[],
|
||
n: number[],
|
||
uv: number[],
|
||
w: number,
|
||
d: number,
|
||
h: number,
|
||
yB: number,
|
||
slats: number,
|
||
): void {
|
||
const a = w / 2
|
||
// Cap the crown at a true half-round — never bulge past a semicircle, so the
|
||
// top reads as a clean, smaller-radius arch. `height` flattens it further.
|
||
const b = Math.min(h, a)
|
||
const zF = d / 2
|
||
const zB = -d / 2
|
||
const NF = 20
|
||
|
||
const ringF = halfRing(a, b, yB, zF, 1, NF)
|
||
const ringB = halfRing(a, b, yB, zB, 1, NF)
|
||
|
||
// Curved top shell (constant cross section).
|
||
addBand(p, n, uv, ringB, ringF, NF, (qa, qb, qc, qd) => {
|
||
const mx = (qa[0]! + qb[0]! + qc[0]! + qd[0]!) / 4
|
||
const my = (qa[1]! + qb[1]! + qc[1]! + qd[1]!) / 4
|
||
return [mx, my - yB, 0]
|
||
})
|
||
|
||
// Back cap — fan the rear semicircle, facing -Z.
|
||
const backCenter = [0, yB, zB]
|
||
for (let j = 0; j < NF; j++) {
|
||
pushTri(p, n, uv, backCenter, ringB[j + 1]!, ringB[j]!, [0, 0, -1])
|
||
}
|
||
|
||
// Louvered front face (a slat count bumped up — the D-vent reads denser).
|
||
addArchLouvers(p, n, uv, a, b, yB, zF - d * 0.04, slats > 0 ? Math.max(slats, 4) : 0)
|
||
}
|
||
|
||
// ─── Style: slant-box (low hooded box) ────────────────────────────────────
|
||
|
||
function addSlantBox(
|
||
p: number[],
|
||
n: number[],
|
||
uv: number[],
|
||
w: number,
|
||
d: number,
|
||
h: number,
|
||
yB: number,
|
||
slats: number,
|
||
backRatio: number,
|
||
): void {
|
||
const hw = w / 2
|
||
const zF = d / 2
|
||
const zB = -d / 2
|
||
const yFront = yB + h // tall front — the louvered/screened opening
|
||
const yBack = yB + h * backRatio // lower at the back, top slopes down to it
|
||
|
||
// Corner shorthands.
|
||
const fbl = [-hw, yB, zF]
|
||
const fbr = [hw, yB, zF]
|
||
const ftl = [-hw, yFront, zF]
|
||
const ftr = [hw, yFront, zF]
|
||
const bbl = [-hw, yB, zB]
|
||
const bbr = [hw, yB, zB]
|
||
const btl = [-hw, yBack, zB]
|
||
const btr = [hw, yBack, zB]
|
||
|
||
// Sides (trapezoids), slanted top, back wall.
|
||
pushQuad(p, n, uv, fbr, bbr, btr, ftr, [1, 0, 0])
|
||
pushQuad(p, n, uv, bbl, fbl, ftl, btl, [-1, 0, 0])
|
||
pushQuad(p, n, uv, ftl, ftr, btr, btl, [0, 1, 0])
|
||
pushQuad(p, n, uv, bbl, bbr, btr, btl, [0, 0, -1])
|
||
|
||
// Front frame: a face plate at z = zF with a rectangular hole. The louvers
|
||
// and screen live RECESSED inside that hole, so they're fully contained by
|
||
// the box — never poking out past the front face or above the opening.
|
||
const frame = Math.min(0.04, Math.min(w, h) * 0.14)
|
||
const oL = -hw + frame
|
||
const oR = hw - frame
|
||
const oB = yB + frame
|
||
const oT = yFront - frame
|
||
// Four frame rails around the opening (front face, +Z).
|
||
pushQuad(p, n, uv, [-hw, oT, zF], [hw, oT, zF], ftr, ftl, [0, 0, 1]) // top
|
||
pushQuad(p, n, uv, fbl, fbr, [hw, oB, zF], [-hw, oB, zF], [0, 0, 1]) // bottom
|
||
pushQuad(p, n, uv, [-hw, oB, zF], [oL, oB, zF], [oL, oT, zF], [-hw, oT, zF], [0, 0, 1]) // left
|
||
pushQuad(p, n, uv, [oR, oB, zF], [hw, oB, zF], [hw, oT, zF], [oR, oT, zF], [0, 0, 1]) // right
|
||
|
||
// Recessed screen panel at the back of the pocket (blocks see-through).
|
||
const screenZ = zF - d * 0.2
|
||
pushQuad(
|
||
p,
|
||
n,
|
||
uv,
|
||
[oL, oB, screenZ],
|
||
[oR, oB, screenZ],
|
||
[oR, oT, screenZ],
|
||
[oL, oT, screenZ],
|
||
[0, 0, 1],
|
||
)
|
||
|
||
// Horizontal louvers inside the pocket — bounded by the opening in height
|
||
// and recessed in depth between the frame face and the screen.
|
||
addRectLouvers(p, n, uv, oR, oB, oT, zF - d * 0.07, slats)
|
||
}
|
||
|
||
// ─── Louver helpers ───────────────────────────────────────────────────────
|
||
|
||
// Angled horizontal slats filling a half-ellipse opening (radius a × b,
|
||
// flat side on the plate at `yB`), set just inside the face at `z`.
|
||
function addArchLouvers(
|
||
p: number[],
|
||
n: number[],
|
||
uv: number[],
|
||
a: number,
|
||
b: number,
|
||
yB: number,
|
||
z: number,
|
||
slats: number,
|
||
): void {
|
||
if (slats <= 0) return
|
||
const drop = (b / (slats + 1)) * 0.55
|
||
const back = Math.max(0.012, b * 0.12)
|
||
const thick = Math.max(0.004, drop * 0.32)
|
||
for (let k = 1; k <= slats; k++) {
|
||
const s = k / (slats + 1) // height fraction up the semicircle (sin φ)
|
||
const y = yB + b * s
|
||
const halfX = a * Math.sqrt(Math.max(0, 1 - s * s)) * 0.96
|
||
if (halfX < 1e-3) continue
|
||
addSlab(
|
||
p,
|
||
n,
|
||
uv,
|
||
[-halfX, y, z],
|
||
[halfX, y, z],
|
||
[halfX, y - drop, z - back],
|
||
[-halfX, y - drop, z - back],
|
||
thick,
|
||
)
|
||
}
|
||
}
|
||
|
||
// Angled horizontal slats across a rectangular opening (±halfX, yB..yTop) set
|
||
// just inside the face at `z`.
|
||
function addRectLouvers(
|
||
p: number[],
|
||
n: number[],
|
||
uv: number[],
|
||
halfX: number,
|
||
yB: number,
|
||
yTop: number,
|
||
z: number,
|
||
slats: number,
|
||
): void {
|
||
if (slats <= 0) return
|
||
const span = yTop - yB
|
||
const drop = (span / (slats + 1)) * 0.55
|
||
const back = Math.max(0.012, span * 0.18)
|
||
const thick = Math.max(0.004, drop * 0.32)
|
||
for (let k = 1; k <= slats; k++) {
|
||
const y = yB + (span * k) / (slats + 1)
|
||
addSlab(
|
||
p,
|
||
n,
|
||
uv,
|
||
[-halfX, y, z],
|
||
[halfX, y, z],
|
||
[halfX, y - drop, z - back],
|
||
[-halfX, y - drop, z - back],
|
||
thick,
|
||
)
|
||
}
|
||
}
|
||
|
||
// Extrude a planar quad (a→b→c→d) into a thin solid slab of `t` thickness
|
||
// along its normal — gives louver blades real depth so they don't read as
|
||
// paper-thin at a grazing angle. Each of the 6 faces is oriented outward from
|
||
// the slab centre, so winding is correct without hand-tracing.
|
||
function addSlab(
|
||
p: number[],
|
||
n: number[],
|
||
uv: number[],
|
||
a: number[],
|
||
b: number[],
|
||
c: number[],
|
||
d: number[],
|
||
t: number,
|
||
): void {
|
||
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 len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1
|
||
nx = (nx / len) * (t / 2)
|
||
ny = (ny / len) * (t / 2)
|
||
nz = (nz / len) * (t / 2)
|
||
const up = (q: number[]): number[] => [q[0]! + nx, q[1]! + ny, q[2]! + nz]
|
||
const dn = (q: number[]): number[] => [q[0]! - nx, q[1]! - ny, q[2]! - nz]
|
||
const aT = up(a)
|
||
const bT = up(b)
|
||
const cT = up(c)
|
||
const dT = up(d)
|
||
const aB = dn(a)
|
||
const bB = dn(b)
|
||
const cB = dn(c)
|
||
const dB = dn(d)
|
||
let cx = 0
|
||
let cy = 0
|
||
let cz = 0
|
||
for (const v of [aT, bT, cT, dT, aB, bB, cB, dB]) {
|
||
cx += v[0]! / 8
|
||
cy += v[1]! / 8
|
||
cz += v[2]! / 8
|
||
}
|
||
const face = (q0: number[], q1: number[], q2: number[], q3: number[]) => {
|
||
const mx = (q0[0]! + q1[0]! + q2[0]! + q3[0]!) / 4
|
||
const my = (q0[1]! + q1[1]! + q2[1]! + q3[1]!) / 4
|
||
const mz = (q0[2]! + q1[2]! + q2[2]! + q3[2]!) / 4
|
||
pushQuad(p, n, uv, q0, q1, q2, q3, [mx - cx, my - cy, mz - cz])
|
||
}
|
||
face(aT, bT, cT, dT) // top
|
||
face(aB, bB, cB, dB) // bottom
|
||
face(aT, bT, bB, aB) // leading edge
|
||
face(bT, cT, cB, bB) // right
|
||
face(cT, dT, dB, cB) // trailing edge
|
||
face(dT, aT, aB, dB) // left
|
||
}
|
||
|
||
// Half-ellipse ring (flat side down on `yB`): φ from 0 (+x) to π (−x).
|
||
function halfRing(
|
||
a: number,
|
||
b: number,
|
||
yB: number,
|
||
z: number,
|
||
scale: number,
|
||
steps: number,
|
||
): number[][] {
|
||
const row: number[][] = []
|
||
for (let j = 0; j <= steps; j++) {
|
||
const phi = (Math.PI * j) / steps
|
||
row.push([a * scale * Math.cos(phi), yB + b * scale * Math.sin(phi), z])
|
||
}
|
||
return row
|
||
}
|
||
|
||
// ─── Primitives ───────────────────────────────────────────────────────────
|
||
|
||
function addBand(
|
||
p: number[],
|
||
n: number[],
|
||
uv: number[],
|
||
rA: number[][],
|
||
rB: number[][],
|
||
lng: number,
|
||
hintFn: (a: number[], b: number[], c: number[], d: number[]) => number[],
|
||
): void {
|
||
for (let j = 0; j < lng; j++) {
|
||
const a = rA[j]!
|
||
const b = rA[j + 1]!
|
||
const c = rB[j + 1]!
|
||
const d = rB[j]!
|
||
pushQuad(p, n, uv, a, b, c, d, hintFn(a, b, c, d))
|
||
}
|
||
}
|
||
|
||
// Append a reversed-winding, negated-normal copy of every triangle already in
|
||
// the buffers, making the mesh render from both sides under a FrontSide
|
||
// material. `triCount` is snapshotted up front so we only mirror the originals.
|
||
function doubleSide(p: number[], n: number[], uv: number[]): void {
|
||
const triCount = Math.floor(p.length / 9)
|
||
for (let t = 0; t < triCount; t++) {
|
||
const o = t * 9
|
||
const u = t * 6
|
||
// verts v0, v2, v1 (reverse the last two to flip the face).
|
||
p.push(
|
||
p[o]!,
|
||
p[o + 1]!,
|
||
p[o + 2]!,
|
||
p[o + 6]!,
|
||
p[o + 7]!,
|
||
p[o + 8]!,
|
||
p[o + 3]!,
|
||
p[o + 4]!,
|
||
p[o + 5]!,
|
||
)
|
||
n.push(
|
||
-n[o]!,
|
||
-n[o + 1]!,
|
||
-n[o + 2]!,
|
||
-n[o + 6]!,
|
||
-n[o + 7]!,
|
||
-n[o + 8]!,
|
||
-n[o + 3]!,
|
||
-n[o + 4]!,
|
||
-n[o + 5]!,
|
||
)
|
||
uv.push(uv[u]!, uv[u + 1]!, uv[u + 4]!, uv[u + 5]!, uv[u + 2]!, uv[u + 3]!)
|
||
}
|
||
}
|
||
|
||
// ─── Winding-safe primitives ─────────────────────────────────────────────
|
||
|
||
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)
|
||
}
|
||
|
||
function pushTri(
|
||
positions: number[],
|
||
normals: number[],
|
||
uvs: number[],
|
||
a: number[],
|
||
b: number[],
|
||
c: number[],
|
||
hint: number[],
|
||
) {
|
||
let nx = (b[1]! - a[1]!) * (c[2]! - a[2]!) - (b[2]! - a[2]!) * (c[1]! - a[1]!)
|
||
let ny = (b[2]! - a[2]!) * (c[0]! - a[0]!) - (b[0]! - a[0]!) * (c[2]! - a[2]!)
|
||
let nz = (b[0]! - a[0]!) * (c[1]! - a[1]!) - (b[1]! - a[1]!) * (c[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
|
||
|
||
if (flip) {
|
||
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!)
|
||
} else {
|
||
positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!)
|
||
}
|
||
uvs.push(0, 0, 1, 0, 0, 1)
|
||
for (let i = 0; i < 3; i++) normals.push(nx, ny, nz)
|
||
}
|