308 lines
11 KiB
TypeScript

import {
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
getDutchRoofMetrics,
type RoofNode,
type RoofSegmentNode,
} from '@pascal-app/core'
/**
* Stage C floor-plan builder for roof segment. Renders the segment as a
* proper architectural roof plan: the footprint outline plus the
* ridge / hip / break linework (and a downslope arrow for sheds) that
* makes each roof shape — hip, gable, shed, gambrel, dutch, mansard,
* flat — read distinctly, rather than as a bare rectangle.
*
* All linework is derived in segment-local space, mirroring the faces the
* 3D builder (`getModuleFaces` in the roof system) generates per type, so
* the plan and the model agree. Everything is composed into world coords
* via the parent roof's position + rotation and the segment's own.
*/
export function buildRoofSegmentFloorplan(
node: RoofSegmentNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const roof = ctx.parent as RoofNode | null
if (roof?.type !== 'roof') return null
// Segment center in world coords. Floor-plan plots at `-rotation` so
// SVG's CW-with-y-down `rotate` direction ends up matching Three.js
// Y-rotation (CCW from top-down). The standard math rotation matrix
// applied to (localX, localZ) with `+rotation` gives screen-CW in
// SVG; negating the rotation gives screen-CCW = matches Three.js.
const planRoofRotation = -roof.rotation
const cosRoof = Math.cos(planRoofRotation)
const sinRoof = Math.sin(planRoofRotation)
const localX = node.position[0]
const localZ = node.position[2]
const cx = roof.position[0] + localX * cosRoof - localZ * sinRoof
const cz = roof.position[2] + localX * sinRoof + localZ * cosRoof
const rotation = -(roof.rotation + node.rotation)
const cos = Math.cos(rotation)
const sin = Math.sin(rotation)
const halfWidth = node.width / 2
const halfDepth = node.depth / 2
// Map a segment-local point (lx = width axis, lz = depth axis) into
// world plan coords — the same rotation + translation the footprint
// corners use. Shared by the per-type ridge/hip linework below.
const toPlan = (lx: number, lz: number): FloorplanPoint => [
cx + lx * cos - lz * sin,
cz + lx * sin + lz * cos,
]
const corners: Array<[number, number]> = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
]
const points: FloorplanPoint[] = corners.map(([x, y]) => toPlan(x, y))
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted
// Black architectural outline by default; palette accent on select.
// Mirrors the elevator / column style so all structural elements read
// the same in the floor plan.
const baseInk = '#111111'
const stroke = showSelectedChrome && palette ? palette.selectedStroke : baseInk
const children: FloorplanGeometry[] = [
// Invisible hit-target — full footprint, transparent fill, captures
// clicks across the entire roof rectangle (so the user doesn't need
// to pixel-hunt the outline strokes).
{
kind: 'polygon',
points,
fill: stroke,
fillOpacity: 0,
stroke: 'none',
strokeWidth: 0,
pointerEvents: 'all',
},
]
// The segment's own rectangle outline + fill render ONLY while it's
// selected / highlighted — that highlights which sub-plane is active
// (including its interior edges shared with neighbours). When unselected
// the eaves come from the parent roof's merged outline
// (`buildRoofFloorplan`), so overlapping segments read as one combined
// shape instead of stacked rectangles. Ridges/hips below always draw.
if (showSelectedChrome) {
children.push({
kind: 'polygon',
points,
fill: '#fed7aa',
fillOpacity: 0.55,
stroke,
strokeWidth: 0.035,
strokeLinejoin: 'miter',
})
}
// NOTE: the ridge / hip / break / slope linework is NOT drawn here — the
// parent roof's builder (`buildRoofFloorplan`) draws it for every segment,
// clipped against the merged-roof valleys so a segment's ridge stops at
// the junction instead of running on into a neighbour it overlaps. This
// builder owns only the per-segment interaction chrome below. The shape
// math lives in `getRoofSegmentPlanLinework` (exported for the roof
// builder to consume).
// Selection chrome — orange move-handle dot at the centre, four
// perpendicular side resize-arrows (width on X, depth on Z), and a
// rotate-arrow at the +X/+Z corner. Sister to the 3D handles in
// `definition.ts`. Resize/rotate route through the matching
// `floorplanAffordances`; the dot drives body-move via
// `def.floorplanMoveTarget`.
if (isSelected) {
children.push({
kind: 'move-handle',
point: [cx, cz],
})
const sideArrowOffset = 0.12
const rotateCornerOffset = 0.22
const halfW = node.width / 2
const halfD = node.depth / 2
// Effective rotation = parent roof rotation + segment-local rotation.
// Reuse `cos` / `sin` from the corner computation above (they were
// computed for the same `rotation` value).
const rotateLocal = (lx: number, ly: number): [number, number] => [
lx * cos - ly * sin,
lx * sin + ly * cos,
]
const sides: Array<{
local: [number, number]
localAngle: number
axis: 'x' | 'z'
side: 1 | -1
}> = [
{ local: [halfW + sideArrowOffset, 0], localAngle: 0, axis: 'x', side: 1 },
{ local: [-(halfW + sideArrowOffset), 0], localAngle: Math.PI, axis: 'x', side: -1 },
{ local: [0, halfD + sideArrowOffset], localAngle: Math.PI / 2, axis: 'z', side: 1 },
{ local: [0, -(halfD + sideArrowOffset)], localAngle: -Math.PI / 2, axis: 'z', side: -1 },
]
for (const s of sides) {
const [ox, oz] = rotateLocal(s.local[0], s.local[1])
const [tx, tz] = rotateLocal(Math.cos(s.localAngle), Math.sin(s.localAngle))
children.push({
kind: 'move-arrow',
point: [cx + ox, cz + oz],
angle: Math.atan2(tz, tx),
affordance: 'roof-segment-resize',
payload: { axis: s.axis, side: s.side },
})
}
// Rotate-arrow at the +X / +Z corner. Local angle π/4 puts the
// curved arrow's bow at the diagonal corner so it reads as a
// rotation gizmo around the segment centre.
const [cornerX, cornerZ] = rotateLocal(halfW + rotateCornerOffset, halfD + rotateCornerOffset)
const [radialX, radialZ] = rotateLocal(1, 1)
children.push({
kind: 'rotate-arrow',
point: [cx + cornerX, cz + cornerZ],
angle: Math.atan2(radialZ, radialX),
affordance: 'roof-segment-rotate',
pivot: [cx, cz],
})
}
return { kind: 'group', children }
}
export type PlanPt = readonly [number, number]
export type PlanSeg = readonly [PlanPt, PlanPt]
/**
* Ridge / hip / break linework for a roof segment in segment-local space
* (lx = width axis, lz = depth axis), mirroring the faces the 3D builder
* (`getModuleFaces`) generates for each roof type. The floor-plan builder
* maps these to world coords. `slope`, when set, is a shed roof's downhill
* fall direction (tail = high eave, head = low eave).
*
* - ridge: peak line(s) where opposite slopes meet
* - hip: diagonal from an eave corner up to a ridge end / peak
* - break: horizontal fold where the slope angle changes (gambrel kink,
* mansard/dutch waist)
*
* Exported so the roof-level builder can reuse it to terminate the valley
* diagonals it draws at merged-roof junctions against the segments' ridges.
*/
export function getRoofSegmentPlanLinework(node: RoofSegmentNode): {
ridges: PlanSeg[]
hips: PlanSeg[]
breaks: PlanSeg[]
slope: { tail: PlanPt; head: PlanPt } | null
} {
const hw = node.width / 2
const hd = node.depth / 2
const ridges: PlanSeg[] = []
const hips: PlanSeg[] = []
const breaks: PlanSeg[] = []
let slope: { tail: PlanPt; head: PlanPt } | null = null
// Eave corners, matching e1..e4 in the 3D `getModuleFaces` builder.
const e1: PlanPt = [-hw, hd]
const e2: PlanPt = [hw, hd]
const e3: PlanPt = [hw, -hd]
const e4: PlanPt = [-hw, -hd]
// Hip linework shared by `hip` and the collapsed-waist mansard/dutch
// fallbacks: ridge along the longer axis, four hips from the eave
// corners to the nearer ridge end — or a single peak when square.
const pushHip = () => {
if (Math.abs(node.width - node.depth) < 0.01) {
const peak: PlanPt = [0, 0]
hips.push([e1, peak], [e2, peak], [e3, peak], [e4, peak])
} else if (node.width >= node.depth) {
const r1: PlanPt = [-hw + hd, 0]
const r2: PlanPt = [hw - hd, 0]
ridges.push([r1, r2])
hips.push([e1, r1], [e4, r1], [e2, r2], [e3, r2])
} else {
const r1: PlanPt = [0, hd - hw]
const r2: PlanPt = [0, -hd + hw]
ridges.push([r1, r2])
hips.push([e1, r1], [e2, r1], [e3, r2], [e4, r2])
}
}
switch (node.roofType) {
case 'flat':
break
case 'gable':
// Single ridge down the middle along the width axis.
ridges.push([
[-hw, 0],
[hw, 0],
])
break
case 'shed':
// 3D builder slopes from the high eave (lz = -hd) down to lz = +hd.
slope = { tail: [0, -hd * 0.55], head: [0, hd * 0.55] }
break
case 'hip':
pushHip()
break
case 'gambrel': {
// Ridge + two kink lines parallel to it.
const mz = hd * node.gambrelLowerWidthRatio
ridges.push([
[-hw, 0],
[hw, 0],
])
breaks.push(
[
[-hw, mz],
[hw, mz],
],
[
[-hw, -mz],
[hw, -mz],
],
)
break
}
case 'mansard': {
// Inner waist rectangle + four corner hips from the eaves to it.
const i = Math.min(node.width, node.depth) * node.mansardSteepWidthRatio
if (hw - i > 0.02 && hd - i > 0.02) {
const w1: PlanPt = [-hw + i, hd - i]
const w2: PlanPt = [hw - i, hd - i]
const w3: PlanPt = [hw - i, -hd + i]
const w4: PlanPt = [-hw + i, -hd + i]
breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1])
hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4])
} else {
pushHip()
}
break
}
case 'dutch': {
const metrics = getDutchRoofMetrics(node)
if (!(metrics.waistHalfX > 0.02 && metrics.waistHalfZ > 0.02)) {
pushHip()
break
}
const w1: PlanPt = [-metrics.waistHalfX, metrics.waistHalfZ]
const w2: PlanPt = [metrics.waistHalfX, metrics.waistHalfZ]
const w3: PlanPt = [metrics.waistHalfX, -metrics.waistHalfZ]
const w4: PlanPt = [-metrics.waistHalfX, -metrics.waistHalfZ]
hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4])
breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1])
ridges.push([metrics.ridgeStart, metrics.ridgeEnd])
break
}
}
return { ridges, hips, breaks, slope }
}