diff --git a/packages/nodes/src/slab/floorplan.ts b/packages/nodes/src/slab/floorplan.ts index ad0afdfd..93e0a971 100644 --- a/packages/nodes/src/slab/floorplan.ts +++ b/packages/nodes/src/slab/floorplan.ts @@ -1,38 +1,49 @@ import { type FloorplanGeometry, type FloorplanPoint, + type GeometryContext, getRenderableSlabPolygon, type SlabNode, } from '@pascal-app/core' /** * Stage C floor-plan builder for slab. Renders the slab polygon as a - * filled path with holes cut out. + * filled path with holes cut out; when selected, overlays themed + * chrome (accent stroke, hatch fill) plus the full boundary editor: * - * Uses `getRenderableSlabPolygon` (the same helper the legacy - * floorplan-panel.tsx uses) to compute the visual polygon — accounts - * for wall-clipping when a slab is auto-generated from walls. + * - Vertex handles on every polygon corner (orange dots). + * - Midpoint `+` handles between vertices to insert a new vertex. + * - Edge handles along each edge so the user can drag the whole + * edge perpendicular. + * - Same three handle sets for every hole in `node.holes`, with the + * `holeIndex` carried in each handle's payload. + * + * Uses `getRenderableSlabPolygon` for the visible fill (auto-slabs + * generated from walls clip to wall footprints), but vertex / edge / + * midpoint handles live on the **raw** `node.polygon` — matches the + * legacy slab boundary editor which always operates on raw data. */ -export function buildSlabFloorplan(node: SlabNode): FloorplanGeometry | null { +export function buildSlabFloorplan(node: SlabNode, ctx: GeometryContext): FloorplanGeometry | null { const polygon = node.polygon if (!polygon || polygon.length < 3) return null const visualPolygon = getRenderableSlabPolygon(node) if (!visualPolygon || visualPolygon.length < 3) return null + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const showSelectedChrome = isSelected || isHighlighted + const outer: FloorplanPoint[] = visualPolygon.map(([x, z]) => [x, z] as FloorplanPoint) - // SVG path with outer ring + hole subpaths. Each subpath uses M/L - // commands + Z to close. Holes follow the outer ring; FloorplanGeometry - // 'path' kind supports this natively (renderer passes the `d` string - // straight to the SVG ). - const segments: string[] = [] const ring = (points: FloorplanPoint[]) => { const [first, ...rest] = points if (!first) return '' return [`M ${first[0]} ${first[1]}`, ...rest.map(([x, y]) => `L ${x} ${y}`), 'Z'].join(' ') } - segments.push(ring(outer)) + const segments: string[] = [ring(outer)] const holes = node.holes ?? [] for (const hole of holes) { @@ -41,12 +52,90 @@ export function buildSlabFloorplan(node: SlabNode): FloorplanGeometry | null { segments.push(ring(holePts)) } - return { - kind: 'path', - d: segments.join(' '), - fill: '#cbd5e1', - stroke: '#475569', - strokeWidth: 0.03, - opacity: 0.85, + const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#475569' + const fill = showSelectedChrome ? '#ffffff' : '#cbd5e1' + + // Slab body. Uses `fillOpacity` / `strokeOpacity` independently so the + // outline stays crisp while the fill stays translucent — zones under + // the slab read through, and on the selected state the hatch overlay + // (`{ kind: 'hatch' }` below) carries the visual weight without the + // background going opaque-white. + const children: FloorplanGeometry[] = [ + { + kind: 'path', + d: segments.join(' '), + fill, + fillOpacity: showSelectedChrome ? 0.45 : 0.6, + stroke, + strokeWidth: showSelectedChrome ? 0.04 : 0.03, + strokeOpacity: showSelectedChrome ? 0.96 : 0.85, + }, + ] + + // Hatch overlay on selected — same `` pattern as the wall. + if (isSelected && palette) { + children.push({ + kind: 'hatch', + points: outer, + color: palette.selectedHatch, + opacity: 0.7, + }) + } + + // Boundary editor — visible only when the slab is the active selection. + if (isSelected) { + appendRingEditor(children, polygon, undefined) + holes.forEach((hole, holeIndex) => { + if (hole.length >= 3) appendRingEditor(children, hole, holeIndex) + }) + } + + return { kind: 'group', children } +} + +/** + * Push vertex / midpoint / edge handles for a single ring (boundary or + * hole). `holeIndex === undefined` targets `node.polygon`; otherwise + * `node.holes[holeIndex]`. Emits in this order so the hit-test + * priority is sensible: edges first (largest hit area, lowest z), + * then midpoints, then vertices on top. + */ +function appendRingEditor( + children: FloorplanGeometry[], + ring: ReadonlyArray, + holeIndex: number | undefined, +): void { + for (let i = 0; i < ring.length; i++) { + const a = ring[i]! + const b = ring[(i + 1) % ring.length]! + children.push({ + kind: 'edge-handle', + x1: a[0], + y1: a[1], + x2: b[0], + y2: b[1], + affordance: 'move-edge', + payload: { holeIndex, edgeIndex: i }, + }) + } + for (let i = 0; i < ring.length; i++) { + const a = ring[i]! + const b = ring[(i + 1) % ring.length]! + children.push({ + kind: 'midpoint-handle', + point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], + affordance: 'add-vertex', + payload: { holeIndex, edgeIndex: i }, + }) + } + for (let i = 0; i < ring.length; i++) { + const [x, z] = ring[i]! + children.push({ + kind: 'endpoint-handle', + point: [x, z], + state: 'idle', + affordance: 'move-vertex', + payload: { holeIndex, vertexIndex: i }, + }) } }