140 lines
6.5 KiB
TypeScript
140 lines
6.5 KiB
TypeScript
import {
|
||
getRoofSegmentSurfaceY,
|
||
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)
|
||
}
|
||
|
||
// 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): THREE.Vector3 {
|
||
return new THREE.Vector3(dx * tan, 1, dz * tan).normalize()
|
||
}
|
||
|
||
export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 {
|
||
const { roofType, depth, width } = seg
|
||
const slope = getSegmentSlopeFrame(seg)
|
||
if (slope.activeRh === 0 || slope.tanTheta === 0) {
|
||
return new THREE.Vector3(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)
|
||
}
|
||
}
|
||
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
|
||
}
|
||
|
||
// Single slope falling toward +Z (ridge at -Z, eave at +Z).
|
||
if (roofType === 'shed') {
|
||
return buildSlopeNormal(0, 1, primaryTan)
|
||
}
|
||
|
||
// 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 fx = halfW > 0 ? Math.abs(lx) / halfW : 0
|
||
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
|
||
if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
|
||
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan)
|
||
}
|
||
|
||
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)
|
||
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan)
|
||
}
|
||
|
||
if (roofType === 'dutch') {
|
||
// Hip on the short-axis ends, gable on the long-axis sides. Both
|
||
// share the primary pitch on their primary (eave-band) face, so the
|
||
// approximation collapses to "pick the dominant axis."
|
||
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
|
||
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
|
||
if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
|
||
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan)
|
||
}
|
||
|
||
return new THREE.Vector3(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 wx = new THREE.Vector3(1, 0, 0)
|
||
const right = wx.sub(normal.clone().multiplyScalar(new THREE.Vector3(1, 0, 0).dot(normal)))
|
||
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()
|
||
}
|
||
const forward = new THREE.Vector3().crossVectors(right, normal).normalize()
|
||
const m = new THREE.Matrix4().makeBasis(right, normal, forward)
|
||
return out.setFromRotationMatrix(m)
|
||
}
|