133 lines
4.6 KiB
TypeScript
133 lines
4.6 KiB
TypeScript
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
|
|
import { INCHES_TO_METERS } from './geometry'
|
|
import type { DuctSegmentNode } from './schema'
|
|
|
|
const SUPPLY_CENTERLINE = '#d4825a'
|
|
const RETURN_CENTERLINE = '#5a8ad4'
|
|
const BODY_COLOR = '#9ca3af'
|
|
/** Move-arrow stand-off past the duct body, in plan meters. */
|
|
const SIDE_ARROW_GAP = 0.27
|
|
/** Below this plan length a segment / end has no usable direction. */
|
|
const MIN_SEGMENT_LEN = 0.05
|
|
|
|
/**
|
|
* Floor-plan representation of a duct run: the path drawn at the duct's
|
|
* real width (plan-unit stroke so it scales with zoom), with a dashed
|
|
* centerline tinted by system — orange for supply, blue for return, the
|
|
* same hues the 3D tint uses. Vertical risers collapse to a point in
|
|
* plan; consecutive duplicate plan points are dropped so they don't
|
|
* render zero-length artifacts.
|
|
*/
|
|
export function buildDuctSegmentFloorplan(
|
|
node: DuctSegmentNode,
|
|
ctx: GeometryContext,
|
|
): FloorplanGeometry | null {
|
|
if (node.path.length < 2) return null
|
|
|
|
// Project to plan, dropping consecutive duplicates (risers). `indexMap[k]`
|
|
// is the original path index plan point k came from, so the drag handle
|
|
// edits the right vertex.
|
|
const points: FloorplanPoint[] = []
|
|
const indexMap: number[] = []
|
|
for (let i = 0; i < node.path.length; i++) {
|
|
const [x, , z] = node.path[i]!
|
|
const prev = points[points.length - 1]
|
|
if (prev && Math.abs(prev[0] - x) < 1e-6 && Math.abs(prev[1] - z) < 1e-6) continue
|
|
points.push([x, z])
|
|
indexMap.push(i)
|
|
}
|
|
|
|
// Plan width: rect / oval runs draw at their actual width; round at diameter.
|
|
const diameterM = (node.shape === 'round' ? node.diameter : node.width) * INCHES_TO_METERS
|
|
const view = ctx.viewState
|
|
const palette = view?.palette
|
|
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
|
|
const centerline = node.system === 'supply' ? SUPPLY_CENTERLINE : RETURN_CENTERLINE
|
|
|
|
// A pure riser (single plan point) still gets a marker: a circle at
|
|
// the duct's diameter so the vertical run is visible in plan.
|
|
if (points.length < 2) {
|
|
const p = points[0] ?? [node.path[0]![0], node.path[0]![2]]
|
|
return {
|
|
kind: 'group',
|
|
children: [
|
|
{
|
|
kind: 'circle',
|
|
cx: p[0],
|
|
cy: p[1],
|
|
r: diameterM / 2,
|
|
fill: BODY_COLOR,
|
|
stroke: showSelectedChrome && palette ? palette.selectedStroke : centerline,
|
|
strokeWidth: 0.02,
|
|
opacity: 0.9,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
const children: FloorplanGeometry[] = [
|
|
{
|
|
kind: 'polyline',
|
|
points,
|
|
stroke: showSelectedChrome && palette ? palette.selectedStroke : BODY_COLOR,
|
|
strokeWidth: diameterM,
|
|
strokeLinecap: 'round',
|
|
strokeLinejoin: 'round',
|
|
opacity: showSelectedChrome ? 0.95 : 0.8,
|
|
},
|
|
{
|
|
kind: 'polyline',
|
|
points,
|
|
stroke: centerline,
|
|
strokeWidth: 1.5,
|
|
vectorEffect: 'non-scaling-stroke',
|
|
strokeDasharray: '5 4',
|
|
strokeLinecap: 'round',
|
|
strokeLinejoin: 'round',
|
|
opacity: 0.9,
|
|
},
|
|
]
|
|
|
|
// Selection chrome: one draggable handle per path vertex (2D twin of the
|
|
// 3D selection handles). Routes to the shared `move-path-point` affordance.
|
|
if (view?.selected) {
|
|
for (let k = 0; k < points.length; k++) {
|
|
children.push({
|
|
kind: 'endpoint-handle',
|
|
point: points[k]!,
|
|
state: 'idle',
|
|
affordance: 'move-path-point',
|
|
payload: { pointIndex: indexMap[k]! },
|
|
})
|
|
}
|
|
|
|
// Side-move arrows: a front / back pair at each segment midpoint, sliding
|
|
// that segment perpendicular to itself. 2D twin of the 3D side-move
|
|
// arrows. The arrows stand one duct-radius + gap off the body; `angle`
|
|
// points each chevron outward along the segment normal.
|
|
const offset = diameterM / 2 + SIDE_ARROW_GAP
|
|
for (let k = 0; k < points.length - 1; k++) {
|
|
const a = points[k]!
|
|
const b = points[k + 1]!
|
|
const dx = b[0] - a[0]
|
|
const dz = b[1] - a[1]
|
|
const len = Math.hypot(dx, dz)
|
|
if (len < MIN_SEGMENT_LEN) continue
|
|
const normal: [number, number] = [-dz / len, dx / len]
|
|
const mid: FloorplanPoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
|
|
for (const side of [1, -1] as const) {
|
|
const n: [number, number] = [normal[0] * side, normal[1] * side]
|
|
children.push({
|
|
kind: 'move-arrow',
|
|
point: [mid[0] + n[0] * offset, mid[1] + n[1] * offset],
|
|
angle: Math.atan2(n[1], n[0]),
|
|
affordance: 'move-segment',
|
|
payload: { segmentIndex: indexMap[k]!, normal: n },
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return { kind: 'group', children }
|
|
}
|