// Snapping (docs/design/drawing-tools.md §5). Läuft auf jedem Pointer-Move // BEVOR der Punkt an das Werkzeug geht: sammelt Snap-Kandidaten, wählt den // besten innerhalb der Bildschirm-Toleranz und liefert Marker-Info. // // Phase 2: endpoint · midpoint · intersection · onEdge (Lot) · grid · ortho. // Prioritäts-Reihenfolge (Vorrang bei nahem Abstand): endpoint > intersection > // midpoint > onEdge > grid; ortho/Winkelraster ist eine Projektion (überlagert). import type { Project, Vec2 } from "../model/types"; import { lineIntersect } from "../model/geometry"; import type { SnapKind, SnapResult, SnapSettings } from "./types"; const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y); const mid = (a: Vec2, b: Vec2): Vec2 => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }); /** Bildschirm-px-Bonus je Snap-Art: höher = gewinnt knappe Vergleiche. */ const PRIORITY: Record = { endpoint: 7, intersection: 5, midpoint: 3, center: 3, quadrant: 3, onEdge: 0, grid: -2, ortho: 0, extension: 0, }; export interface SnapInput { raw: Vec2; project: Project; levelId: string; visibleCodes: Set; settings: SnapSettings; draftPoints: Vec2[]; lastPoint: Vec2 | null; pxPerMeter: number; shift: boolean; ctrl: boolean; } /** Eine Strecke als Snap-Quelle (Wandachse oder 2D-Segment). */ type Seg = [Vec2, Vec2]; /** Sammelt alle sichtbaren Strecken des Geschosses (Wandachsen + 2D-Segmente). */ function collectSegments(input: SnapInput): Seg[] { const segs: Seg[] = []; for (const w of input.project.walls) { if (w.floorId !== input.levelId) continue; if (!input.visibleCodes.has(w.categoryCode)) continue; segs.push([w.start, w.end]); } for (const d of input.project.drawings2d) { if (d.levelId !== input.levelId) continue; if (!input.visibleCodes.has(d.categoryCode)) continue; const g = d.geom; if (g.shape === "line") segs.push([g.a, g.b]); else if (g.shape === "polyline") { for (let i = 0; i < g.pts.length - 1; i++) segs.push([g.pts[i], g.pts[i + 1]]); if (g.closed && g.pts.length > 2) segs.push([g.pts[g.pts.length - 1], g.pts[0]]); } else if (g.shape === "rect") { const c1 = g.min, c3 = g.max; const c2 = { x: g.max.x, y: g.min.y }, c4 = { x: g.min.x, y: g.max.y }; segs.push([c1, c2], [c2, c3], [c3, c4], [c4, c1]); } } // Bereits gesetzte Draft-Segmente (Polylinie/Wand im Bau). for (let i = 0; i < input.draftPoints.length - 1; i++) { segs.push([input.draftPoints[i], input.draftPoints[i + 1]]); } return segs; } /** Lotfußpunkt von p auf die Strecke a-b, auf das Segment geklemmt. */ function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 { const dx = b.x - a.x, dy = b.y - a.y; const len2 = dx * dx + dy * dy; if (len2 < 1e-12) return a; let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2; t = Math.max(0, Math.min(1, t)); return { x: a.x + dx * t, y: a.y + dy * t }; } /** * Bestimmt den effektiven Snap für den rohen Cursor-Punkt (oder null). * Punkt-Snaps haben Vorrang vor Ortho und Raster (Prioritäts-Bonus). */ export function computeSnap(input: SnapInput): SnapResult | null { const { raw, settings, pxPerMeter, shift, ctrl } = input; if (ctrl || !settings.enabled) return null; const tolM = settings.tolerancePx / Math.max(pxPerMeter, 1e-6); // Bester Kandidat nach (distPx − Prioritäts-Bonus). let best: SnapResult | null = null; let bestScore = Infinity; const consider = (point: Vec2, kind: SnapKind, refA?: Vec2) => { const d = dist(point, raw); if (d > tolM) return; const distPx = d * pxPerMeter; const score = distPx - PRIORITY[kind]; if (score < bestScore) { bestScore = score; best = { point, kind, distPx, refA }; } }; const segs = settings.midpoint || settings.intersection || settings.onEdge || settings.endpoint ? collectSegments(input) : []; // endpoint (Strecken-Endpunkte + Draft-Knoten). if (settings.endpoint) { for (const [a, b] of segs) { consider(a, "endpoint"); consider(b, "endpoint"); } for (const p of input.draftPoints) consider(p, "endpoint"); } // midpoint. if (settings.midpoint) { for (const [a, b] of segs) consider(mid(a, b), "midpoint"); } // intersection (paarweise Geraden-Schnitt der Strecken, nahe am Cursor). if (settings.intersection) { for (let i = 0; i < segs.length; i++) { for (let j = i + 1; j < segs.length; j++) { const [a, b] = segs[i]; const [c, d] = segs[j]; const x = lineIntersect(a, { x: b.x - a.x, y: b.y - a.y }, c, { x: d.x - c.x, y: d.y - c.y, }); if (x) consider(x, "intersection"); } } } // onEdge (Lotfußpunkt auf nahe Strecken; niedrige Priorität). if (settings.onEdge) { for (const [a, b] of segs) consider(perpFoot(raw, a, b), "onEdge"); } if (best) return best; // Ortho / Winkelraster (Projektion relativ zum letzten Punkt). if ((settings.ortho || shift) && input.lastPoint) { const point = applyAngleConstraint(input.lastPoint, raw, settings.angleStep); return { point, kind: "ortho", refA: input.lastPoint, distPx: 0 }; } // Raster. if (settings.grid) { const g = snapToGrid(raw, settings.gridSize); if (dist(g, raw) <= tolM) { return { point: g, kind: "grid", distPx: dist(g, raw) * pxPerMeter }; } } return null; } /** Projiziert `to` auf die nächste erlaubte Richtung vom Punkt `from`. */ export function applyAngleConstraint(from: Vec2, to: Vec2, stepDeg: number): Vec2 { const dx = to.x - from.x; const dy = to.y - from.y; const ang = Math.atan2(dy, dx); const step = (stepDeg * Math.PI) / 180; const k = Math.round(ang / step) * step; const len = Math.hypot(dx, dy); return { x: from.x + Math.cos(k) * len, y: from.y + Math.sin(k) * len }; } /** Rundet einen Punkt auf das Raster (Meter). */ function snapToGrid(p: Vec2, size: number): Vec2 { if (!(size > 0)) return p; return { x: Math.round(p.x / size) * size, y: Math.round(p.y / size) * size }; }