308 lines
11 KiB
TypeScript
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 }
|
|
}
|