549 lines
20 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 {
getRoofModuleFaces,
getRoofSegmentSurfaceY,
getRoofShapeInsets,
getRoofShapeRatios,
getSegmentSlopeFrame,
ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode,
} from '@pascal-app/core'
import * as THREE from 'three'
// ─── Roof-surface helpers ────────────────────────────────────────────
// Analytical slope geometry for a roof segment, shared by every roof
// accessory that seats itself on the slope (solar-panel, skylight,
// box-vent). Lives here rather than inside any one kind's folder so the
// accessories don't reach across into a sibling kind for it.
export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number {
return getRoofSegmentSurfaceY(seg, lx, lz)
}
export function getRoofTopSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number {
return getRoofSurfaceFaceBoundsAt(seg, lx, lz).surfaceYAt(lx, lz)
}
export type RoofSurfacePoint2D = [number, number]
export type RoofSurfaceFaceBounds = {
polygon: RoofSurfacePoint2D[]
minX: number
maxX: number
minZ: number
maxZ: number
surfaceYAt: (x: number, z: number) => number
xIntervalAtZ: (z: number) => [number, number] | null
zIntervalAtX: (x: number) => [number, number] | null
}
export function getRoofSurfaceFaceBoundsAt(
segment: RoofSegmentNode,
lx: number,
lz: number,
): RoofSurfaceFaceBounds {
const faces = getRoofSurfaceFaces(segment)
const face = topmostFaceAtPoint(faces, lx, lz) ?? nearestFaceToPoint(faces, [lx, lz])
const { polygon } = face
const xs = polygon.map((point) => point[0])
const zs = polygon.map((point) => point[1])
return {
polygon,
minX: Math.min(...xs),
maxX: Math.max(...xs),
minZ: Math.min(...zs),
maxZ: Math.max(...zs),
surfaceYAt: (x, z) =>
surfaceYOnFace(face.vertices, x, z) ?? getRoofSegmentSurfaceY(segment, x, z),
xIntervalAtZ: (z) => lineInterval(polygon, 'x', z),
zIntervalAtX: (x) => lineInterval(polygon, 'z', x),
}
}
type RoofSurfaceFace = {
polygon: RoofSurfacePoint2D[]
vertices: FaceVertex[]
}
type FaceVertex = { x: number; y: number; z: number }
const SHINGLE_SURFACE_EPSILON = 0.02
const FACE_TOLERANCE = 1e-6
const ROOF_SURFACE_FACE_CACHE_MAX = 128
const roofSurfaceFaceCache = new Map<string, RoofSurfaceFace[]>()
const _downSlopeYawNormal = new THREE.Vector3()
const _surfaceQuatRight = new THREE.Vector3()
const _surfaceQuatForward = new THREE.Vector3()
const _surfaceQuatMatrix = new THREE.Matrix4()
function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] {
const key = roofSurfaceFaceCacheKey(segment)
const cached = roofSurfaceFaceCache.get(key)
if (cached) return cached
const faces = buildRoofSurfaceFaces(segment)
roofSurfaceFaceCache.set(key, faces)
if (roofSurfaceFaceCache.size > ROOF_SURFACE_FACE_CACHE_MAX) {
const oldestKey = roofSurfaceFaceCache.keys().next().value
if (oldestKey) roofSurfaceFaceCache.delete(oldestKey)
}
return faces
}
function roofSurfaceFaceCacheKey(segment: RoofSegmentNode): string {
return [
segment.roofType,
segment.width,
segment.depth,
segment.wallHeight,
segment.wallThickness,
segment.deckThickness,
segment.overhang,
segment.shingleThickness,
segment.pitch,
segment.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio,
segment.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio,
segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio,
segment.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio,
segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
segment.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio,
].join('|')
}
function buildRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] {
const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang } = segment
const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(segment)
const verticalRt = activeRh > 0 ? deckThickness / cosTheta : deckThickness
const horizontalOverhang = (overhang ?? 0) * cosTheta
const deckExt = wallThickness / 2 + horizontalOverhang
const shingleThickness = segment.shingleThickness ?? 0
const stSin = shingleThickness * sinTheta
const stCos = shingleThickness * cosTheta
const shinBotW = Math.max(0.01, width + 2 * deckExt)
const shinBotD = Math.max(0.01, depth + 2 * deckExt)
const deckDrop = deckExt * tanTheta
const shinBotWh = wallHeight - deckDrop + verticalRt
let shinBotRh = activeRh
if (activeRh > 0) {
shinBotRh = activeRh + deckDrop
if (roofType === 'shed') shinBotRh = activeRh + 2 * deckDrop
}
let shinTopW = shinBotW
let shinTopD = shinBotD
let transZ = 0
if (roofType === 'hip' || roofType === 'mansard' || roofType === 'dutch') {
shinTopW += 2 * stSin
shinTopD += 2 * stSin
} else if (roofType === 'gable' || roofType === 'gambrel') {
shinTopD += 2 * stSin
} else if (roofType === 'shed') {
shinTopD += stSin
transZ = stSin / 2
}
const shinTopWh = shinBotWh + stCos
let shinTopRh = shinBotRh
if (activeRh > 0) shinTopRh = shinBotRh + stSin * tanTheta
const availableR = (Math.min(shinBotW, shinBotD) / 2) * 0.95
const maxDrop = tanTheta > 0.001 ? availableR / tanTheta : 2
const dropTop = Math.min(1, maxDrop * 0.4)
const topBaseY = shinBotWh - dropTop
const dutchHipWidthRatio = segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio
const insetsTop = getRoofShapeInsets({
roofType,
width,
depth,
wh: shinTopWh,
baseY: topBaseY,
isVoid: false,
brushW: shinTopW,
brushD: shinTopD,
tanTheta,
shingleThickness,
dutchHipWidthRatio,
})
const shapeRatios = getRoofShapeRatios({
gambrelLowerWidthRatio: segment.gambrelLowerWidthRatio,
mansardSteepWidthRatio: segment.mansardSteepWidthRatio,
dutchHipWidthRatio,
dutchHipHeightRatio: segment.dutchHipHeightRatio,
dutchWaistLengthRatio: segment.dutchWaistLengthRatio,
dutchGabletRake: segment.dutchGabletRake,
})
return getRoofModuleFaces({
type: roofType,
w: shinTopW,
d: shinTopD,
wh: shinTopWh,
rh: shinTopRh,
baseY: topBaseY,
insets: insetsTop,
baseW: width,
baseD: depth,
tanTheta,
shapeRatios,
dutchTopRakeThickness: segment.dutchTopRakeThickness,
})
.filter((face) => faceNormalY(face) > SHINGLE_SURFACE_EPSILON)
.map((face) => {
const vertices = face.map((point) => ({ ...point, z: point.z + transZ }))
return {
vertices,
polygon: dedupePolygon(vertices.map((point) => [point.x, point.z])),
}
})
.filter((face) => face.polygon.length >= 3)
}
function topmostFaceAtPoint(
faces: RoofSurfaceFace[],
lx: number,
lz: number,
): RoofSurfaceFace | null {
let best: RoofSurfaceFace | null = null
let bestY = Number.NEGATIVE_INFINITY
for (const face of faces) {
if (!pointInPolygon([lx, lz], face.polygon)) continue
const y = surfaceYOnFace(face.vertices, lx, lz)
if (y === null || y <= bestY) continue
best = face
bestY = y
}
return best
}
function faceNormalY(face: FaceVertex[]): number {
const a = face[0]
const b = face[1]
const c = face[2]
if (!(a && b && c)) return 0
const abx = b.x - a.x
const aby = b.y - a.y
const abz = b.z - a.z
const acx = c.x - a.x
const acy = c.y - a.y
const acz = c.z - a.z
return abz * acx - abx * acz
}
function dedupePolygon(points: RoofSurfacePoint2D[]): RoofSurfacePoint2D[] {
const out: RoofSurfacePoint2D[] = []
for (const point of points) {
const prev = out.at(-1)
if (prev && Math.hypot(prev[0] - point[0], prev[1] - point[1]) <= FACE_TOLERANCE) continue
out.push(point)
}
const first = out[0]
const last = out.at(-1)
if (first && last && Math.hypot(first[0] - last[0], first[1] - last[1]) <= FACE_TOLERANCE) {
out.pop()
}
return out
}
function pointInPolygon(point: RoofSurfacePoint2D, polygon: RoofSurfacePoint2D[]): boolean {
let inside = false
const [px, pz] = point
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, zi] = polygon[i]!
const [xj, zj] = polygon[j]!
if (pointOnSegment(point, [xi, zi], [xj, zj])) return true
const intersects = zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi
if (intersects) inside = !inside
}
return inside
}
function pointOnSegment(
point: RoofSurfacePoint2D,
a: RoofSurfacePoint2D,
b: RoofSurfacePoint2D,
): boolean {
const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1])
if (Math.abs(cross) > FACE_TOLERANCE) return false
const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])
if (dot < -FACE_TOLERANCE) return false
const lengthSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2
return dot <= lengthSq + FACE_TOLERANCE
}
function nearestFaceToPoint(faces: RoofSurfaceFace[], point: RoofSurfacePoint2D): RoofSurfaceFace {
let best = faces[0]
let bestDistance = Number.POSITIVE_INFINITY
for (const face of faces) {
const distance = distanceToPolygon(point, face.polygon)
if (distance < bestDistance) {
best = face
bestDistance = distance
}
}
return (
best ?? {
polygon: [
[-0.5, -0.5],
[0.5, -0.5],
[0.5, 0.5],
[-0.5, 0.5],
],
vertices: [
{ x: -0.5, y: 0, z: -0.5 },
{ x: 0.5, y: 0, z: -0.5 },
{ x: 0.5, y: 0, z: 0.5 },
{ x: -0.5, y: 0, z: 0.5 },
],
}
)
}
function surfaceYOnFace(vertices: FaceVertex[], x: number, z: number): number | null {
for (let i = 0; i < vertices.length - 2; i++) {
const a = vertices[i]
const b = vertices[i + 1]
const c = vertices[i + 2]
if (!(a && b && c)) continue
const abx = b.x - a.x
const aby = b.y - a.y
const abz = b.z - a.z
const acx = c.x - a.x
const acy = c.y - a.y
const acz = c.z - a.z
const nx = aby * acz - abz * acy
const ny = abz * acx - abx * acz
const nz = abx * acy - aby * acx
if (Math.abs(ny) <= FACE_TOLERANCE) continue
return a.y - (nx * (x - a.x) + nz * (z - a.z)) / ny
}
return null
}
function distanceToPolygon(point: RoofSurfacePoint2D, polygon: RoofSurfacePoint2D[]): number {
if (pointInPolygon(point, polygon)) return 0
let best = Number.POSITIVE_INFINITY
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i]!
const b = polygon[(i + 1) % polygon.length]!
best = Math.min(best, distanceToSegment(point, a, b))
}
return best
}
function distanceToSegment(
point: RoofSurfacePoint2D,
a: RoofSurfacePoint2D,
b: RoofSurfacePoint2D,
): number {
const abx = b[0] - a[0]
const abz = b[1] - a[1]
const lengthSq = abx * abx + abz * abz
if (lengthSq <= FACE_TOLERANCE) return Math.hypot(point[0] - a[0], point[1] - a[1])
const t = Math.max(0, Math.min(1, ((point[0] - a[0]) * abx + (point[1] - a[1]) * abz) / lengthSq))
return Math.hypot(point[0] - (a[0] + abx * t), point[1] - (a[1] + abz * t))
}
function lineInterval(
polygon: RoofSurfacePoint2D[],
axis: 'x' | 'z',
value: number,
): [number, number] | null {
const hits: number[] = []
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i]!
const b = polygon[(i + 1) % polygon.length]!
const aFixed = axis === 'x' ? a[1] : a[0]
const bFixed = axis === 'x' ? b[1] : b[0]
const aVar = axis === 'x' ? a[0] : a[1]
const bVar = axis === 'x' ? b[0] : b[1]
if (Math.abs(aFixed - value) <= FACE_TOLERANCE && Math.abs(bFixed - value) <= FACE_TOLERANCE) {
hits.push(aVar, bVar)
continue
}
if (value < Math.min(aFixed, bFixed) - FACE_TOLERANCE) continue
if (value > Math.max(aFixed, bFixed) + FACE_TOLERANCE) continue
if (Math.abs(aFixed - bFixed) <= FACE_TOLERANCE) continue
const t = (value - aFixed) / (bFixed - aFixed)
if (t < -FACE_TOLERANCE || t > 1 + FACE_TOLERANCE) continue
hits.push(aVar + (bVar - aVar) * t)
}
const unique = Array.from(new Set(hits.map((hit) => hit.toFixed(6)))).map(Number)
if (unique.length < 2) return null
return [Math.min(...unique), Math.max(...unique)]
}
// Outward normal for a roof surface tilting at angle θ in the horizontal
// direction (dx, dz). Derivation: the surface tangent vectors are the
// ridge axis (perpendicular to the fall line, horizontal) and the
// down-slope direction (cos θ horizontal + −sin θ vertical). Crossing
// them gives the outward normal ∝ (sin θ · dx, cos θ, sin θ · dz),
// equivalently (dx · tan θ, 1, dz · tan θ) un-normalised.
function buildSlopeNormal(dx: number, dz: number, tan: number, out: THREE.Vector3): THREE.Vector3 {
return out.set(dx * tan, 1, dz * tan).normalize()
}
export function getAnalyticalNormal(
lx: number,
lz: number,
seg: RoofSegmentNode,
out = new THREE.Vector3(),
): THREE.Vector3 {
const { roofType, depth, width } = seg
const slope = getSegmentSlopeFrame(seg)
if (slope.activeRh === 0 || slope.tanTheta === 0) {
return out.set(0, 1, 0)
}
const primaryTan = slope.tanTheta
const halfW = width / 2
const halfD = depth / 2
// Ridge runs along X — slope falls in ±Z. Gambrel shares the gable
// dispatch (its kink-to-eave/lower tier is the primary slope frame).
if (roofType === 'gable' || roofType === 'gambrel') {
if (roofType === 'gambrel') {
// Tier-aware: the upper (shallower) face spans |z| < mz; the
// lower (steep) face spans mz < |z| ≤ halfD. Using primaryTan on
// the upper tier would tilt the ghost too steeply near the ridge.
const lowerWidthRatio =
seg.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio
const lowerHeightRatio =
seg.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio
const mz = halfD * lowerWidthRatio
if (Math.abs(lz) <= mz) {
const upperRise = slope.activeRh * (1 - lowerHeightRatio)
const upperRun = mz
const upperTan = upperRun > 0 ? upperRise / upperRun : 0
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan, out)
}
}
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan, out)
}
// Single slope falling toward +Z (ridge at -Z, eave at +Z).
if (roofType === 'shed') {
return buildSlopeNormal(0, 1, primaryTan, out)
}
// 4-sided slopes: the dominant axis chooses which face the point sits
// on. Hip is uniform across all four faces. Mansard has a steep outer
// band (primaryTan) and a shallow top inside the waist. Dutch has hip
// ends and gable sides — both share the same primaryTan from the
// slope frame, so directional dispatch is enough.
if (roofType === 'hip') {
const onZ = (halfD > 0 ? Math.abs(lz) / halfD : 0) >= (halfW > 0 ? Math.abs(lx) / halfW : 0)
if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan, out)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan, out)
}
if (roofType === 'mansard') {
const widthRatio = seg.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio
const heightRatio = seg.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio
const inset = Math.min(width, depth) * widthRatio
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
const onZ = fz >= fx
const inSteepBand = onZ ? Math.abs(lz) > halfD - inset : Math.abs(lx) > halfW - inset
let tan = primaryTan
if (!inSteepBand) {
// Top hip (shallow) above the waist — rises from the waist
// rectangle at fraction `heightRatio` of activeRh up to the peak.
const topRise = slope.activeRh * (1 - heightRatio)
const topRun = Math.max(0, Math.min(halfW, halfD) - inset)
tan = topRun > 0 ? topRise / topRun : 0
}
if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan, out)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan, out)
}
if (roofType === 'dutch') {
const inset =
Math.min(width, depth) * (seg.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio)
const heightRatio = seg.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio
const lengthRatio = seg.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio
const lowerRise = slope.activeRh * heightRatio
if (width >= depth) {
const waistHalfX = Math.max(0, (halfW - inset) * lengthRatio)
const waistHalfZ = Math.max(0, halfD - inset)
if (Math.abs(lx) <= waistHalfX && Math.abs(lz) <= waistHalfZ) {
const topRise = slope.activeRh * (1 - heightRatio)
const topTan = waistHalfZ > 0 ? topRise / waistHalfZ : 0
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, topTan, out)
}
const xRun = Math.max(0.0001, halfW - waistHalfX)
const zRun = Math.max(0.0001, halfD - waistHalfZ)
const xTan = Math.abs(lx) > waistHalfX ? lowerRise / xRun : 0
const zTan = Math.abs(lz) > waistHalfZ ? lowerRise / zRun : 0
return out.set((lx >= 0 ? 1 : -1) * xTan, 1, (lz >= 0 ? 1 : -1) * zTan).normalize()
}
const waistHalfX = Math.max(0, halfW - inset)
const waistHalfZ = Math.max(0, (halfD - inset) * lengthRatio)
if (Math.abs(lx) <= waistHalfX && Math.abs(lz) <= waistHalfZ) {
const topRise = slope.activeRh * (1 - heightRatio)
const topTan = waistHalfX > 0 ? topRise / waistHalfX : 0
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, topTan, out)
}
const xRun = Math.max(0.0001, halfW - waistHalfX)
const zRun = Math.max(0.0001, halfD - waistHalfZ)
const xTan = Math.abs(lx) > waistHalfX ? lowerRise / xRun : 0
const zTan = Math.abs(lz) > waistHalfZ ? lowerRise / zRun : 0
return out.set((lx >= 0 ? 1 : -1) * xTan, 1, (lz >= 0 ? 1 : -1) * zTan).normalize()
}
return out.set(0, 1, 0)
}
// ─── Quaternion helper ───────────────────────────────────────────────
// Given a normal in the panel's parent frame, build a rotation that
// aligns the panel's local +Y to that normal. Lifted out so the
// renderer and the placement preview share one source of truth.
export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaternion) {
// Build `right` by projecting world +X onto the surface plane instead of
// using `up × normal`. The cross-product version flips sign when the
// normal's Z component flips (e.g. the two slopes of a gable roof), so
// the resulting basis has its +X axis reversed on one slope — which
// makes hosted children's local +X point in opposite world directions
// depending on which slope they sit on, and registry chevrons end up
// anchored to the wrong edge. Projecting +X keeps the basis stable
// across slope-flips that share the same X axis.
const right = _surfaceQuatRight.set(1, 0, 0).addScaledVector(normal, -normal.x)
if (right.lengthSq() < 1e-6) {
// Degenerate: normal is parallel to ±X. Fall back to +Z so the basis
// is still well-defined; this is the wall-like edge case (vertical
// surface facing along X) where any in-plane convention is OK.
right.set(0, 0, 1)
} else {
right.normalize()
}
_surfaceQuatForward.crossVectors(right, normal).normalize()
_surfaceQuatMatrix.makeBasis(right, normal, _surfaceQuatForward)
return out.setFromRotationMatrix(_surfaceQuatMatrix)
}
// Yaw (about the surface normal, composed AFTER `surfaceQuatFromNormal`)
// that points the node's local +Z down the slope. The analytical normals
// are axis-aligned (n.x or n.z is 0), and in the +X-projected basis above
// the down-slope direction decomposes to atan2(n.x · n.y, n.z): +Z face
// → 0, −Z → π, +X → +π/2, −X → −π/2. Kept next to `surfaceQuatFromNormal`
// so the two stay in lockstep — the formula is only valid for its basis.
export function getDownSlopeYaw(lx: number, lz: number, seg: RoofSegmentNode): number {
const n = getAnalyticalNormal(lx, lz, seg, _downSlopeYawNormal)
if (n.x === 0 && n.z === 0) return 0
return Math.atan2(n.x * n.y, n.z)
}