task(D11): snap extensions - quadrant (4 circle quadrants), perpendicular (foot point from refPoint), tangent (both tangent points via acos), fromOffset helper

This commit is contained in:
Agent Zero
2026-08-29 03:32:42 +02:00
parent 3c806437d6
commit c4e3206932
2 changed files with 244 additions and 1 deletions
+95 -1
View File
@@ -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<string, number> = {
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<string, unknown>).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<string, unknown>;
const segs: Array<Array<{ x: number; y: number }>> = [];
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<string, unknown>).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 };
}
+149
View File
@@ -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<SnapConfig> = {}): Partial<SnapConfig> {
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 });
});
});