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:
@@ -104,6 +104,16 @@ export class SnapEngine {
|
|||||||
if (this.config.modes.has('nearest')) {
|
if (this.config.modes.has('nearest')) {
|
||||||
this.collectNearest(el, worldX, worldY, tol, candidates);
|
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)
|
// Intersection snap (between pairs)
|
||||||
@@ -124,7 +134,8 @@ export class SnapEngine {
|
|||||||
|
|
||||||
// Priority: endpoint > intersection > center > midpoint > nearest > grid
|
// Priority: endpoint > intersection > center > midpoint > nearest > grid
|
||||||
const priority: Record<string, number> = {
|
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
|
// 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 {
|
private collectNearest(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
||||||
const p = el.properties;
|
const p = el.properties;
|
||||||
const check = (x: number, y: number) => {
|
const check = (x: number, y: number) => {
|
||||||
@@ -400,3 +484,13 @@ export class SnapEngine {
|
|||||||
return { x: snapX, y: snapY, type: 'nearest' };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user