import { type FloorplanGeometry, type GeometryContext, getFenceCenterlineFrameAt, getFenceCenterlineLength, getFenceControlHandle, getWallMidpointHandlePoint, isCurvedWall, isSplineFence, sampleFenceCenterline, } from '@pascal-app/core' import type { FenceNode } from './schema' /** * Stage C floor-plan builder for fence. 1:1 visual port of the legacy * `FloorplanFenceLayer` from `floorplan-panel.tsx`: * * 1. Three stacked stroke paths along the centerline, all with * `vectorEffect: 'non-scaling-stroke'` so widths stay constant on * screen at any zoom: * a. Optional glow (semi-transparent, only when active/hovered). * b. White underlay — the visual "fence body" base layer. * c. Dark accent — the actual fence outline. * 2. Style-aware markers at computed positions along the centerline: * - `privacy` / `horizontal`: rotated rectangle (solid panel). * - `rail`: concentric circle stack (post + ring + tiny center). * - default `slat`: white X mark with a coloured X on top. * 3. Markers thinned when `showInfill === false` — only first + last * remain (matches legacy "endpoints only" mode). * 4. Selection chrome: dots on the endpoints + centered length label * (same shape as the wall builder). * * `getFloorplanFenceMarkerTs` is inlined here — it was a private helper * in the legacy panel and is fence-specific, so it lives with the kind. */ // The tangent handle arm is drawn this many times longer than the raw curve // handle vector so it's easy to grab on screen even at the default (small) // tangent. The `move-tangent` affordance divides this factor back out so the // stored tangent matches the visual arm length. Must stay in sync with the // 3D tool's arm scale. const TANGENT_HANDLE_ARM_SCALE = 3 function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)) } function getFloorplanFenceLength(fence: FenceNode): number { if (isSplineFence(fence) || isCurvedWall(fence)) { return getFenceCenterlineLength(fence) } return Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]) } /** * Distribute markers along the fence centerline. Spacing depends on * `postSpacing` (tighter for privacy style); `inset` keeps the first / * last marker away from the endpoints. Returns a list of t-values in * [0, 1] suitable for `getWallCurveFrameAt`. */ function getFloorplanFenceMarkerTs(fence: FenceNode): number[] { const length = getFloorplanFenceLength(fence) if (length <= 0.24) return [0.5] const spacing = clamp( fence.style === 'privacy' ? fence.postSpacing * 0.72 : fence.postSpacing, 0.34, 1.5, ) const inset = clamp( Math.max(fence.postSize * 1.25, fence.edgeInset * 10), 0.18, Math.min(0.48, length * 0.22), ) const usableLength = Math.max(length - inset * 2, 0) if (usableLength <= 0.001) return [0.5] const markerCount = Math.max(1, Math.min(24, Math.floor(usableLength / spacing) + 1)) if (markerCount === 1) return [0.5] return Array.from({ length: markerCount }, (_, index) => clamp((inset + (usableLength * index) / (markerCount - 1)) / length, 0.08, 0.92), ) } function buildCenterlinePathD(points: ReadonlyArray<{ x: number; y: number }>): string { if (points.length < 2) return '' const first = points[0]! return [`M ${first.x} ${first.y}`, ...points.slice(1).map((p) => `L ${p.x} ${p.y}`)].join(' ') } function buildMarker( fence: FenceNode, point: { x: number; y: number }, angleRadians: number, accentColor: string, surfaceColor: string, isActive: boolean, ): FloorplanGeometry { const markerStrokeWidth = isActive ? 1.65 : 1.35 if (fence.style === 'privacy' || fence.style === 'horizontal') { const w = clamp(fence.postSize * 0.58, 0.038, 0.068) const h = clamp(Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4), 0.1, 0.17) // Surface plate underneath + accent rectangle on top — gives a clean // "punched out of the underlay stroke" look at all zooms. return { kind: 'group', transform: { translate: [point.x, point.y], rotate: angleRadians }, children: [ { kind: 'rect', x: -(w + 0.032) / 2, y: -(h + 0.038) / 2, width: w + 0.032, height: h + 0.038, rx: 0.014, ry: 0.014, fill: surfaceColor, }, { kind: 'rect', x: -w / 2, y: -h / 2, width: w, height: h, rx: 0.01, ry: 0.01, fill: accentColor, }, ], } } if (fence.style === 'rail') { const r = clamp(fence.postSize * 0.52, 0.048, 0.078) return { kind: 'group', transform: { translate: [point.x, point.y] }, children: [ { kind: 'circle', cx: 0, cy: 0, r: r + 0.018, fill: surfaceColor }, { kind: 'circle', cx: 0, cy: 0, r, fill: surfaceColor, stroke: accentColor, strokeWidth: markerStrokeWidth, vectorEffect: 'non-scaling-stroke', }, { kind: 'circle', cx: 0, cy: 0, r: r * 0.34, fill: accentColor, fillOpacity: isActive ? 0.24 : 0.18, vectorEffect: 'non-scaling-stroke', }, ], } } // Default — slat X mark. const half = clamp(fence.postSize * 0.42, 0.03, 0.055) return { kind: 'group', transform: { translate: [point.x, point.y], rotate: angleRadians }, children: [ // White underlay so the X shows against any background. { kind: 'line', x1: -half, y1: -half, x2: half, y2: half, stroke: surfaceColor, strokeWidth: 2.8, strokeLinecap: 'round', vectorEffect: 'non-scaling-stroke', }, { kind: 'line', x1: half, y1: -half, x2: -half, y2: half, stroke: surfaceColor, strokeWidth: 2.8, strokeLinecap: 'round', vectorEffect: 'non-scaling-stroke', }, // Accent X on top. { kind: 'line', x1: -half, y1: -half, x2: half, y2: half, stroke: accentColor, strokeWidth: markerStrokeWidth, strokeLinecap: 'round', vectorEffect: 'non-scaling-stroke', }, { kind: 'line', x1: half, y1: -half, x2: -half, y2: half, stroke: accentColor, strokeWidth: markerStrokeWidth, strokeLinecap: 'round', vectorEffect: 'non-scaling-stroke', }, ], } } export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): FloorplanGeometry { const view = ctx.viewState const palette = view?.palette const isSelected = view?.selected ?? false const isHighlighted = view?.highlighted ?? false const isHovered = view?.hovered ?? false const isActive = isSelected || isHighlighted const showInteractiveChrome = isActive || isHovered // Centerline path — sampled for spline / arc fences so the underlay / // accent / glow all trace the same shape. const centerlinePoints = isSplineFence(node) || isCurvedWall(node) ? sampleFenceCenterline(node, 24) : [ { x: node.start[0], y: node.start[1] }, { x: node.end[0], y: node.end[1] }, ] const pathD = buildCenterlinePathD(centerlinePoints) // Stroke shifts: selected wins; hover (not selected) → `wallHoverStroke` // (light blue from the legacy palette, same as walls); otherwise dark // accent. Mirrors the `fenceStroke` ternary in the legacy panel. const accentStroke = isActive && palette ? palette.selectedStroke : isHovered && palette ? palette.wallHoverStroke : '#111827' const glowStroke = isActive && palette ? palette.selectedStroke : isHovered && palette ? palette.wallHoverStroke : accentStroke const underlayStroke = 'rgba(255, 255, 255, 0.98)' // Surface (white) for marker plates — themed dark surface would look // wrong on the white underlay, so we hardcode white here. const markerSurface = '#ffffff' // Widths step up on hover (between idle and active) — same pattern the // legacy panel uses for `fenceUnderlayWidth` / `fenceStrokeWidth`. const underlayWidth = isActive ? 6.5 : isHovered ? 6 : 5.2 const accentWidth = isActive ? 2.6 : isHovered ? 2.35 : 2.05 // Glow only appears when active or hovered. Opacity gradient matches // the legacy (0.22 active / 0.14 hover / 0 idle). const glowOpacity = isActive ? 0.22 : isHovered ? 0.14 : 0 // Marker frames. Filter to first+last when infill is off so the user // still sees end posts (matches legacy). const markerTs = getFloorplanFenceMarkerTs(node) const markerFrames = markerTs.map((t) => { const frame = getFenceCenterlineFrameAt(node, t) return { point: frame.point, angle: Math.atan2(frame.tangent.y, frame.tangent.x), } }) const visibleMarkers = (node.showInfill ?? true) ? markerFrames : markerFrames.filter((_, i) => i === 0 || i === markerFrames.length - 1) const children: FloorplanGeometry[] = [] // 1. Glow (only when active / highlighted / hovered). Wide, // low-opacity ring. Width steps with the interaction level so hover // is subtler than active. if (glowOpacity > 0) { children.push({ kind: 'path', d: pathD, fill: 'none', stroke: glowStroke, strokeWidth: isActive ? 9.5 : isHovered ? 8.8 : 8.2, strokeOpacity: glowOpacity, strokeLinecap: 'round', strokeLinejoin: 'round', vectorEffect: 'non-scaling-stroke', }) } // 2. White underlay — visible fence body base layer. children.push({ kind: 'path', d: pathD, fill: 'none', stroke: underlayStroke, strokeOpacity: 0.98, strokeWidth: underlayWidth, strokeLinecap: 'round', strokeLinejoin: 'round', vectorEffect: 'non-scaling-stroke', }) // 3. Dark accent on top. children.push({ kind: 'path', d: pathD, fill: 'none', stroke: accentStroke, strokeWidth: accentWidth, strokeLinecap: 'round', strokeLinejoin: 'round', vectorEffect: 'non-scaling-stroke', }) // 4. Style-aware markers. Pass `showInteractiveChrome` so hover also // bumps marker stroke widths slightly (legacy panel does the same). for (const marker of visibleMarkers) { children.push( buildMarker( node, marker.point, marker.angle, accentStroke, markerSurface, showInteractiveChrome, ), ) } // 5. Hit-line(s) for click detection. A straight/arc fence uses a single // chord-spanning line; a spline fence emits one short hit-line per sampled // span so the clickable region follows the curve instead of cutting the // chord (there's no curved `hit-path` primitive yet). if (isSplineFence(node)) { for (let i = 1; i < centerlinePoints.length; i += 1) { const a = centerlinePoints[i - 1]! const b = centerlinePoints[i]! children.push({ kind: 'hit-line', x1: a.x, y1: a.y, x2: b.x, y2: b.y, strokeWidthPx: 18, cursor: 'pointer', }) } } else { children.push({ kind: 'hit-line', x1: node.start[0], y1: node.start[1], x2: node.end[0], y2: node.end[1], strokeWidthPx: 18, cursor: 'pointer', }) } // 6. Endpoint handles + side move-arrows + curve handle + length when // selected. Mirrors the wall builder so fences gain the same set of // in-plan affordances (drag endpoints, drag body via either side // arrow, drag the midpoint sagitta to curve). if (isSelected) { if (isSplineFence(node) && node.path) { // Spline fence: per control point draw (a) the symmetric tangent line // through the point with a small handle dot on each end — dragging an // end bends the curve on both sides via `move-tangent` — and (b) the // larger control-point dot itself, which moves the point. for (let i = 0; i < node.path.length; i += 1) { const point = node.path[i]! const handle = getFenceControlHandle(node.path, node.tangents, i) // Scale the on-screen handle arm so even the default (small) tangent // is grabbable; the affordance divides this back out on apply. const armX = handle.x * TANGENT_HANDLE_ARM_SCALE const armY = handle.y * TANGENT_HANDLE_ARM_SCALE const out: [number, number] = [point[0] + armX, point[1] + armY] const inn: [number, number] = [point[0] - armX, point[1] - armY] // Connecting line (the "tangent" through the point). Violet to match // the 3D tangent line + the handle dots. children.push({ kind: 'line', x1: inn[0], y1: inn[1], x2: out[0], y2: out[1], stroke: '#8381ed', strokeWidth: 1.25, strokeOpacity: 0.85, vectorEffect: 'non-scaling-stroke', }) // Handle dot on each end. Both drive the same `move-tangent` // affordance; `side` tells it which end is being dragged so the // stored OUT vector gets the correct sign. children.push({ kind: 'endpoint-handle', point: out, state: 'idle', variant: 'curve', affordance: 'move-tangent', payload: { fenceId: node.id, index: i, side: 'out' as const }, }) children.push({ kind: 'endpoint-handle', point: inn, state: 'idle', variant: 'curve', affordance: 'move-tangent', payload: { fenceId: node.id, index: i, side: 'in' as const }, }) // The control-point dot last so it sits on top of the tangent line. children.push({ kind: 'endpoint-handle', point: [point[0], point[1]], state: 'idle', affordance: 'move-control-point', payload: { fenceId: node.id, index: i }, }) } } else { children.push({ kind: 'endpoint-handle', point: [node.start[0], node.start[1]], state: 'idle', affordance: 'move-endpoint', payload: { fenceId: node.id, endpoint: 'start' as const }, }) children.push({ kind: 'endpoint-handle', point: [node.end[0], node.end[1]], state: 'idle', affordance: 'move-endpoint', payload: { fenceId: node.id, endpoint: 'end' as const }, }) } // Two perpendicular `move-arrow` chevrons at the fence midpoint. // No `affordance` → the registry layer routes pointer-down through // `setMovingNode`, which the `FloorplanRegistryMoveOverlay` picks // up and runs through `def.floorplanMoveTarget` (see // `fence/floorplan-move.ts`). Sized in plan-units like the wall // counterpart so they shrink / grow with zoom. { const dx = node.end[0] - node.start[0] const dz = node.end[1] - node.start[1] const lineLength = Math.hypot(dx, dz) if (lineLength > 1e-6) { const frame = isSplineFence(node) || isCurvedWall(node) ? getFenceCenterlineFrameAt(node, 0.5) : null const midX = frame ? frame.point.x : (node.start[0] + node.end[0]) / 2 const midZ = frame ? frame.point.y : (node.start[1] + node.end[1]) / 2 const nx = frame ? frame.normal.x : -dz / lineLength const nz = frame ? frame.normal.y : dx / lineLength const offset = (node.thickness ?? 0.08) / 2 + 0.05 children.push({ kind: 'move-arrow', point: [midX + nx * offset, midZ + nz * offset], angle: Math.atan2(nz, nx), }) children.push({ kind: 'move-arrow', point: [midX - nx * offset, midZ - nz * offset], angle: Math.atan2(-nz, -nx), }) } } // Curve sagitta handle — teal dot at the visual midpoint that drives // `curveOffset`. Routes through `fenceCurveAffordance`. Fences host // no children, so there's no equivalent of wall's curve-blocking // check to gate this. Suppressed for spline fences: the single sagitta // is meaningless against a multi-point curve. if (!isSplineFence(node)) { const curveHandle = getWallMidpointHandlePoint(node) children.push({ kind: 'endpoint-handle', point: [curveHandle.x, curveHandle.y], state: 'idle', variant: 'curve', affordance: 'curve', payload: { fenceId: node.id }, }) } const length = getFloorplanFenceLength(node) if (length >= 0.1) { const labelFrame = isSplineFence(node) || isCurvedWall(node) ? getFenceCenterlineFrameAt(node, 0.5) : null const midX = labelFrame ? labelFrame.point.x : (node.start[0] + node.end[0]) / 2 const midZ = labelFrame ? labelFrame.point.y : (node.start[1] + node.end[1]) / 2 const angle = labelFrame ? Math.atan2(labelFrame.tangent.y, labelFrame.tangent.x) : Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) children.push({ kind: 'dimension-label', cx: midX, cy: midZ, text: `${Number.parseFloat(length.toFixed(2))}m`, angle, }) } } return { kind: 'group', children } }