From c4e320693228c29d5352a53af4ce5aa6af9ae30b Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 29 Aug 2026 03:32:42 +0200 Subject: [PATCH] task(D11): snap extensions - quadrant (4 circle quadrants), perpendicular (foot point from refPoint), tangent (both tangent points via acos), fromOffset helper --- frontend/src/canvas/SnapEngine.ts | 96 ++++++++++++++++- frontend/tests/snapExtensions.test.ts | 149 ++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/snapExtensions.test.ts diff --git a/frontend/src/canvas/SnapEngine.ts b/frontend/src/canvas/SnapEngine.ts index c75fb70..6cf9e29 100644 --- a/frontend/src/canvas/SnapEngine.ts +++ b/frontend/src/canvas/SnapEngine.ts @@ -104,6 +104,16 @@ export class SnapEngine { 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) @@ -124,7 +134,8 @@ export class SnapEngine { // Priority: endpoint > intersection > center > midpoint > nearest > grid const priority: Record = { - endpoint: 0, intersection: 1, center: 2, midpoint: 3, nearest: 4, grid: 5, + 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 @@ -234,6 +245,79 @@ export class SnapEngine { } } + /** 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) => { @@ -400,3 +484,13 @@ export class SnapEngine { 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 }; +} diff --git a/frontend/tests/snapExtensions.test.ts b/frontend/tests/snapExtensions.test.ts new file mode 100644 index 0000000..76445e7 --- /dev/null +++ b/frontend/tests/snapExtensions.test.ts @@ -0,0 +1,149 @@ +/** + * Task D11 - Snap-Erweiterungen: tangent, perpendicular, quadrant, fromOffset. + * + * quadrant: Kreis-Quadrantenpunkte (0/90/180/270 auf dem Radius). + * perpendicular: Fuspunkt auf Linie senkrecht zum Referenzpunkt. + * tangent: Tangentialpunkte am Kreis vom Referenzpunkt. + * fromOffset: Basispunkt + dx/dy-Eingabefeld. + */ +import { describe, it, expect } from 'vitest'; +import { SnapEngine, fromOffset, type SnapConfig } from '../src/canvas/SnapEngine'; +import type { CADElement } from '../src/types/cad.types'; + +function cfg(modes: string[], extra: Partial = {}): Partial { + return { + enabled: true, + modes: new Set(modes as never), + tolerance: 10, + ...extra, + }; +} + +function circle(id: string, cx: number, cy: number, r: number): CADElement { + return { + id, type: 'circle', layerId: 'l', + x: cx, y: cy, width: r * 2, height: r * 2, + properties: { radius: r }, + } as unknown as CADElement; +} + +function line(id: string, x1: number, y1: number, x2: number, y2: number): CADElement { + return { + id, type: 'line', layerId: 'l', + x: (x1 + x2) / 2, y: (y1 + y2) / 2, + width: Math.abs(x2 - x1), height: Math.abs(y2 - y1), + properties: { x1, y1, x2, y2 }, + } as unknown as CADElement; +} + +// ─── Quadrant ────────────────────────────────────────────── + +describe('D11: quadrant snap', () => { + it('Kreis r=50: alle 4 Quadrantenpunkte werden geliefert', () => { + const eng = new SnapEngine(cfg(['quadrant'])); + eng.setElements([circle('c1', 100, 100, 50)]); + // Cursor in der Naehe des 0-Quadranten (150,100) + const res = eng.snap(148, 100); + expect(res.point).not.toBeNull(); + expect(res.point!.type).toBe('quadrant'); + expect(res.point!.x).toBe(150); + expect(res.point!.y).toBe(100); + }); + + it('90-Quadrant (100,50) wird gefunden', () => { + const eng = new SnapEngine(cfg(['quadrant'])); + eng.setElements([circle('c1', 100, 100, 50)]); + const res = eng.snap(100, 52); + expect(res.point!.x).toBe(100); + expect(res.point!.y).toBe(50); + expect(res.point!.type).toBe('quadrant'); + }); + + it('270-Quadrant (unten) wird gefunden', () => { + const eng = new SnapEngine(cfg(['quadrant'])); + eng.setElements([circle('c1', 100, 100, 50)]); + const res = eng.snap(100, 148); + expect(res.point!.y).toBe(150); + expect(res.point!.type).toBe('quadrant'); + }); + + it('Modus nicht aktiviert -> kein quadrant snap', () => { + const eng = new SnapEngine(cfg(['endpoint'])); + eng.setElements([circle('c1', 100, 100, 50)]); + const res = eng.snap(148, 100); + expect(res.point?.type ?? null).not.toBe('quadrant'); + }); +}); + +// ─── Perpendicular ───────────────────────────────────────── + +describe('D11: perpendicular snap', () => { + it('Fuspunkt senkrecht vom refPoint auf horizontale Linie', () => { + const eng = new SnapEngine(cfg(['perpendicular'])); + // Horizontale Linie y=100, refPoint (50,50): Fuspunkt (50,100) + eng.setElements([line('l1', 0, 100, 200, 100)]); + const res = eng.snap(52, 98, { x: 50, y: 50 }); + expect(res.point).not.toBeNull(); + expect(res.point!.type).toBe('perpendicular'); + expect(res.point!.x).toBe(50); + expect(res.point!.y).toBe(100); + }); + + it('Schrge Linie: Fuspunkt berechnet', () => { + const eng = new SnapEngine(cfg(['perpendicular'])); + // Linie (0,0)-(100,100), refPoint (100,0): Fuspunkt (50,50) + eng.setElements([line('l1', 0, 0, 100, 100)]); + const res = eng.snap(51, 49, { x: 100, y: 0 }); + expect(res.point!.x).toBeCloseTo(50, 0); + expect(res.point!.y).toBeCloseTo(50, 0); + }); + + it('ohne refPoint kein perpendicular snap (Modus still)', () => { + const eng = new SnapEngine(cfg(['perpendicular'])); + eng.setElements([line('l1', 0, 100, 200, 100)]); + const res = eng.snap(50, 98); + expect(res.point?.type ?? null).not.toBe('perpendicular'); + }); +}); + +// ─── Tangent ─────────────────────────────────────────────── + +describe('D11: tangent snap', () => { + it('Tangentialpunkt am Kreis von refPoint (rechts vom Kreis)', () => { + const eng = new SnapEngine(cfg(['tangent'])); + // Kreis (100,100) r=50, refPoint (200,100), d=100: + // halfAng = acos(r/d) = 60° → Tangentenpunkte bei (125, 143.3)/(125, 56.7) + eng.setElements([circle('c1', 100, 100, 50)]); + const res = eng.snap(127, 145, { x: 200, y: 100 }); + expect(res.point).not.toBeNull(); + expect(res.point!.type).toBe('tangent'); + expect(res.point!.x).toBeCloseTo(125, 0); + expect(res.point!.y).toBeCloseTo(143.3, 0); + }); + + it('refPoint AUF dem Kreiszentrum -> kein Tangentenpunkt (degeneriert)', () => { + const eng = new SnapEngine(cfg(['tangent'])); + eng.setElements([circle('c1', 100, 100, 50)]); + const res = eng.snap(148, 100, { x: 100, y: 100 }); + expect(res.point?.type ?? null).not.toBe('tangent'); + }); +}); + +// ─── fromOffset ──────────────────────────────────────────── + +describe('D11: fromOffset', () => { + it('Basispunkt + dx/dy ergibt Zielpunkt', () => { + const p = fromOffset({ x: 100, y: 200 }, 50, -30); + expect(p).toEqual({ x: 150, y: 170 }); + }); + + it('dx=dy=0 bleibt am Basispunkt', () => { + const p = fromOffset({ x: 10, y: 20 }, 0, 0); + expect(p).toEqual({ x: 10, y: 20 }); + }); + + it('negative Offsets funktionieren', () => { + const p = fromOffset({ x: 0, y: 0 }, -100, -50); + expect(p).toEqual({ x: -100, y: -50 }); + }); +});