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')) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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