import type { CADElement } from '../types/cad.types'; import type { SnapPoint } from './RenderEngine'; export type SnapMode = | 'endpoint' | 'midpoint' | 'center' | 'intersection' | 'nearest' | 'perpendicular' | 'tangent' | 'quadrant' | 'grid' | 'none'; export interface SnapConfig { enabled: boolean; modes: Set; tolerance: number; // world units gridSpacing: number; polarEnabled: boolean; polarAngles: number[]; // angles in degrees for polar tracking polarTolerance: number; // angular tolerance in degrees } export interface SnapResult { point: SnapPoint | null; preview: SnapPoint[]; // nearby candidates for visual feedback } export class SnapEngine { private config: SnapConfig; private elements: CADElement[] = []; constructor(config?: Partial) { this.config = { enabled: true, modes: new Set(['endpoint', 'midpoint', 'center', 'intersection', 'nearest']), tolerance: 10, gridSpacing: 20, polarEnabled: false, polarAngles: [0, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330], polarTolerance: 5, ...config, }; } setElements(elements: CADElement[]): void { this.elements = elements; } setConfig(config: Partial): void { this.config = { ...this.config, ...config }; } getConfig(): SnapConfig { return { ...this.config, modes: new Set(this.config.modes) }; } toggleMode(mode: SnapMode): void { if (this.config.modes.has(mode)) { this.config.modes.delete(mode); } else { this.config.modes.add(mode); } } /** * Find the best snap point near the given world coordinates. * Returns null if no snap point is within tolerance. * If refPoint is provided and polar tracking is enabled, snaps to polar angles. */ snap(worldX: number, worldY: number, refPoint?: { x: number; y: number }): SnapResult { if (!this.config.enabled || this.config.modes.size === 0) { return { point: null, preview: [] }; } // Polar tracking: if we have a reference point, check polar angles first if (this.config.polarEnabled && refPoint) { const polarResult = this.polarSnap(worldX, worldY, refPoint); if (polarResult) { return { point: polarResult, preview: [polarResult] }; } } const candidates: SnapPoint[] = []; const tol = this.config.tolerance; // Grid snap (lowest priority) if (this.config.modes.has('grid')) { const gs = this.config.gridSpacing; const gx = Math.round(worldX / gs) * gs; const gy = Math.round(worldY / gs) * gs; const dist = Math.sqrt((worldX - gx) ** 2 + (worldY - gy) ** 2); if (dist < tol) { candidates.push({ x: gx, y: gy, type: 'grid' }); } } // Element-based snaps for (const el of this.elements) { if (this.config.modes.has('endpoint')) { this.collectEndpoints(el, worldX, worldY, tol, candidates); } if (this.config.modes.has('midpoint')) { this.collectMidpoints(el, worldX, worldY, tol, candidates); } if (this.config.modes.has('center')) { this.collectCenters(el, worldX, worldY, tol, candidates); } if (this.config.modes.has('nearest')) { this.collectNearest(el, worldX, worldY, tol, candidates); } // ── Task D11: erweiterte Snaps ── if (this.config.modes.has('quadrant')) { this.collectQuadrant(el, worldX, worldY, tol, candidates); } if (this.config.modes.has('perpendicular') && refPoint) { this.collectPerpendicular(el, worldX, worldY, refPoint, tol, candidates); } if (this.config.modes.has('tangent') && refPoint) { this.collectTangent(el, worldX, worldY, refPoint, tol, candidates); } } // Intersection snap (between pairs) if (this.config.modes.has('intersection')) { this.collectIntersections(worldX, worldY, tol, candidates); } if (candidates.length === 0) { return { point: null, preview: [] }; } // Sort by distance, pick closest candidates.sort((a, b) => { const da = (a.x - worldX) ** 2 + (a.y - worldY) ** 2; const db = (b.x - worldX) ** 2 + (b.y - worldY) ** 2; return da - db; }); // Priority: endpoint > intersection > center > midpoint > nearest > grid const priority: Record = { endpoint: 0, intersection: 1, center: 2, quadrant: 3, perpendicular: 4, tangent: 5, midpoint: 6, nearest: 7, grid: 8, }; // Find best within tolerance — prefer higher priority if distances are close const best = candidates[0]; const closeOnes = candidates.filter(c => { const d = Math.sqrt((c.x - worldX) ** 2 + (c.y - worldY) ** 2); return d < tol * 1.5; }); closeOnes.sort((a, b) => { const pa = priority[a.type] ?? 99; const pb = priority[b.type] ?? 99; if (pa !== pb) return pa - pb; const da = (a.x - worldX) ** 2 + (a.y - worldY) ** 2; const db = (b.x - worldX) ** 2 + (b.y - worldY) ** 2; return da - db; }); return { point: closeOnes[0] || best, preview: candidates.slice(0, 10), }; } private collectEndpoints(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void { const p = el.properties; const check = (x: number, y: number) => { const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2); if (d < tol) out.push({ x, y, type: 'endpoint' }); }; switch (el.type) { case 'line': check(p.x1 ?? el.x, p.y1 ?? el.y); check(p.x2 ?? el.x + el.width, p.y2 ?? el.y + el.height); break; case 'polyline': case 'polygon': { const pts = p.points || []; for (const pt of pts) check(pt.x, pt.y); break; } case 'rect': check(el.x - el.width / 2, el.y - el.height / 2); check(el.x + el.width / 2, el.y - el.height / 2); check(el.x - el.width / 2, el.y + el.height / 2); check(el.x + el.width / 2, el.y + el.height / 2); break; case 'arc': { const r = p.radius || el.width / 2; const sa = (p.startAngle || 0) * Math.PI / 180; const ea = (p.endAngle || 360) * Math.PI / 180; check(el.x + r * Math.cos(sa), el.y + r * Math.sin(sa)); check(el.x + r * Math.cos(ea), el.y + r * Math.sin(ea)); break; } } } private collectMidpoints(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void { const p = el.properties; const check = (x: number, y: number) => { const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2); if (d < tol) out.push({ x, y, type: 'midpoint' }); }; switch (el.type) { case 'line': { const x1 = p.x1 ?? el.x, y1 = p.y1 ?? el.y; const x2 = p.x2 ?? el.x + el.width, y2 = p.y2 ?? el.y + el.height; check((x1 + x2) / 2, (y1 + y2) / 2); break; } case 'polyline': case 'polygon': { const pts = p.points || []; for (let i = 0; i < pts.length - 1; i++) { check((pts[i].x + pts[i + 1].x) / 2, (pts[i].y + pts[i + 1].y) / 2); } if (el.type === 'polygon' && pts.length > 2) { check((pts[pts.length - 1].x + pts[0].x) / 2, (pts[pts.length - 1].y + pts[0].y) / 2); } break; } case 'rect': check(el.x, el.y - el.height / 2); check(el.x, el.y + el.height / 2); check(el.x - el.width / 2, el.y); check(el.x + el.width / 2, el.y); break; } } private collectCenters(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void { const check = (x: number, y: number) => { const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2); if (d < tol) out.push({ x, y, type: 'center' }); }; switch (el.type) { case 'circle': case 'arc': check(el.x, el.y); break; case 'rect': check(el.x, el.y); break; } } /** Task D11: Quadranten-Punkte bei Kreis/Bogen (0/90/180/270). */ private collectQuadrant(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void { const check = (x: number, y: number) => { const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2); if (d < tol) out.push({ x, y, type: 'quadrant' }); }; if (el.type === 'circle' || el.type === 'arc') { const r = (el.properties as Record).radius as number | undefined ?? el.width / 2; check(el.x + r, el.y); // 0 check(el.x, el.y + r); // 90 check(el.x - r, el.y); // 180 check(el.x, el.y - r); // 270 } } /** * Task D11: Perpendicular-Fußpunkt — der Punkt auf einem Segment, * an dem die Verbindung zum refPoint SENKRECHT auf dem Segment steht. */ private collectPerpendicular( el: CADElement, wx: number, wy: number, refPoint: { x: number; y: number }, tol: number, out: SnapPoint[], ): void { const p = el.properties as Record; const segs: Array> = []; if ( p.x1 !== undefined && p.y1 !== undefined && p.x2 !== undefined && p.y2 !== undefined ) { segs.push([{ x: p.x1 as number, y: p.y1 as number }, { x: p.x2 as number, y: p.y2 as number }]); } else if (Array.isArray(p.points)) { const pts = p.points as Array<{ x: number; y: number }>; for (let i = 0; i < pts.length - 1; i++) segs.push([pts[i], pts[i + 1]]); } for (const [a, b] of segs) { const foot = this.nearestOnSegment(refPoint.x, refPoint.y, a.x, a.y, b.x, b.y); const d = Math.sqrt((wx - foot.x) ** 2 + (wy - foot.y) ** 2); if (d < tol) out.push({ x: foot.x, y: foot.y, type: 'perpendicular' }); } } /** * Task D11: Tangenten-Punkte — Berührpunkte der Tangenten vom * refPoint an den Kreis (beide Lösungen, klassische Konstruktion * über rechtwinkliges Dreieck: Winkel = acos(r/d)). */ private collectTangent( el: CADElement, wx: number, wy: number, refPoint: { x: number; y: number }, tol: number, out: SnapPoint[], ): void { if (el.type !== 'circle' && el.type !== 'arc') return; const r = ((el.properties as Record).radius as number | undefined) ?? el.width / 2; const dx = refPoint.x - el.x; const dy = refPoint.y - el.y; const d = Math.hypot(dx, dy); // refPoint im Zentrum oder im Kreisinneren: keine reale Tangente if (d <= r) return; const baseAng = Math.atan2(dy, dx); const halfAng = Math.acos(r / d); for (const t of [baseAng + halfAng, baseAng - halfAng]) { const px = el.x + r * Math.cos(t); const py = el.y + r * Math.sin(t); const dist = Math.sqrt((wx - px) ** 2 + (wy - py) ** 2); if (dist < tol) out.push({ x: px, y: py, type: 'tangent' }); } } private collectNearest(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void { const p = el.properties; const check = (x: number, y: number) => { const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2); if (d < tol) out.push({ x, y, type: 'nearest' }); }; switch (el.type) { case 'line': { const x1 = p.x1 ?? el.x, y1 = p.y1 ?? el.y; const x2 = p.x2 ?? el.x + el.width, y2 = p.y2 ?? el.y + el.height; const np = this.nearestOnSegment(wx, wy, x1, y1, x2, y2); check(np.x, np.y); break; } case 'circle': { const r = p.radius || el.width / 2; const d = Math.sqrt((wx - el.x) ** 2 + (wy - el.y) ** 2); if (d > 0) { check(el.x + r * (wx - el.x) / d, el.y + r * (wy - el.y) / d); } break; } case 'polyline': case 'polygon': { const pts = p.points || []; for (let i = 0; i < pts.length - 1; i++) { const np = this.nearestOnSegment(wx, wy, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y); check(np.x, np.y); } if (el.type === 'polygon' && pts.length > 2) { const np = this.nearestOnSegment(wx, wy, pts[pts.length - 1].x, pts[pts.length - 1].y, pts[0].x, pts[0].y); check(np.x, np.y); } break; } } } private collectIntersections(wx: number, wy: number, tol: number, out: SnapPoint[]): void { // Check pairs of elements near the cursor const nearby = this.elements.filter(el => { const halfW = el.width / 2 + tol; const halfH = el.height / 2 + tol; return Math.abs(wx - el.x) < halfW && Math.abs(wy - el.y) < halfH; }); for (let i = 0; i < nearby.length; i++) { for (let j = i + 1; j < nearby.length; j++) { const pts = this.findIntersection(nearby[i], nearby[j]); for (const pt of pts) { const d = Math.sqrt((wx - pt.x) ** 2 + (wy - pt.y) ** 2); if (d < tol) out.push({ x: pt.x, y: pt.y, type: 'intersection' }); } } } } private findIntersection(a: CADElement, b: CADElement): Array<{ x: number; y: number }> { // Get line segments from both elements const segsA = this.getElementSegments(a); const segsB = this.getElementSegments(b); const results: Array<{ x: number; y: number }> = []; for (const sa of segsA) { for (const sb of segsB) { const pt = this.segmentIntersection(sa.x1, sa.y1, sa.x2, sa.y2, sb.x1, sb.y1, sb.x2, sb.y2); if (pt) results.push(pt); } } return results; } private getElementSegments(el: CADElement): Array<{ x1: number; y1: number; x2: number; y2: number }> { const p = el.properties; switch (el.type) { case 'line': return [{ x1: p.x1 ?? el.x, y1: p.y1 ?? el.y, x2: p.x2 ?? el.x + el.width, y2: p.y2 ?? el.y + el.height, }]; case 'rect': { const hw = el.width / 2, hh = el.height / 2; return [ { x1: el.x - hw, y1: el.y - hh, x2: el.x + hw, y2: el.y - hh }, { x1: el.x + hw, y1: el.y - hh, x2: el.x + hw, y2: el.y + hh }, { x1: el.x + hw, y1: el.y + hh, x2: el.x - hw, y2: el.y + hh }, { x1: el.x - hw, y1: el.y + hh, x2: el.x - hw, y2: el.y - hh }, ]; } case 'polyline': case 'polygon': { const pts = p.points || []; const segs: Array<{ x1: number; y1: number; x2: number; y2: number }> = []; for (let i = 0; i < pts.length - 1; i++) { segs.push({ x1: pts[i].x, y1: pts[i].y, x2: pts[i + 1].x, y2: pts[i + 1].y }); } if (el.type === 'polygon' && pts.length > 2) { segs.push({ x1: pts[pts.length - 1].x, y1: pts[pts.length - 1].y, x2: pts[0].x, y2: pts[0].y }); } return segs; } default: return []; } } private segmentIntersection( x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number, ): { x: number; y: number } | null { const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); if (Math.abs(denom) < 1e-10) return null; const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom; const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom; if (t >= 0 && t <= 1 && u >= 0 && u <= 1) { return { x: x1 + t * (x2 - x1), y: y1 + t * (y2 - y1) }; } return null; } private nearestOnSegment(px: number, py: number, x1: number, y1: number, x2: number, y2: number): { x: number; y: number } { const dx = x2 - x1; const dy = y2 - y1; const lenSq = dx * dx + dy * dy; if (lenSq === 0) return { x: x1, y: y1 }; let t = ((px - x1) * dx + (py - y1) * dy) / lenSq; t = Math.max(0, Math.min(1, t)); return { x: x1 + t * dx, y: y1 + t * dy }; } /** * Polar tracking: snap cursor to the nearest polar angle from a reference point. * Returns a SnapPoint if the cursor is close to a polar angle, or null. */ private polarSnap(worldX: number, worldY: number, refPoint: { x: number; y: number }): SnapPoint | null { const dx = worldX - refPoint.x; const dy = worldY - refPoint.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 1) return null; // too close to reference point const cursorAngle = (Math.atan2(dy, dx) * 180) / Math.PI; const normalizedCursor = ((cursorAngle % 360) + 360) % 360; // Find closest polar angle let bestAngle: number | null = null; let bestDiff = Infinity; for (const angle of this.config.polarAngles) { let diff = Math.abs(normalizedCursor - angle); if (diff > 180) diff = 360 - diff; if (diff < bestDiff) { bestDiff = diff; bestAngle = angle; } } if (bestAngle === null || bestDiff > this.config.polarTolerance) return null; // Project cursor position onto the polar angle line at the same distance const rad = (bestAngle * Math.PI) / 180; const snapX = refPoint.x + dist * Math.cos(rad); const snapY = refPoint.y + dist * Math.sin(rad); return { x: snapX, y: snapY, type: 'nearest' }; } } // ───────────────────────────────────────────────────────────── // Task D11: fromOffset — Basispunkt + Eingabefeld dx/dy. // Reine Funktion (für from-offset-Snap im UI nutzbar). // ───────────────────────────────────────────────────────────── /** Zielpunkt = Basispunkt + Offsets dx/dy (Task D11 from-offset). */ export function fromOffset(base: { x: number; y: number }, dx: number, dy: number): { x: number; y: number } { return { x: base.x + dx, y: base.y + dy }; }