Files
editor/packages/nodes/src/shared/roof-surface.ts
T
Aymeric RabotandGitHub aab48e053f fix(editor): harden editor interactions and WebGPU rendering
Fix editor bug sweep regressions, WebGPU CSG/material crashes, Shift snap bypass behavior, arrow handle drag projection, and the wall preview null guard covered by the Sentry follow-up PRs.
2026-06-11 13:03:34 -04:00

152 lines
7.1 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 {
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)
}
// 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)
if (n.x === 0 && n.z === 0) return 0
return Math.atan2(n.x * n.y, n.z)
}