/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * The Space Sketch SVG canvas — the presentational layer. Pure render: it draws * the grid, building underlay, rooms (at the chosen boundary), leak diagnostics, * and all the live interaction cues (hover, cut rubber-band, snap rings, draw * preview, delete telegraph), plus the action-intent chip. All interaction * logic lives in the overlay controller; this component only receives state and * forwards pointer events. */ import type { Room } from '@/lib/space-plate-session'; import { sX, sY, centroid, polyArea, uniqueVerts, type Fit, type Pt } from '@/lib/space-sketch-geometry'; import type { SnapKind } from '@/lib/space-snap'; import type { BoundaryMode } from '@ifc-lite/create'; import type { Hover, SplitTarget, Intent, IntentTone } from './types'; const EPS = 1e-6; const ROOM_COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#06b6d4', '#84cc16', '#a855f7', '#ef4444']; const INTENT_TEXT_CLASS: Record = { move: 'text-foreground', draw: 'text-emerald-600 dark:text-emerald-400', cut: 'text-blue-600 dark:text-blue-400', remove: 'text-red-600 dark:text-red-400', pan: 'text-muted-foreground', }; const INTENT_DOT_CLASS: Record = { move: 'bg-zinc-400', draw: 'bg-emerald-500', cut: 'bg-blue-500', remove: 'bg-red-500', pan: 'bg-zinc-400', }; interface GridLine { x1: number; y1: number; x2: number; y2: number } interface BoundaryInfo { disp: Pt[]; unbounded: boolean } interface Diagnostic { a: Pt; b: Pt; bounding: boolean } export interface SpaceSketchCanvasProps { svgRef: React.RefObject; width: number; height: number; cursor: string; fit: Fit; gridLines: GridLine[]; underlay: React.ReactNode; rooms: Room[]; boundaryInfo: BoundaryInfo[]; boundaryMode: BoundaryMode; mergeFaces: Set | null; diagnostics: Diagnostic[] | null; hover: Hover; splitPick: SplitTarget | null; previewEnd: Pt | null; splitHover: Pt | null; snapPos: Pt | null; snapKind: SnapKind; drawPts: Pt[]; drawCursor: Pt | null; alignGuides: { vRef: Pt | null; hRef: Pt | null }; deleteHover: Pt | null; intent: Intent | null; onPointerDown: (e: React.PointerEvent) => void; onPointerMove: (e: React.PointerEvent) => void; onPointerUp: (e: React.PointerEvent) => void; onDoubleClick: () => void; onContextMenu: (e: React.MouseEvent) => void; onPointerLeave: () => void; } export function SpaceSketchCanvas(props: SpaceSketchCanvasProps) { const { svgRef, width, height, cursor, fit: f, gridLines, underlay, rooms, boundaryInfo, boundaryMode, mergeFaces, diagnostics, hover, splitPick, previewEnd, splitHover, snapPos, snapKind, drawPts, drawCursor, alignGuides, deleteHover, intent, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, onContextMenu, onPointerLeave, } = props; return (
{/* Live action preview — tells you what the next click will do, colour-keyed to the on-canvas cues (green draw · blue cut · red remove/merge). */} {intent && (
{intent.text}
)} {gridLines.map((l, i) => )} {/* Building-element underlay (plan cut ~1.2 m above the storey) for orientation. */} {underlay} {rooms.map((r, ri) => { const color = ROOM_COLORS[ri % ROOM_COLORS.length]; const { disp, unbounded } = boundaryInfo[ri]; const pts = disp.map((p) => `${sX(f, p[0])},${sY(f, p[1])}`).join(' '); const [cwx, cwy] = centroid(disp); const cx = sX(f, cwx), cy = sY(f, cwy); const lit = mergeFaces?.has(r.face); const bad = !r.simple; const area = boundaryMode === 'center' ? r.area : polyArea(disp); return ( {boundaryMode !== 'center' && ( `${sX(f, p[0])},${sY(f, p[1])}`).join(' ')} fill="none" stroke={color} strokeOpacity={0.25} strokeDasharray="3 3" strokeWidth={1} /> )} {unbounded && Boundary “{boundaryMode}” made no change to this room — no wall offset applies (no wall runs along its edges, or it's fully internal in Outer mode).} {area.toFixed(2)} ); })} {/* Issue 7 — leak diagnostics: walls that bound a room (green) vs walls that bound nothing (red dashed = a stray segment / leak suspect). */} {diagnostics && diagnostics.map((s, i) => ( ))} {hover?.kind === 'edge' && ( )} {splitPick && previewEnd && ( )} {/* Cut/insert cue on a wall — the "+" handle that telegraphs "click to place a cut point here" (and previews the second cut point). */} {splitHover && ( )} {/* first committed split pick */} {splitPick && ( )} {snapPos && ( {snapKind === 'line' ? ( // On-wall snap — amber diamond. ) : ( // Corner/vertex snap — green ring. )} )} {/* First-corner preview: before any point is placed, show where the click will land + the snap cue, so the snap is visible up front. */} {drawPts.length === 0 && drawCursor && ( {snapKind === 'line' && ( )} {snapKind === 'vertex' && ( )} )} {/* Draw-room in progress (Issue 2): placed points, rubber band, close hint. */} {drawPts.length > 0 && ( {/* Alignment guides — the dashed lines that telegraph "this corner is lined up with that earlier corner" (e.g. under the first). */} {drawCursor && alignGuides.vRef && ( )} {drawCursor && alignGuides.hRef && ( )} {drawPts.length >= 3 && ( `${sX(f, p[0])},${sY(f, p[1])}`).join(' ')} fill="#22c55e" fillOpacity={0.1} stroke="none" /> )} `${sX(f, p[0])},${sY(f, p[1])}`).join(' ')} fill="none" stroke="#22c55e" strokeWidth={1.5} /> {drawCursor && ( )} {drawCursor && snapKind !== 'none' && ( snapKind === 'line' ? : )} {drawPts.map((p, i) => ( = 3 ? 6 : 3.5} fill={i === 0 && drawPts.length >= 3 ? '#22c55e' : '#fff'} stroke="#16a34a" strokeWidth={1.5} /> ))} )} {uniqueVerts(rooms).map((p, i) => { const isHover = hover?.kind === 'vertex' && Math.abs(hover.pos[0] - p[0]) < EPS && Math.abs(hover.pos[1] - p[1]) < EPS; return ( ); })} {/* ⌥/Ctrl-click delete telegraph (Issue 3): a red ring + minus over the node. */} {deleteHover && ( )}
); }