Compare commits

..

2 Commits

3 changed files with 248 additions and 4 deletions
+4 -3
View File
@@ -608,7 +608,7 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
- [x] D8 PolylineEditor (Vertex add/del, segment→arc glätten) *(2026-08-29 verifiziert: polylineEditTool in core-modify per Log D-fin + Code-Grep)*
- [x] D9 GripEditing (select-Tool erweitern: Handles an Endpunkten/Centern, drag=modify in Transaktion) *(2026-08-29: getGripPoints (line=Endpunkte, polyline=Vertices, circle/arc=Zentrum, rect=BBox-Ecken), Vertex-Drag verschiebt nur den Punkt in 1 Transaktion = undo-fähig, Vertex-Grips gewinnen gegenüber Corner-Skalierung; Log D9)*
- [x] D10 Arrays: ~~array-rect (rows,cols,dx,dy), array-polar (cx,count,angleSum,rotate)~~ erledigt (Log D-ext, Tests vorhanden); **array-path (entlang polyline/arc) noch offen**; Dialog+Vorschau für alle drei noch offen *(2026-08-29 komplett: rect/polar per Log D-ext, array-path jetzt ergänzt — Kopien gleichmäßig entlang Pfadlänge via pointAlongPolyline, 1 Undo-Transaktion, count-Option; Dialog+Vorschau bewusst als UI-Follow-up notiert; Log D10)*
- [ ] D11 Snaps erweitern (SnapEngine + Optionen): tangent, perpendicular, quadrant, center, from-offset (Basispunkt+Eingabefeld dx/dy)
- [x] D11 Snaps erweitern (SnapEngine + Optionen): tangent, perpendicular, quadrant, center, from-offset (Basispunkt+Eingabefeld dx/dy) *(2026-08-29: collectQuadrant (Kreis/Arc-Quadranten 0/90/180/270), collectPerpendicular (Fußpunkt vom refPoint auf Segmente), collectTangent (beide Tangentenpunkte via acos(r/d)), fromOffset (Basispunkt+dx/dy), Prioritäts-Map erweitert; center existierte bereits; Log D11)*
- [ ] D12 BlockAttribute: BlockDef.attrDefs[], Instanz attrValues; PropertiesPanel rendert Formular; RenderAdapter zeichnet Attr-Texte; (DXF ATTDEF/ATTR erst F)
## Phase E — Event-Domain (plugin: event-tools)
@@ -667,6 +667,7 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|---|---|---|---|---|---|
| 2026-08-29 | D11 | Snap-Erweiterungen: (1) collectQuadrant NEU — Kreis/Arc-Quadrantenpunkte 0°/90°/180°/270° auf dem Radius (4 Checks je Element); (2) collectPerpendicular NEU — Perpendicular-Fußpunkt: Punkt auf einem Segment (line-Props oder polyline-Segmente), an dem die Verbindung zum refPoint SENKRECHT steht (nearestOnSegment vom refPoint), still ohne refPoint (Modus braucht aktiven Zug); (3) collectTangent NEU — Berührpunkte der Tangenten vom refPoint an den Kreis über rechtwinkliges Dreieck: halfAng = acos(r/d), BEIDE Lösungen als Kandidaten, Degenerations-Guard d≤r (refPoint im Kreisinneren/Zentrum → keine Tangente); (4) fromOffset NEU als reine exportierte Funktion (Basispunkt + dx/dy für das from-offset-UI); (5) snap()-Schleife erweitert (perpendicular/tangent nur mit refPoint) + Prioritäts-Map erweitert (quadrant zwischen center und perpendicular); SnapPoint-Typ unterstützte perpendicular/tangent/quadrant bereits (C3fin-Typunion vorbereitet — nur die Collects fehlten); Test-Zwischenfall selbst erkannt: mein erster Tangenten-Test erwartete (150,100) — das ist der Kreis-Scheitel Richtung refPoint, NICHT der Tangentenpunkt (Tangente steht senkrecht auf dem Radius!); echte Tangentenpunkte bei d=100/r=50: halfAng=60° → (125,143.3)/(125,56.7) — Test auf echte Geometrie korrigiert | Frontend: 690/690 Tests (+12 snapExtensions.test.ts), tsc 0, Build EXIT 0 | c4e3206 | D11 COMPLETE; nächster Task D12 (BlockAttribute — letztes D-Tool) |
| 2026-08-29 | D10 | array-path als D10-Rest komplett: arrayPathTool NEU in core-modify — Klick1: Quelle (Auto-HitSelect bei leerer Selektion wie array-rect/polar), Klick2: muss eine Polyline/Polygon als Pfad treffen (Typ-Validierung + Punkteanzahl-Check, sonst Status-Erklärung ohne Commit); pointAlongPolyline-Helfer: Punkt bei kumulierter Pfadlänge s über Segment-Iteration (Clamp an Enden); Verteilung: count Kopien bei s = i/(count-1)·total (Default count=5, Option 2100) — gleichmäßig über die GESAMTE Pfadlänge inkl. Endpunkte, gebogene/L-förmige Pfade folgen der Geometrie (Winkel-Test: alle Kopien auf dem Pfad); Pfad-Element selbst wird NICHT kopiert (Filter aus Selektion); ALLES in EINER doc.transact = 1 Undo (Undo-Test: alle Kopien in einem Schritt weg); Original bleibt unangetastet | Frontend: 678/678 Tests (+8 arrayPath.test.ts), tsc 0, Build EXIT 0 | 227d617 | **D10 COMPLETE — alle drei Array-Typen (rect/polar/path) implementiert**; Dialog+Vorschau für Arrays als UI-Follow-up notiert; nächster Task D11 (Snaps erweitern) |
| 2026-08-29 | D9 | GripEditing im select-Tool: (1) getGripPoints NEU als reine exportierte Funktion — element-spezifische Griff-Punkte: line = beide Endpunkte (vertex), polyline/polygon = alle Vertices (vertex), circle/arc = Zentrum (center), rect/default = 4 BBox-Ecken (corner — C3fin-Skalierung bleibt unverändert parallel bestehen); (2) Vertex-Drag-Sitzung im select-Tool — down prüft Vertex-/Center-Grips VOR den BBox-Corners (spezifischer gewinnt bei Überlappung — der Test verifiziert eine line deren Endpunkt AUCH BBox-Ecke ist: Vertex-Drag wirkt, kein Scale), move zeigt Live-Preview (nur der Punkt aktualisiert), up committet doc.updateElement in EINER Transaktion = 1 Undo-Schritt (Undo-Test stellt Endpunkt exakt wieder her), cancel resettet beide Drag-Typen; (3) applyVertexDrag als reine Funktion für Preview UND Commit (line: Endpunkt + BBox-Sync, polyline: nur Vertex + BBox-Sync, center: nur x/y, Radius unberührt — Radius-Bleibt-Test verifiziert) | Frontend: 670/670 Tests (+11 gripEditing.test.ts), tsc 0, Build EXIT 0 | c6f38ee | D9 COMPLETE; nächster Task D10-Rest (array-path) |
| 2026-08-29 | D4 | MTEXT-Tool: mtextTool NEU in core-annotate v1.1.0 — Klick platziert mehrzeiligen Text (Inhalt aus Options-Feld, Zeilenumbrüche via charCode-10-Split konsistent zur F5-Parser-MTEXT-Konvention, mtext:true-Marker); fontSize-Option (Default 12, min 6, max 72); frame-Checkbox-Option erzeugt zustzliches rect-Element in derselben doc.transact (Text + Rahmen = 1 Undo-Schritt, Undo-Test verifiziert BEIDE weg); RahmengröÜe aus längster Zeile×fontSize×0.6 Breite und Zeilenzahl×fontSize×1.4 Höhe plus Padding (Höhen-Wachstums-Test verifiziert); OptionsSchema mit text/number/checkbox-Feldern; tools-Array: textTool, dimensionTool, leaderTool, mtextTool | Frontend: 659/659 Tests (+7 mtextTool.test.ts), tsc 0, Build EXIT 0 | fbc4978 | D4 COMPLETE; nächster Task D9 (GripEditing) |
@@ -748,5 +749,5 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
---
*Nächster offener Schritt: **D11** (Snaps erweitern: tangent, perpendicular, quadrant, center, from-offset — Basispunkt+Eingabefeld dx/dy). Danach: D12 BlockAttribute, Phase I (Docs/CI/Release).*
*Status 2026-08-29: Phasen B+A+C+D+E+F+G+H komplett, D1D4+D9+D10 fertig (alle Arrays: rect/polar/path), BUG-1-Kollisionsklasse geschlossen. FE 678/678 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main. Offen: D11/D12, Phase I (Release).*
*Nächster offener Schritt: **D12** (BlockAttribute: BlockDef.attrDefs[], Instanz attrValues; PropertiesPanel rendert Formular; RenderAdapter zeichnet Attr-Texte; DXF ATTDEF/ATTR erst F — bereits erledigt in F5/F6). Danach: Phase I (Docs/CI/Release).*
*Status 2026-08-29: Phasen B+A+C+D+E+F+G+H komplett, D1D4+D9+D10+D11 fertig (Snaps erweitert), BUG-1-Kollisionsklasse geschlossen. FE 690/690 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main. Offen: D12, Phase I (Release).*
+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 });
});
});