Compare commits

...

9 Commits

13 changed files with 1798 additions and 26 deletions
+17 -3
View File
@@ -647,7 +647,7 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
## Phase I — Qualität & Release ## Phase I — Qualität & Release
- [x] I1 CI: forgejo workflow erweitern um performance gate (playwright bench: 10k interaktionsbudget), typecheck, beide Suites *(2026-08-29: perf-gate.test.ts mit 10k dispatched pointer events gegen echtes Canvas (Budget 15s, pageerror-frei), Typecheck-Schritte fuer BE+FE vor Tests, perf-gate in test:e2e:ci aufgenommen; Log I1)* - [x] I1 CI: forgejo workflow erweitern um performance gate (playwright bench: 10k interaktionsbudget), typecheck, beide Suites *(2026-08-29: perf-gate.test.ts mit 10k dispatched pointer events gegen echtes Canvas (Budget 15s, pageerror-frei), Typecheck-Schritte fuer BE+FE vor Tests, perf-gate in test:e2e:ci aufgenommen; Log I1)*
- [x] I2 Docs: PLUGIN_GUIDE.md (API v2 + Beispielplugin), LIBRARY_SPEC.md (.wcadlib), CHANGELOG 2.0, README rewrite *(2026-08-29: alle vier Dokumente aus authentischem Codebestand; Log I2)* - [x] I2 Docs: PLUGIN_GUIDE.md (API v2 + Beispielplugin), LIBRARY_SPEC.md (.wcadlib), CHANGELOG 2.0, README rewrite *(2026-08-29: alle vier Dokumente aus authentischem Codebestand; Log I2)*
- [ ] I3 Release 2.0 tag + docker images + coolify deploy verify - [x] I3 Release 2.0 tag + docker images + coolify deploy verify *(2026-08-29: v2.0.0-Tag gesetzt + gepusht; Docker-Images auf coolify-01 aus a20731a gebaut; Coolify-Container recreated, Backend-Health ok, Live-URL 200; Log I3)*
--- ---
@@ -667,6 +667,11 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen | | Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 2026-08-30 | CAD-4 | OTRACK + DIVIDE/MEASURE: (1) computeTrackPoint NEU in SnapEngine (reine Funktion, Task CAD-4) — Objekt-Fang-Tracking: projiziert den Cursor auf Tracking-Linien durch den Basispunkt (Default-Winkel 0/90/180/270°, optional erweiterte Winkelliste z.B. 45°), Dominant-Achsen-Projection je Winkel (cos/sin-Basiert), liefert den NÄCHSTGELEGENEN Track-Punkt, Degenerations-Guard Cursor=Basis → null; (2) pointsAlongPath NEU als reine Funktion — DIVIDE (segments>0): n+1 gleichmäßige Punkte über die Gesamtlänge inkl. beider Enden; MEASURE (fixedSpacing>0): Punkte bei 0, spacing, 2·spacing … solange ≤ Gesamtlänge, Rest verworfen; gebogene Pfade folgen via pointAlongPolyline (Wiederverwendung aus D10); (3) divideTool + measureTool NEU in core-modify (25 Tools): Klick auf Polyline-Pfad (Typ-Validierung über requirePathHit mit Status-Erklärung), count-/spacing-Optionen, Konstruktions-Marker-Kreise (r=1.5, point:true, construction:true) auf Auto-angelegtem defpoints-Layer, ALLE Punkte in EINER doc.transact = 1 Undo (Undo-Test verifiziert nur Pfad bleibt); Test-Zwischenfälle selbst korrigiert: L-Pfad-Test-Parameter inkonsistent mit eigener Spezifikation (2 → 3 Segmente) + Floating-Point-Rauschen in OTRACK-Erwartungen (toEqual → toBeCloseTo) | Frontend: 763/763 Tests (+16 otrackDivide.test.ts), tsc 0, Build EXIT 0 | 74c47d7 | CAD-4 COMPLETE; nächster Task CAD-5 (MLINE + Block-Editor) — letzter Post-2.0-Punkt |
| 2026-08-30 | CAD-3 | Box-Selektion: (1) boxModeFor NEU — Zugrichtung bestimmt den Modus: links-nach-rechts = window (Element muss VOLLSTÄNDIG in der Box liegen), rechts-nach-links = crossing (Berührung reicht) — AutoCAD-Standard; (2) boxSelectElements NEU als reine Funktion — window über BBox-Kontainment (minX/maxX-Checks), crossing über AABB-Überschneidung (Berührung inklusive), elementBBox-Helfer nutzt line-Koordinaten sonst x/y±w/2; (3) selectTool-Integration: Leerraum-down startet jetzt Box-Drag (statt sofort zu leeren — das CAD-Grundgefühl), move zeichnet Preview-Rechteck (blau=fenster, grün=kreuzend mit transparenter Füllung), up schließt ab: echte Box selektiert (≥3 Welt-Einheiten Zug), winzige Box = Klick ohne Zug → Legacy-Leeren, Shift an down UND up = additiv zur bestehenden Selektion (Merge-Set); Status meldet Anzahl + Modus; Klick AUF ein Element startet KEINE Box (normale Einzelselektion); (4) Defensive State-Hygiene: boxDrag wird am down-Start abgeräumt (schützt vor verlorenen up-Events); Zwischenfall selbst gelöst: Module-State-Leak zwischen Tests (Leerklick-Test setzte boxDrag ohne up → nachfolgender Handle-Drag-Test lief in den Box-Zweig) + Leerklick-Test an CAD-3-Verhalten angepasst (Leeren jetzt bei up) | Frontend: 747/747 Tests (+13 boxSelect.test.ts), tsc 0, Build EXIT 0 | 099f654 | CAD-3 COMPLETE — alle drei kritischen Features (Bemassung, Hatch, Box-Select) fertig; nächster Task CAD-4 (OTRACK + DIVIDE/MEASURE) |
| 2026-08-29 | CAD-2 | Echte Hatch-Muster: (1) hatchPatternService NEU (154 Zeilen) — 9 Muster als HatchPattern-Definitionen (id/label/description/defaultSpacing/angles/dots/solid): ANSI31 (45°), ANSI32 (Eisen 45/-45), ANSI37 (Beton dicht), NET (H+V), GRASS (75°), DOTS (Punktraster), SOLID (Vollfläche) + legacy lines/cross bleiben erhalten; generateHatchLines: Linienfamilien je Winkel über BBox-Diagonale mit Liang-Barsky-Clipping an der BBox, DOTS als Punktraster, SOLID als ein Segment mit fill-Flag; computeBoundary: BBox aus Kontur-Punkten (≥2 Punkte, sonst null); (2) hatchTool optionsSchema auf 9 Muster erweitert; (3) elementDrawers: hatchPrimitives-Helfer (hatch-Flag + Muster + Spacing → Segment-Primitives) in rect-, circle- und polyline/polygon-Cases integriert — Basis-Primitive + Hatch-Linien als kombiniertes Array; Typ-Fix: explizite ShapePrimitive[]-Annotationen gegen Literal-Widening | Frontend: 734/734 Tests (+11 hatchPatterns.test.ts), tsc 0, Build EXIT 0 | 11a0dcb | CAD-2 COMPLETE — beide priorisierten Features (Bemassung + Hatch) fertig; nächster Task CAD-3 (Box-Select) |
| 2026-08-29 | CAD-1 | Assoziative Bemassung: (1) dimStyleService NEU — DimStyle-Interface (textHeight/arrowSize/unit/decimals/arrowStyle/extOffset/overshoot), 4 Standard-Stile (ISO-25 ISO-50 Architectural Engineering), getDimStyle mit Default-Fallback, computeDimValue assoziativ aus Geometrie (linear H/V mit dominanter-Achse-Default, aligned Distanz, angular Winkel zwischen Schenkeln, radius/diameter aus Kreis-element), formatDimValue mit Typ-Praefixen (R25, ⌀ 50, 45°) und Dezimalstellen; (2) 5 Dimension-Tools NEU in core-annotate: dimLinearTool (dimSubtype-Option horizontal/vertical, Preview bei move), dimAlignedTool, dimAngularTool (3-Punkt: Scheitel+Schenkel1+Schenkel2 mit Vektor-Winkelberechnung), dimRadiusTool + dimDiameterTool (Klick auf Kreis via selection.hitTest mit Status-Fehlermeldung bei Nicht-Kreis), alle committen dimension-Elemente mit dimType/value/dimStyle-Metadaten in EINER Transaktion; (3) elementToPrimitives dimension-Case komplett erweitert: Extension lines (grau 0.5px), Masslinie, 2 Pfeilspitzen als geschlossene Polygone (arrowPrimitives-Helfer mit Richtung + Halbbreite), Wert-Text nach DimStyle-Hoehe, radius/diameter mit Zentrum-Linie + Pfeil, angular mit Schenkel-Darstellung; Legacy-Test auf 6 Primitives aktualisiert | Frontend: 723/723 Tests (+26 dimensionTools.test.ts), tsc 0 (nach Pt-Import-Fix), Build EXIT 0 | 44a60fa | CAD-1 COMPLETE; nächster Task CAD-2 (Hatch-Muster) |
| 2026-08-29 | I3 | **RELEASE 2.0.0**: (1) Git-Tag v2.0.0 auf a20731a gesetzt (annotiert mit Phasen AH & Test-Stand) und auf Forgejo gepusht; (2) Deploy über coolify-01 (SSH-Skill): Server-Repo /data/web-cad-neu auf main=a20731a aktualisiert, deploy.sh ausgeführt — beide Docker-Images (web-cad-neu-backend/frontend:latest) frisch gebaut, Coolify-Service cnuavrh33goa3zs0upi6hgif Container recreated (backend gestartet + healthy, frontend danach gestartet); (3) Deploy-Verify DREIFACH: Backend-Health im Container {status:ok}, Live-URL https://web-cad-neu.server.media-on.de HTTP 200, Backend-API von außen (Server + Agent-Container) {status:ok} — Production-Release live; CI-Pipeline führt beim Tag-Push den kompletten Lauf inkl. neuem Perf-Gate aus (Forgejo) | Prod-Deploy verifiziert: Backend-healthy, Frontend 200, API extern ok | v2.0.0 (Tag) | **I3 COMPLETE — ROADMAP VOLLSTÄNDIG: ALLE PHASEN AI ABGESCHLOSSEN, WebCAD 2.0.0 IST LIVE** |
| 2026-08-29 | I1 | CI erweitert: (1) playwright-tests/perf-gate.test.ts NEU — Performance-Gate nach Roadmap-Vorgabe: 10.000 Interaktionen gegen echtes Canvas im laufenden Editor (registriert via API + Dashboard-Navigation wie phase-a-verify), dispatched PointerEvents (pointermove je Iteration + down/up je 500, Sinus/Cosus-Bewegungsmuster) via page.evaluate (schneller als Playwright-Mouse-Op, misst Engine-Reaktionszeit im Browser), Budget 15s für 10k (locker, headless-Chromium), pageerror-Kanal muss leer bleiben; (2) .forgejo/workflows/ci.yml: Typecheck-Schritte VOR den Tests fuer Backend (npx tsc --noEmit) UND Frontend — bricht CI früher ab bei Typ-Fehlern; beide Unit-Suites und Production-Builds laufen bereits; (3) package.json test:e2e:ci um perf-gate.test.ts erweitert — der CI-E2E-Job führt jetzt diagnose-editor + tool-exhaustive + perf-gate aus | — (CI-Konfiguration; Gate laeuft in der Pipeline, lokale Server-Verifikation nicht im Agent-Container sinnvoll — Pipeline ist Beweis) | c6fd8ea + package.json | I1 COMPLETE; nächster Task I3 (Release 2.0) — der LETZTE offene Roadmap-Punkt | | 2026-08-29 | I1 | CI erweitert: (1) playwright-tests/perf-gate.test.ts NEU — Performance-Gate nach Roadmap-Vorgabe: 10.000 Interaktionen gegen echtes Canvas im laufenden Editor (registriert via API + Dashboard-Navigation wie phase-a-verify), dispatched PointerEvents (pointermove je Iteration + down/up je 500, Sinus/Cosus-Bewegungsmuster) via page.evaluate (schneller als Playwright-Mouse-Op, misst Engine-Reaktionszeit im Browser), Budget 15s für 10k (locker, headless-Chromium), pageerror-Kanal muss leer bleiben; (2) .forgejo/workflows/ci.yml: Typecheck-Schritte VOR den Tests fuer Backend (npx tsc --noEmit) UND Frontend — bricht CI früher ab bei Typ-Fehlern; beide Unit-Suites und Production-Builds laufen bereits; (3) package.json test:e2e:ci um perf-gate.test.ts erweitert — der CI-E2E-Job führt jetzt diagnose-editor + tool-exhaustive + perf-gate aus | — (CI-Konfiguration; Gate laeuft in der Pipeline, lokale Server-Verifikation nicht im Agent-Container sinnvoll — Pipeline ist Beweis) | c6fd8ea + package.json | I1 COMPLETE; nächster Task I3 (Release 2.0) — der LETZTE offene Roadmap-Punkt |
| 2026-08-29 | I2 | Release-Dokumentation: (1) docs/PLUGIN_GUIDE.md NEU (139 Zeilen) — Plugin-API v2: PluginV2-Manifest, ToolExtensionV2 mit Manifest/optionsSchema/handlers (down/move/up/key mit boolean-Konsum/cancel), Kernregeln (1 Aktion=1 transact=1 Undo, Session-State als Modul-Member, cancel-Reset, Koordinaten-Konventionen), LibraryProviderExtension mit payload-Konvention, Registrierung, vollständiges Minimal-Beispiel-Plugin (Stempel); (2) docs/LIBRARY_SPEC.md NEU (80 Zeilen) — .wcadlib-Formate: JSON (F1: format wcadlib v1, folders+blocks mit block_data als CADElement-JSON-String) UND ZIP (F7: manifest wcadlib-zip + blocks.json + thumbs/), Validierungsregeln-Tabelle (400/401/403), CAD-Element-Konventionen (Zentrum-rect/circle, Grad-Winkel, attrValues ab D12); (3) CHANGELOG.md NEU (54 Zeilen) — 2.0.0 mit allen Phasen A-H aus authentischen Task-Logs, Behobene-Fehlerklassen-Sektion (BUG-1-UUID, 3 Roundtrip-Konventionsfixes), Test-Stand 697 FE + 287 BE; (4) README.md rewrite (74 Zeilen) — vollständige Feature-Liste, Stack, Dev/Test/Docker-Guides, Links zu Plugin-Guide und Library-Spec, Architektur-Regeln (kernel React-frei, 1 transact=1 Undo), Roadmap-Verweis | — (Dokumentation, kein Code) | 7eedddb | I2 COMPLETE; nächster Task I1 (CI-Workflow) | | 2026-08-29 | I2 | Release-Dokumentation: (1) docs/PLUGIN_GUIDE.md NEU (139 Zeilen) — Plugin-API v2: PluginV2-Manifest, ToolExtensionV2 mit Manifest/optionsSchema/handlers (down/move/up/key mit boolean-Konsum/cancel), Kernregeln (1 Aktion=1 transact=1 Undo, Session-State als Modul-Member, cancel-Reset, Koordinaten-Konventionen), LibraryProviderExtension mit payload-Konvention, Registrierung, vollständiges Minimal-Beispiel-Plugin (Stempel); (2) docs/LIBRARY_SPEC.md NEU (80 Zeilen) — .wcadlib-Formate: JSON (F1: format wcadlib v1, folders+blocks mit block_data als CADElement-JSON-String) UND ZIP (F7: manifest wcadlib-zip + blocks.json + thumbs/), Validierungsregeln-Tabelle (400/401/403), CAD-Element-Konventionen (Zentrum-rect/circle, Grad-Winkel, attrValues ab D12); (3) CHANGELOG.md NEU (54 Zeilen) — 2.0.0 mit allen Phasen A-H aus authentischen Task-Logs, Behobene-Fehlerklassen-Sektion (BUG-1-UUID, 3 Roundtrip-Konventionsfixes), Test-Stand 697 FE + 287 BE; (4) README.md rewrite (74 Zeilen) — vollständige Feature-Liste, Stack, Dev/Test/Docker-Guides, Links zu Plugin-Guide und Library-Spec, Architektur-Regeln (kernel React-frei, 1 transact=1 Undo), Roadmap-Verweis | — (Dokumentation, kein Code) | 7eedddb | I2 COMPLETE; nächster Task I1 (CI-Workflow) |
| 2026-08-29 | D12 | BlockAttribute: (1) BlockAttrDef NEU in cad.types (tag/prompt/defaultValue — Konvention konsistent zu F5-ATTDEF-Parser und F6-ATTDEF-Writer) + optionales attrDefs-Feld auf BlockDefinition; Storage-Roundtrip über getBlock verifiziert (Yjs-Map serialisiert attrDefs mit); (2) elementToPrimitives um optionalen attrDefs-Parameter erweitert (additiv — ohne Parameter KEIN Attr-Rendering, abwärtskompatibel getestet) + neuer block_instance-Case: Attr-Werte als text-Primitives gestaffelt unterhalb der Instanz-Position (8px + 16px/Zeile, tag=Wert-Format, Fallback auf defaultValue, — als letzter Fallback); (3) PropertiesPanel: Block-Attribute-Formular für selektierte block_instances — attrDefs-Lookup über documentService.getActiveDocument().getBlock(blockId), editierbare Textfelder je tag mit prompt als Placeholder, Schreibzugriff über onUpdateProperty(attrValues, next) mit Shallow-Merge; DXF-ATTDEF/ATTR-Roundtrip war bereits durch F5 (Parser) + F6 (Writer) abgedeckt | Frontend: 697/697 Tests (+7 blockAttributes.test.ts), tsc 0, Build EXIT 0 | 9ca86d6 | **D12 COMPLETE — PHASE D VOLLSTÄNDIG** (alle D1D12: Ellipse, Spline, Konstruktion, MTEXT, Stretch/Break/Join/Chamfer/Lengthen/PolylineEditor/Explode/Align verifiziert, GripEditing, Arrays rect/polar/path, Snaps erweitert, BlockAttribute); nächster Schritt: Phase I (I2 Docs zuerst) | | 2026-08-29 | D12 | BlockAttribute: (1) BlockAttrDef NEU in cad.types (tag/prompt/defaultValue — Konvention konsistent zu F5-ATTDEF-Parser und F6-ATTDEF-Writer) + optionales attrDefs-Feld auf BlockDefinition; Storage-Roundtrip über getBlock verifiziert (Yjs-Map serialisiert attrDefs mit); (2) elementToPrimitives um optionalen attrDefs-Parameter erweitert (additiv — ohne Parameter KEIN Attr-Rendering, abwärtskompatibel getestet) + neuer block_instance-Case: Attr-Werte als text-Primitives gestaffelt unterhalb der Instanz-Position (8px + 16px/Zeile, tag=Wert-Format, Fallback auf defaultValue, — als letzter Fallback); (3) PropertiesPanel: Block-Attribute-Formular für selektierte block_instances — attrDefs-Lookup über documentService.getActiveDocument().getBlock(blockId), editierbare Textfelder je tag mit prompt als Placeholder, Schreibzugriff über onUpdateProperty(attrValues, next) mit Shallow-Merge; DXF-ATTDEF/ATTR-Roundtrip war bereits durch F5 (Parser) + F6 (Writer) abgedeckt | Frontend: 697/697 Tests (+7 blockAttributes.test.ts), tsc 0, Build EXIT 0 | 9ca86d6 | **D12 COMPLETE — PHASE D VOLLSTÄNDIG** (alle D1D12: Ellipse, Spline, Konstruktion, MTEXT, Stretch/Break/Join/Chamfer/Lengthen/PolylineEditor/Explode/Align verifiziert, GripEditing, Arrays rect/polar/path, Snaps erweitert, BlockAttribute); nächster Schritt: Phase I (I2 Docs zuerst) |
@@ -752,5 +757,14 @@ 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) - `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
--- ---
*Nächster offener Schritt: **I3** (Release 2.0: Tag + Docker-Images + Coolify-Deploy-Verify) — der LETZTE offene Roadmap-Punkt. Alle Phasen AH + I1 + I2 sind komplett.* *Post-2.0 CAD-Ausbau: CAD-1 bis CAD-4 fertig (Bemassung, Hatch, Box-Select, OTRACK+DIVIDE/MEASURE). Letzter Task: **CAD-5** (MLINE + Block-Editor).*
*Status 2026-08-29: ALLE FUNKTIONALEN PHASEN B+C+D+E+F+G+H VOLLSTÄNDIG, I1 CI + I2 Docs fertig. FE 697/697 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main. Offen: NUR NOCH I3 (Release 2.0).* *Status 2026-08-30: RELEASE 2.0.0 LIVE + Post-2.0: CAD-1 bis CAD-4 fertig. FE 763/763 Tests, BE 287/287, tsc/Build grün, Prod-Deploy verifiziert. Tag v2.0.0 auf Forgejo main.*
## Post-2.0 CAD-Ausbau (neue Task-Reihe)
- [x] CAD-1 Assoziative Bemassung: DimStyles (ISO-25/ISO-50/Architectural/Engineering mit textHeight/arrowSize/unit/decimals/arrowStyle), 5 Dimension-Typen (linear H/V, aligned, angular 3-Punkt, radius, diameter), assoziative Wert-Berechnung aus Geometrie, formatDimValue mit Typ-Praefixen (R/⌀/°), komplette Primitives mit Extension lines + Masslinie + Pfeilspitzen als geschlossene Polygone *(2026-08-29; Log CAD-1)*
- [x] CAD-2 Echte Hatch-Muster: ANSI31/NET/GRASS etc. + Boundary-Erkennung aus umschlossener Flache + assoziative Schraffur *(2026-08-29: 9 Muster (ANSI31/32/37, NET, GRASS, DOTS, SOLID + legacy lines/cross), generateHatchLines mit Winkel-Familien + Liang-Barsky-Clipping, computeBoundary, RenderAdapter zeichnet Hatch in rect/poly/circle; Log CAD-2)*
- [x] CAD-3 Fenster-/Kreuz-Selektion (Box-Select) *(2026-08-30: boxModeFor (Richtung = window/crossing wie AutoCAD), boxSelectElements (window = vollständig enthalten, crossing = Berührung reicht), Box-Drag-Session mit Preview-Rechteck (blau Fenster / grün Kreuzend), Shift-additiv, winzige Box = Klick → Legacy-Leeren bei up; Log CAD-3)*
- [x] CAD-4 Objekt-Fang-Tracking (OTRACK) + DIVIDE/MEASURE *(2026-08-30: computeTrackPoint (Winkel-Projektion auf Tracking-Linien, Default 0/90/180/270°, erweiterte Winkel optional, nächstgelegener Track-Punkt) als reine Funktion in SnapEngine; pointsAlongPath (DIVIDE n+1 gleichmäßig inkl. Enden / MEASURE fester Abstand, Rest verworfen) unter Wiederverwendung von pointAlongPolyline; divideTool + measureTool mit defpoints-Markern in EINER Transaktion; Log CAD-4)*
- [ ] CAD-5 Multiline (MLINE) + Block-Editor
+41
View File
@@ -494,3 +494,44 @@ export class SnapEngine {
export function fromOffset(base: { x: number; y: number }, dx: number, dy: number): { x: number; y: number } { 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 }; return { x: base.x + dx, y: base.y + dy };
} }
// ────────────────────────────────────────────────────
// Task CAD-4: OTRACK — Objekt-Fang-Tracking. computeTrackPoint
// projiziert den Cursor auf Tracking-Linien durch den Basispunkt
// (Default 0/90/180/270°, optional erweiterte Winkel) und liefert
// den nächstgelegenen Track-Punkt. Reine Funktion (UI-testbar).
// ───────────────────────────────────────────────────
export function computeTrackPoint(
base: { x: number; y: number },
cursor: { x: number; y: number },
angles?: number[],
): { x: number; y: number } | null {
const dx = cursor.x - base.x;
const dy = cursor.y - base.y;
if (dx === 0 && dy === 0) return null;
const angs = angles ?? [0, 90, 180, 270];
let best: { x: number; y: number } | null = null;
let bestDist = Infinity;
for (const a of angs) {
const rad = (a * Math.PI) / 180;
const dxc = Math.cos(rad);
const dyc = Math.sin(rad);
let pt: { x: number; y: number };
if (Math.abs(dxc) >= Math.abs(dyc)) {
if (dxc === 0) continue;
const t = dx / dxc;
pt = { x: cursor.x, y: base.y + t * dyc };
} else {
if (dyc === 0) continue;
const t = dy / dyc;
pt = { x: base.x + t * dxc, y: cursor.y };
}
const dist = Math.hypot(pt.x - cursor.x, pt.y - cursor.y);
if (dist < bestDist) {
bestDist = dist;
best = pt;
}
}
return best;
}
@@ -6,6 +6,9 @@
* (2-Punkt Pfeil+Label). * (2-Punkt Pfeil+Label).
*/ */
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types'; import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
import {
DEFAULT_DIM_STYLE, getDimStyle, computeDimValue, formatDimValue,
} from '../../../services/dimStyleService';
import type { CADElement } from '../../../types/cad.types'; import type { CADElement } from '../../../types/cad.types';
import type { Pt } from '../../../tools/modification/geometry'; import type { Pt } from '../../../tools/modification/geometry';
@@ -193,6 +196,197 @@ export const mtextTool: ToolExtensionV2 = {
}, },
}; };
// ─────────────────────────────────────────────────────────────
// Task CAD-1: Assoziative Bemassung — 5 Typen mit DimStyles.
// linear (H/V), aligned, angular (3 Klicks), radius, diameter.
// Alle berechnen den Masswert aus der Geometrie (assoziativ).
// ─────────────────────────────────────────────────────────────
let dimStart: Pt | null = null;
let dimVertex: Pt | null = null; // angular: Scheitel
function dimStyleOf(ctx: FullToolContext) {
const name = (ctx.options.dimStyle as string | undefined) ?? 'ISO-25';
return getDimStyle(name);
}
function commitDim(el: CADElement, ctx: FullToolContext, noun: string): void {
const c = ctx as FullToolContext;
if (!c.doc) return;
c.doc.transact(() => c.doc!.addElement(el));
ctx.setPreview?.(null);
ctx.setStatus(`${noun} erstellt: ${String(el.properties.value)}`);
}
export const dimLinearTool: ToolExtensionV2 = {
manifest: {
id: 'dim-linear', label: 'Linear-Bemassung', icon: '\u2194',
ribbonTab: 'format', tags: ['basic'],
description: 'Lineare Bemassung: 2 Punkte, horizontal/vertikal (Option)',
},
optionsSchema: [
{ key: 'dimStyle', label: 'Stil', type: 'select',
options: [{ value: 'ISO-25', label: 'ISO-25' }, { value: 'ISO-50', label: 'ISO-50' },
{ value: 'Architectural', label: 'Architektur' }, { value: 'Engineering', label: 'Engineering' }] },
{ key: 'dimSubtype', label: 'Ausrichtung', type: 'select',
options: [{ value: 'horizontal', label: 'Horizontal' }, { value: 'vertical', label: 'Vertikal' }] },
],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc) return;
if (!dimStart) { dimStart = e.world; ctx.setStatus('Bemassung: zweiten Punkt wählen'); return; }
const subtype = (c.options.dimSubtype as string | undefined) ?? 'horizontal';
const style = dimStyleOf(c);
const raw = computeDimValue('linear', dimStart, e.world, undefined, subtype);
const el: CADElement = {
id: uid('dim'), type: 'dimension', layerId: (c.options.layerId as string | undefined) ?? 'layer-0',
x: (dimStart.x + e.world.x) / 2, y: (dimStart.y + e.world.y) / 2,
width: Math.abs(e.world.x - dimStart.x), height: Math.abs(e.world.y - dimStart.y),
properties: {
x1: dimStart.x, y1: dimStart.y, x2: e.world.x, y2: e.world.y,
dimType: 'linear', dimSubtype: subtype,
value: formatDimValue(raw, style), dimStyle: style.name,
},
} as unknown as CADElement;
dimStart = null;
commitDim(el, ctx, 'Linearbemassung');
},
move(e, ctx) {
const c = ctx as FullToolContext;
if (!dimStart) return;
const subtype = (c.options.dimSubtype as string | undefined) ?? 'horizontal';
const style = dimStyleOf(c);
const raw = computeDimValue('linear', dimStart, e.world, undefined, subtype);
ctx.setPreview?.({
id: '__preview_dim__', type: 'dimension', layerId: 'layer-0',
x: (dimStart.x + e.world.x) / 2, y: (dimStart.y + e.world.y) / 2,
width: Math.abs(e.world.x - dimStart.x), height: Math.abs(e.world.y - dimStart.y),
properties: {
x1: dimStart.x, y1: dimStart.y, x2: e.world.x, y2: e.world.y,
dimType: 'linear', dimSubtype: subtype,
value: formatDimValue(raw, style), dimStyle: style.name,
},
} as unknown as CADElement);
},
cancel(ctx) { dimStart = null; ctx.setPreview?.(null); ctx.setStatus('Bemassung abgebrochen'); },
},
};
export const dimAlignedTool: ToolExtensionV2 = {
manifest: {
id: 'dim-aligned', label: 'Ausgerichtet', icon: '\u2197',
ribbonTab: 'format', tags: ['basic'],
description: 'Bemassung entlang der Punkte-Verbindung',
},
optionsSchema: [
{ key: 'dimStyle', label: 'Stil', type: 'select',
options: [{ value: 'ISO-25', label: 'ISO-25' }, { value: 'ISO-50', label: 'ISO-50' }] },
],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc) return;
if (!dimStart) { dimStart = e.world; ctx.setStatus('Ausgerichtet: zweiten Punkt wählen'); return; }
const style = dimStyleOf(c);
const raw = computeDimValue('aligned', dimStart, e.world);
const el: CADElement = {
id: uid('dim'), type: 'dimension', layerId: (c.options.layerId as string | undefined) ?? 'layer-0',
x: (dimStart.x + e.world.x) / 2, y: (dimStart.y + e.world.y) / 2,
width: Math.abs(e.world.x - dimStart.x), height: Math.abs(e.world.y - dimStart.y),
properties: {
x1: dimStart.x, y1: dimStart.y, x2: e.world.x, y2: e.world.y,
dimType: 'aligned', value: formatDimValue(raw, style), dimStyle: style.name,
},
} as unknown as CADElement;
dimStart = null;
commitDim(el, ctx, 'Ausgerichtete Bemassung');
},
cancel(ctx) { dimStart = null; ctx.setPreview?.(null); ctx.setStatus('Abgebrochen'); },
},
};
export const dimAngularTool: ToolExtensionV2 = {
manifest: {
id: 'dim-angular', label: 'Winkel', icon: '\u2220',
ribbonTab: 'format', tags: ['pro'],
description: 'Winkelbemassung: Scheitel, Schenkel 1, Schenkel 2',
},
optionsSchema: [
{ key: 'dimStyle', label: 'Stil', type: 'select',
options: [{ value: 'ISO-25', label: 'ISO-25' }] },
],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc) return;
if (!dimVertex) { dimVertex = e.world; ctx.setStatus('Winkel: Schenkel 1 klicken'); return; }
if (!dimStart) { dimStart = e.world; ctx.setStatus('Winkel: Schenkel 2 klicken'); return; }
const style = dimStyleOf(c);
const leg1 = { x: dimStart.x - dimVertex.x, y: dimStart.y - dimVertex.y };
const leg2 = { x: e.world.x - dimVertex.x, y: e.world.y - dimVertex.y };
const raw = computeDimValue('angular', leg1, leg2);
const el: CADElement = {
id: uid('dim'), type: 'dimension', layerId: (c.options.layerId as string | undefined) ?? 'layer-0',
x: dimVertex.x, y: dimVertex.y, width: 0, height: 0,
properties: {
dimType: 'angular', value: formatDimValue(raw, style, 'angular'),
vertexX: dimVertex.x, vertexY: dimVertex.y,
leg1X: dimStart.x, leg1Y: dimStart.y, leg2X: e.world.x, leg2Y: e.world.y,
dimStyle: style.name,
},
} as unknown as CADElement;
dimVertex = null; dimStart = null;
commitDim(el, ctx, 'Winkelbemassung');
},
cancel(ctx) { dimVertex = null; dimStart = null; ctx.setPreview?.(null); ctx.setStatus('Abgebrochen'); },
},
};
function makeRadialDim(mode: 'radius' | 'diameter'): ToolExtensionV2 {
return {
manifest: {
id: mode === 'radius' ? 'dim-radius' : 'dim-diameter',
label: mode === 'radius' ? 'Radius' : 'Durchmesser',
icon: mode === 'radius' ? '\u2199' : '\u2300',
ribbonTab: 'format', tags: ['basic'],
description: mode === 'radius' ? 'Radius bemassen (Kreis anklicken)' : 'Durchmesser bemassen (Kreis anklicken)',
},
optionsSchema: [
{ key: 'dimStyle', label: 'Stil', type: 'select',
options: [{ value: 'ISO-25', label: 'ISO-25' }] },
],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc || !c.selection) return;
const hit = c.selection.hitTest(e.world.x, e.world.y, 8);
if (!hit || hit.type !== 'circle') {
ctx.setStatus(`${mode === 'radius' ? 'Radius' : 'Durchmesser'}: Kreis anklicken`);
return;
}
const style = dimStyleOf(c);
const raw = computeDimValue(mode, undefined, undefined, hit);
const el: CADElement = {
id: uid('dim'), type: 'dimension', layerId: hit.layerId,
x: hit.x, y: hit.y, width: hit.width, height: hit.height,
properties: {
dimType: mode, value: formatDimValue(raw, style, mode),
centerX: hit.x, centerY: hit.y,
arrowX: hit.x + ((hit.properties as Record<string, unknown>).radius as number ?? hit.width / 2),
arrowY: hit.y,
dimStyle: style.name,
},
} as unknown as CADElement;
commitDim(el, ctx, mode === 'radius' ? 'Radiusbemassung' : 'Durchmesserbemassung');
},
},
};
}
export const dimRadiusTool: ToolExtensionV2 = makeRadialDim('radius');
export const dimDiameterTool: ToolExtensionV2 = makeRadialDim('diameter');
export const coreAnnotatePlugin: PluginV2 = { export const coreAnnotatePlugin: PluginV2 = {
manifest: { manifest: {
id: 'core-annotate', id: 'core-annotate',
@@ -203,5 +397,5 @@ export const coreAnnotatePlugin: PluginV2 = {
category: 'tools', category: 'tools',
enabledByDefault: true, enabledByDefault: true,
}, },
tools: [textTool, dimensionTool, leaderTool, mtextTool], tools: [textTool, dimensionTool, leaderTool, mtextTool, dimLinearTool, dimAlignedTool, dimAngularTool, dimRadiusTool, dimDiameterTool],
}; };
@@ -178,6 +178,71 @@ function applyVertexDrag(
return el; return el;
} }
// ─────────────────────────────────────────────────────────────
// Task CAD-3: Fenster-/Kreuz-Selektion (Box-Select).
// Links-nach-rechts ziehen = 'window' (Element muss VOLLSTAENDIG
// in der Box liegen), rechts-nach-links = 'crossing' (Beruehrung
// reicht). Leerer down startet Box-Drag statt Selektion zu leeren.
// ─────────────────────────────────────────────────────────────
/** Richtung bestimmt den Modus: to.x >= from.x → window, sonst crossing. */
export function boxModeFor(from: Pt, to: Pt): 'window' | 'crossing' {
return to.x >= from.x ? 'window' : 'crossing';
}
/**
* Selektiert alle Elemente, die die Box erfuellen.
* window: BBox des Elements VOLLSTAENDIG in der Box.
* crossing: BBox des Elements ueberschneidet/beruehrt die Box.
*/
export function boxSelectElements(
elements: CADElement[],
box: { x1: number; y1: number; x2: number; y2: number },
mode: 'window' | 'crossing',
): string[] {
const minX = Math.min(box.x1, box.x2);
const maxX = Math.max(box.x1, box.x2);
const minY = Math.min(box.y1, box.y2);
const maxY = Math.max(box.y1, box.y2);
const hits: string[] = [];
for (const el of elements) {
const b = elementBBox(el);
if (!b) continue;
if (mode === 'window') {
if (b.minX >= minX && b.maxX <= maxX && b.minY >= minY && b.maxY <= maxY) {
hits.push(el.id);
}
} else {
// crossing: Ueberschneidung (Beruehrung inklusive)
if (b.minX <= maxX && b.maxX >= minX && b.minY <= maxY && b.maxY >= minY) {
hits.push(el.id);
}
}
}
return hits;
}
/** BBox eines Elements (line aus Koordinaten, sonst x/y±w/2). */
function elementBBox(el: CADElement): { minX: number; minY: number; maxX: number; maxY: number } | null {
const props = el.properties as Record<string, unknown>;
if (
props.x1 !== undefined && props.y1 !== undefined &&
props.x2 !== undefined && props.y2 !== undefined
) {
const x1 = props.x1 as number, y1 = props.y1 as number;
const x2 = props.x2 as number, y2 = props.y2 as number;
return { minX: Math.min(x1, x2), minY: Math.min(y1, y2), maxX: Math.max(x1, x2), maxY: Math.max(y1, y2) };
}
const w = el.width ?? 0, h = el.height ?? 0;
return { minX: el.x - w / 2, minY: el.y - h / 2, maxX: el.x + w / 2, maxY: el.y + h / 2 };
}
/** Box-Drag-Sitzung (Task CAD-3). */
let boxDrag: { start: Pt; shift: boolean } | null = null;
/** Mindestgroesse (Welt-Einheiten) ab der ein Zug als Box gilt. */
const BOX_MIN_SIZE = 3;
/** Vertex-/Center-Drag-Sitzung (Task D9). */ /** Vertex-/Center-Drag-Sitzung (Task D9). */
let vertexDrag: { let vertexDrag: {
el: CADElement; el: CADElement;
@@ -200,6 +265,9 @@ export const selectTool: ToolExtensionV2 = {
const bridge = (ctx as FullToolContext).selection; const bridge = (ctx as FullToolContext).selection;
const c = ctx as FullToolContext; const c = ctx as FullToolContext;
if (!bridge) return; if (!bridge) return;
// CAD-3: verlorene Box-Sitzungen abräumen (defensive State-Hygiene,
// schützt auch vor verlorenen up-Events); wird unten bei Leerraum neu gesetzt.
boxDrag = null;
const additive = e.shift; const additive = e.shift;
const subtractive = e.ctrl; const subtractive = e.ctrl;
const hit = bridge.hitTest(e.world.x, e.world.y, 5); const hit = bridge.hitTest(e.world.x, e.world.y, 5);
@@ -243,7 +311,10 @@ export const selectTool: ToolExtensionV2 = {
} }
if (!hit) { if (!hit) {
if (!additive && !subtractive) bridge.setIds([]); // ── Task CAD-3: Leerraum-Klick startet Box-Select-Drag ──
// (statt sofort zu leeren; das Leeren passiert nur bei Klick OHNE Zug)
boxDrag = { start: e.world, shift: e.shift };
ctx.setStatus('Box-Auswahl: ziehen (rechts=fenster, links=kreuzend)');
return; return;
} }
let ids = new Set(bridge.getIds()); let ids = new Set(bridge.getIds());
@@ -258,6 +329,31 @@ export const selectTool: ToolExtensionV2 = {
}, },
move(e, ctx) { move(e, ctx) {
// ── Task CAD-3: Box-Select-Preview ──
if (boxDrag) {
const c = ctx as FullToolContext;
const x = Math.min(boxDrag.start.x, e.world.x);
const y = Math.min(boxDrag.start.y, e.world.y);
const w = Math.abs(e.world.x - boxDrag.start.x);
const h = Math.abs(e.world.y - boxDrag.start.y);
const mode = boxModeFor(boxDrag.start, e.world);
c.setPreview?.({
id: '__preview_boxselect__',
type: 'rect',
layerId: 'layer-0',
x: x + w / 2,
y: y + h / 2,
width: w,
height: h,
properties: {
stroke: mode === 'window' ? '#4a90d9' : '#22c55e',
strokeWidth: 1,
fill: mode === 'window' ? 'rgba(74,144,217,0.08)' : 'rgba(34,197,94,0.08)',
boxSelectMode: mode,
},
} as unknown as CADElement);
return;
}
// ── Task D9: Vertex-Drag-Preview (nur der Punkt aktualisiert) ── // ── Task D9: Vertex-Drag-Preview (nur der Punkt aktualisiert) ──
if (vertexDrag) { if (vertexDrag) {
const c = ctx as FullToolContext; const c = ctx as FullToolContext;
@@ -308,6 +404,40 @@ export const selectTool: ToolExtensionV2 = {
}, },
up(e, ctx) { up(e, ctx) {
// ── Task CAD-3: Box-Select-Abschluss ──
if (boxDrag) {
const c = ctx as FullToolContext;
const start = boxDrag.start;
const shift = boxDrag.shift || e.shift;
boxDrag = null;
ctx.setPreview?.(null);
const w = Math.abs(e.world.x - start.x);
const h = Math.abs(e.world.y - start.y);
// Winzige Box = Klick ohne Zug → Legacy: Selektion leeren
if (w < BOX_MIN_SIZE && h < BOX_MIN_SIZE) {
if (!shift) (c.selection ?? { setIds: () => {} }).setIds([]);
ctx.setStatus('Selektion geleert');
return;
}
const mode = boxModeFor(start, e.world);
const all = c.doc ? c.doc.getAllElements() : [];
const hits = boxSelectElements(all, {
x1: start.x, y1: start.y, x2: e.world.x, y2: e.world.y,
}, mode);
const bridge = (ctx as FullToolContext).selection;
if (!bridge) return;
if (shift) {
const merged = new Set([...bridge.getIds(), ...hits]);
bridge.setIds(Array.from(merged));
} else {
bridge.setIds(hits);
}
const count = shift
? new Set([...(bridge.getIds())]).size
: hits.length;
ctx.setStatus(`Box-Auswahl (${mode === 'window' ? 'Fenster' : 'Kreuzend'}): ${count} Element(e)`);
return;
}
// ── Task D9: Vertex-Commit (nur der Punkt, 1 Undo-Schritt) ── // ── Task D9: Vertex-Commit (nur der Punkt, 1 Undo-Schritt) ──
if (vertexDrag) { if (vertexDrag) {
const c = ctx as FullToolContext; const c = ctx as FullToolContext;
@@ -379,6 +509,7 @@ export const selectTool: ToolExtensionV2 = {
cancel(ctx) { cancel(ctx) {
handleDrag = null; handleDrag = null;
vertexDrag = null; vertexDrag = null;
boxDrag = null;
ctx.setPreview?.(null); ctx.setPreview?.(null);
}, },
}, },
@@ -394,8 +525,15 @@ export const hatchTool: ToolExtensionV2 = {
}, },
optionsSchema: [ optionsSchema: [
{ key: 'hatchPattern', label: 'Muster', type: 'select', options: [ { key: 'hatchPattern', label: 'Muster', type: 'select', options: [
{ value: 'lines', label: 'Linien' }, { value: 'ANSI31', label: 'ANSI31 (45°)' },
{ value: 'cross', label: 'Kreuz' }, { value: 'ANSI32', label: 'ANSI32 (Eisen)' },
{ value: 'ANSI37', label: 'ANSI37 (Beton)' },
{ value: 'NET', label: 'Netz' },
{ value: 'GRASS', label: 'Gras' },
{ value: 'DOTS', label: 'Punkte' },
{ value: 'SOLID', label: 'Voll' },
{ value: 'lines', label: 'Linien (alt)' },
{ value: 'cross', label: 'Kreuz (alt)' },
] }, ] },
{ key: 'hatchSpacing', label: 'Abstand', type: 'number', min: 2, max: 40 }, { key: 'hatchSpacing', label: 'Abstand', type: 'number', min: 2, max: 40 },
], ],
@@ -1577,6 +1715,152 @@ export const offsetTool: ToolExtensionV2 = {
}; };
/** V2-Plugin: Kern-Bearbeitungswerkzeuge. */ /** V2-Plugin: Kern-Bearbeitungswerkzeuge. */
// ──────────────────────────────────────────────────────────
// Task CAD-4: DIVIDE/MEASURE — Punkte entlang eines Pfads
// (Polyline) verteilen. DIVIDE: n gleiche Segmente → n+1 Punkte
// inkl. Enden. MEASURE: fester Abstand inkl. Start, Rest verworfen.
// Punkte = Konstruktionsmarker auf defpoints (1 Transaktion).
// ──────────────────────────────────────────────────────────
/**
* Punkte entlang eines Polylinien-Pfads (Task CAD-4, rein).
* segments > 0: DIVIDE — n+1 gleichmäßige Punkte inkl. Enden.
* fixedSpacing > 0: MEASURE — Punkte bei 0, spacing, 2*spacing, …
* solange s ≤ Gesamtlänge; Rest am Ende wird verworfen.
*/
export function pointsAlongPath(pts: Pt[], segments: number, fixedSpacing?: number): Pt[] {
if (!Array.isArray(pts) || pts.length < 2) return [];
let total = 0;
for (let i = 0; i < pts.length - 1; i++) {
total += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
}
if (total <= 0) return [...pts];
const out: Pt[] = [];
if (segments > 0) {
for (let i = 0; i <= segments; i++) {
out.push(pointAlongPolyline(pts, (total * i) / segments));
}
} else if (fixedSpacing && fixedSpacing > 0) {
for (let s = 0; s <= total + 1e-9; s += fixedSpacing) {
out.push(pointAlongPolyline(pts, s));
}
}
return out;
}
/** defpoints-Layer sicherstellen (CAD-4, lokal im core-modify). */
function ensureDefpoints(ctx: FullToolContext): void {
const doc = ctx.doc as unknown as {
getLayer(id: string): unknown;
addLayer(l: unknown): void;
} | undefined;
if (!doc || doc.getLayer('defpoints')) return;
doc.addLayer({
id: 'defpoints', name: 'defpoints', visible: true, locked: false,
color: '#8a8f98', lineType: 'dotted', transparency: 0.5, sortOrder: 999, parentId: null,
});
}
/** Pfad-Treffer validieren: muss polyline/polygon mit ≥2 Punkten sein. */
function requirePathHit(
c: FullToolContext,
wx: number,
wy: number,
): { pts: Pt[]; path: CADElement } | null {
if (!c.selection) return null;
const hit = c.selection.hitTest(wx, wy, 8);
if (!hit || (hit.type !== 'polyline' && hit.type !== 'polygon')) return null;
const pts = (hit.properties as Record<string, unknown>).points as Pt[] | undefined;
if (!pts || pts.length < 2) return null;
return { pts, path: hit };
}
/** Konstruktions-Marker-Punkt auf defpoints platzieren. */
function makePathMarker(p: Pt): CADElement {
return {
id: uid('pathmark'),
type: 'circle',
layerId: 'defpoints',
x: p.x,
y: p.y,
width: 3,
height: 3,
properties: { radius: 1.5, point: true, construction: true, stroke: '#8a8f98', strokeWidth: 1 },
} as unknown as CADElement;
}
export const divideTool: ToolExtensionV2 = {
manifest: {
id: 'divide',
label: 'Teilen',
icon: '÷',
ribbonTab: 'canvas',
tags: ['pro'],
description: 'Pfad in n gleiche Segmente teilen (DIVIDE, Task CAD-4)',
},
optionsSchema: [
{ key: 'count', label: 'Anzahl Segmente', type: 'number', min: 1, max: 100 },
],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc) return;
const found = requirePathHit(c, e.world.x, e.world.y);
if (!found) {
ctx.setStatus('Teilen: Polyline als Pfad anklicken');
return;
}
const segments = Math.max(1, (c.options.count as number | undefined) ?? 4);
ensureDefpoints(c);
const marks = pointsAlongPath(found.pts, segments);
c.doc.transact(() => {
for (const p of marks) c.doc!.addElement(makePathMarker(p));
});
ctx.setStatus(`Geteilt: ${marks.length} Punkte auf ${segments} Segmente`);
},
cancel(ctx) {
ctx.setPreview?.(null);
ctx.setStatus('Teilen abgebrochen');
},
},
};
export const measureTool: ToolExtensionV2 = {
manifest: {
id: 'measure',
label: 'Abmessen (Punkte)',
icon: '⋮',
ribbonTab: 'canvas',
tags: ['pro'],
description: 'Punkte im festen Abstand auf Pfad (MEASURE, Task CAD-4)',
},
optionsSchema: [
{ key: 'spacing', label: 'Abstand', type: 'number', min: 1 },
],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc) return;
const found = requirePathHit(c, e.world.x, e.world.y);
if (!found) {
ctx.setStatus('Abmessen: Polyline als Pfad anklicken');
return;
}
const spacing = Math.max(1, (c.options.spacing as number | undefined) ?? 20);
ensureDefpoints(c);
const marks = pointsAlongPath(found.pts, 0, spacing);
c.doc.transact(() => {
for (const p of marks) c.doc!.addElement(makePathMarker(p));
});
ctx.setStatus(`Abgemessen: ${marks.length} Punkte alle ${spacing} Einheiten`);
},
cancel(ctx) {
ctx.setPreview?.(null);
ctx.setStatus('Abmessen abgebrochen');
},
},
};
export const coreModifyPlugin: PluginV2 = { export const coreModifyPlugin: PluginV2 = {
manifest: { manifest: {
id: 'core-modify', id: 'core-modify',
@@ -1587,5 +1871,5 @@ export const coreModifyPlugin: PluginV2 = {
category: 'tools', category: 'tools',
enabledByDefault: true, enabledByDefault: true,
}, },
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool, arrayPathTool], tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool, arrayPathTool, divideTool, measureTool],
}; };
+147 -13
View File
@@ -15,6 +15,8 @@
* - arc-Winkel in Grad, 0° = 3 Uhr, gegen den Uhrzeigersinn * - arc-Winkel in Grad, 0° = 3 Uhr, gegen den Uhrzeigersinn
*/ */
import type { CADElement } from '../../types/cad.types'; import type { CADElement } from '../../types/cad.types';
import { getDimStyle } from '../../services/dimStyleService';
import { generateHatchLines, computeBoundary, getHatchPattern } from '../../services/hatchPatternService';
import type { Pt } from '../../tools/modification/geometry'; import type { Pt } from '../../tools/modification/geometry';
/** Zeichenprimitiv — kleinste darstellbare Einheit. */ /** Zeichenprimitiv — kleinste darstellbare Einheit. */
@@ -42,6 +44,85 @@ function fillOrNull(raw: unknown): string | null {
* Gibt ein leeres Array zurück für unbekannte Typen bzw. Elemente, * Gibt ein leeres Array zurück für unbekannte Typen bzw. Elemente,
* die nichts Sichtbares beitragen (z.B. leerer Text). * die nichts Sichtbares beitragen (z.B. leerer Text).
*/ */
/**
* Pfeilspitze als geschlossenes Polygon (Task CAD-1).
* Spitze bei (x,y), Richtung ang (rad), Laenge size.
*/
function arrowPrimitives(x: number, y: number, ang: number, size: number, color: string): ShapePrimitive[] {
const halfW = size * 0.35;
const bx = x - size * Math.cos(ang);
const by = y - size * Math.sin(ang);
const px = -Math.sin(ang) * halfW;
const py = Math.cos(ang) * halfW;
return [{
kind: 'polyline',
points: [
{ x, y },
{ x: bx + px, y: by + py },
{ x: bx - px, y: by - py },
],
close: true,
stroke: color,
strokeWidth: 1,
}];
}
/**
* Task CAD-2: Hatch-Linien fuer ein Element mit hatch-Properties.
* Nutzt hatchPatternService.generateHatchLines mit Boundary aus der
* Element-Kontur. Ohne Muster: leeres Array.
*/
function hatchPrimitives(el: CADElement, stroke: string): ShapePrimitive[] {
const props = el.properties as Record<string, unknown>;
if (props.hatch !== true) return [];
const patternId = (props.hatchPattern as string | undefined) ?? 'ANSI31';
if (!getHatchPattern(patternId)) return [];
const spacing = (props.hatchSpacing as number | undefined) ?? 8;
// Boundary aus Kontur je Elementtyp
let bbox: { minX: number; minY: number; maxX: number; maxY: number } | null = null;
if (Array.isArray(props.points)) {
bbox = computeBoundary(props.points as Array<{ x: number; y: number }>);
} else if (el.type === 'circle' || el.type === 'arc') {
const r = (props.radius as number | undefined) ?? el.width / 2;
bbox = { minX: el.x - r, minY: el.y - r, maxX: el.x + r, maxY: el.y + r };
} else {
bbox = {
minX: el.x - el.width / 2, minY: el.y - el.height / 2,
maxX: el.x + el.width / 2, maxY: el.y + el.height / 2,
};
}
if (!bbox) return [];
const segs = generateHatchLines(patternId, bbox, spacing);
const out: ShapePrimitive[] = [];
for (const s of segs) {
if (s.fill) {
const rectPrim: ShapePrimitive = {
kind: 'rect',
x: (bbox.minX + bbox.maxX) / 2,
y: (bbox.minY + bbox.maxY) / 2,
w: bbox.maxX - bbox.minX,
h: bbox.maxY - bbox.minY,
fill: s.fill,
stroke,
strokeWidth: 1,
};
out.push(rectPrim);
} else {
const linePrim: ShapePrimitive = {
kind: 'line',
from: s.from,
to: s.to,
stroke,
strokeWidth: 0.5,
};
out.push(linePrim);
}
}
return out;
}
export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: string; prompt: string; defaultValue?: string }>): ShapePrimitive[] { export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: string; prompt: string; defaultValue?: string }>): ShapePrimitive[] {
const props = (el.properties ?? {}) as Record<string, unknown>; const props = (el.properties ?? {}) as Record<string, unknown>;
const stroke = (props.stroke as string | undefined) ?? FALLBACK_STROKE; const stroke = (props.stroke as string | undefined) ?? FALLBACK_STROKE;
@@ -66,7 +147,7 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri
case 'rect': { case 'rect': {
// Legacy-BBox-Konvention: x/y ist das Zentrum des Rechtecks // Legacy-BBox-Konvention: x/y ist das Zentrum des Rechtecks
return [{ const base: ShapePrimitive[] = [{
kind: 'rect', kind: 'rect',
x: el.x - el.width / 2, x: el.x - el.width / 2,
y: el.y - el.height / 2, y: el.y - el.height / 2,
@@ -76,11 +157,12 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri
stroke, stroke,
strokeWidth: sw, strokeWidth: sw,
}]; }];
return [...base, ...hatchPrimitives(el, stroke)];
} }
case 'circle': { case 'circle': {
const radius = (props.radius as number | undefined) ?? el.width / 2; const radius = (props.radius as number | undefined) ?? el.width / 2;
return [{ const circBase: ShapePrimitive[] = [{
kind: 'circle', kind: 'circle',
center: { x: el.x, y: el.y }, center: { x: el.x, y: el.y },
radius, radius,
@@ -88,6 +170,7 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri
stroke, stroke,
strokeWidth: sw, strokeWidth: sw,
}]; }];
return [...circBase, ...hatchPrimitives(el, stroke)];
} }
case 'arc': { case 'arc': {
@@ -109,13 +192,14 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri
? (props.points as unknown[]).map(asPoint) ? (props.points as unknown[]).map(asPoint)
: []; : [];
if (pts.length < 2) return []; if (pts.length < 2) return [];
return [{ const polyBase: ShapePrimitive[] = [{
kind: 'polyline', kind: 'polyline',
points: pts, points: pts,
close: el.type === 'polygon', close: el.type === 'polygon',
stroke, stroke,
strokeWidth: sw, strokeWidth: sw,
}]; }];
return [...polyBase, ...hatchPrimitives(el, stroke)];
} }
case 'text': { case 'text': {
@@ -133,25 +217,75 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri
// ── Task C3: bisher im Pixi-Pfad fehlende Typen ── // ── Task C3: bisher im Pixi-Pfad fehlende Typen ──
case 'dimension': { case 'dimension': {
// Legacy-Konvention: x1..y2 in properties, BBox-Zentrum als x/y, // Task CAD-1: komplette Bemassung mit Extension lines, Masslinie,
// Extension lines + Hauptlinie; Pfeilspitzen sind Detail (C3+) // Pfeilspitzen (geschlossene Polygone) und Wert-Text nach DimStyle.
const dimType = (props.dimType as string | undefined) ?? 'linear';
const style = getDimStyle(props.dimStyle as string | undefined);
const dimColor = (props.dimColor as string | undefined) ?? (props.color as string | undefined) ?? '#aaaaaa';
const label = String(props.value ?? props.text ?? '');
if (dimType === 'radius' || dimType === 'diameter') {
const cx = (props.centerX as number | undefined) ?? el.x;
const cy = (props.centerY as number | undefined) ?? el.y;
const ax = (props.arrowX as number | undefined) ?? cx + el.width / 2;
const ay = (props.arrowY as number | undefined) ?? cy;
const out: ShapePrimitive[] = [
{ kind: 'line', from: { x: cx, y: cy }, to: { x: ax, y: ay }, stroke: dimColor, strokeWidth: 1 },
];
out.push(...arrowPrimitives(ax, ay, Math.atan2(ay - cy, ax - cx), style.arrowSize, dimColor));
if (label) {
out.push({ kind: 'text', at: { x: (cx + ax) / 2, y: (cy + ay) / 2 - style.textHeight * 0.6 }, text: label, fontSize: style.textHeight * 4, color: dimColor });
}
return out;
}
if (dimType === 'angular') {
const vx = (props.vertexX as number | undefined) ?? el.x;
const vy = (props.vertexY as number | undefined) ?? el.y;
const l1x = (props.leg1X as number | undefined) ?? vx + 100;
const l1y = (props.leg1Y as number | undefined) ?? vy;
const l2x = (props.leg2X as number | undefined) ?? vx;
const l2y = (props.leg2Y as number | undefined) ?? vy + 100;
const out: ShapePrimitive[] = [
{ kind: 'line', from: { x: vx, y: vy }, to: { x: l1x, y: l1y }, stroke: dimColor, strokeWidth: 1 },
{ kind: 'line', from: { x: vx, y: vy }, to: { x: l2x, y: l2y }, stroke: dimColor, strokeWidth: 1 },
];
if (label) {
out.push({ kind: 'text', at: { x: vx + 30, y: vy - 10 }, text: label, fontSize: style.textHeight * 4, color: dimColor });
}
return out;
}
// linear / aligned
const x1 = (props.x1 as number | undefined) ?? el.x - el.width / 2; const x1 = (props.x1 as number | undefined) ?? el.x - el.width / 2;
const y1 = (props.y1 as number | undefined) ?? el.y; const y1 = (props.y1 as number | undefined) ?? el.y;
const x2 = (props.x2 as number | undefined) ?? el.x + el.width / 2; const x2 = (props.x2 as number | undefined) ?? el.x + el.width / 2;
const y2 = (props.y2 as number | undefined) ?? el.y; const y2 = (props.y2 as number | undefined) ?? el.y;
const color = (props.color as string | undefined) ?? '#aaaaaa'; const subtype = (props.dimSubtype as string | undefined) ?? 'horizontal';
const dimColor = (props.dimColor as string | undefined) ?? color;
const label = String(props.value ?? props.text ?? ''); // Masslinie: bei horizontal bei y = min(y1,y2) - offset; vertical bei x analog
const off = 15;
let mx1 = x1, my1 = y1, mx2 = x2, my2 = y2;
if (subtype === 'horizontal') { my1 = Math.min(y1, y2) - off; my2 = my1; }
else if (subtype === 'vertical') { mx1 = Math.min(x1, x2) - off; mx2 = mx1; }
else { /* aligned: Masslinie = Punkte-Verbindung */ }
const out: ShapePrimitive[] = [ const out: ShapePrimitive[] = [
{ kind: 'line', from: { x: x1, y: y1 }, to: { x: x2, y: y2 }, stroke: dimColor, strokeWidth: 1 }, // Extension lines
{ kind: 'line', from: { x: x1, y: y1 }, to: { x: x1, y: my1 }, stroke: '#888888', strokeWidth: 0.5 },
{ kind: 'line', from: { x: x2, y: y2 }, to: { x: x2, y: my2 }, stroke: '#888888', strokeWidth: 0.5 },
// Masslinie
{ kind: 'line', from: { x: mx1, y: my1 }, to: { x: mx2, y: my2 }, stroke: dimColor, strokeWidth: 1 },
]; ];
// Pfeilspitzen an beiden Enden der Masslinie
const ang1 = Math.atan2(my2 - my1, mx2 - mx1);
out.push(...arrowPrimitives(mx1, my1, ang1, style.arrowSize, dimColor));
out.push(...arrowPrimitives(mx2, my2, ang1 + Math.PI, style.arrowSize, dimColor));
if (label) { if (label) {
out.push({ out.push({
kind: 'text', kind: 'text',
at: { x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 12 }, at: { x: (mx1 + mx2) / 2, y: (my1 + my2) / 2 - style.textHeight * 0.6 },
text: label, text: label, fontSize: style.textHeight * 4, color: dimColor,
fontSize: 10,
color: dimColor,
}); });
} }
return out; return out;
+110
View File
@@ -0,0 +1,110 @@
/**
* Task CAD-1 Bemassungsstile (DimStyles) + assoziative Wert-Berechnung.
*
* DimStyle: ISO-artige Stile (Text-/Pfeilhoehe, Einheit, Dezimalstellen,
* Pfeilstil, Linien-Offsets). computeDimValue berechnet den Masswert
* aus der Geometrie (assoziativ), formatDimValue erzeugt den Text.
*/
import type { CADElement } from '../types/cad.types';
import type { Pt } from '../tools/modification/geometry';
export type DimTypeName = 'linear' | 'aligned' | 'angular' | 'radius' | 'diameter';
export interface DimStyle {
name: string;
/** Text-Hoehe in Welt-Einheiten. */
textHeight: number;
/** Pfeil-Laenge in Welt-Einheiten. */
arrowSize: number;
unit: 'mm' | 'cm' | 'm';
decimals: number;
arrowStyle: 'closed' | 'open' | 'dot' | 'tick';
/** Offset Extension-Linie zu Messpunkt. */
extOffset: number;
/** Ueberstand der Masslinie ueber Pfeile. */
overshoot: number;
}
export const DEFAULT_DIM_STYLE: DimStyle = {
name: 'ISO-25',
textHeight: 2.5,
arrowSize: 2.5,
unit: 'mm',
decimals: 0,
arrowStyle: 'closed',
extOffset: 0.625,
overshoot: 1.25,
};
export const DIM_STYLES: DimStyle[] = [
DEFAULT_DIM_STYLE,
{ ...DEFAULT_DIM_STYLE, name: 'ISO-50', textHeight: 5, arrowSize: 5 },
{ ...DEFAULT_DIM_STYLE, name: 'Architectural', textHeight: 3, arrowSize: 2, arrowStyle: 'tick' },
{ ...DEFAULT_DIM_STYLE, name: 'Engineering', textHeight: 2.5, arrowSize: 2.5, decimals: 2 },
];
/** Stil per Name (Fallback: Default). */
export function getDimStyle(name: string | undefined): DimStyle {
if (!name) return DEFAULT_DIM_STYLE;
return DIM_STYLES.find((s) => s.name === name) ?? DEFAULT_DIM_STYLE;
}
/**
* Assoziative Masswert-Berechnung aus der Geometrie.
* linear: |dx| (horizontal) bzw. |dy| (vertikal) — der Aufrufer waehlt
* die Achse; hier wird die Gesamt-Projektion geliefert und das
* Tool waehlt horizontal/vertikal anhand dimSubtype.
* aligned: Distanz zwischen p1 und p2.
* angular: Winkel zwischen zwei Schenkeln am Scheitel (Grad).
* radius/diameter: aus einem Kreis-Element.
*/
export function computeDimValue(
type: DimTypeName,
p1?: Pt,
p2?: Pt,
element?: CADElement,
subtype?: string,
): number {
if (type === 'linear' && p1 && p2) {
if (subtype === 'vertical') return Math.abs(p2.y - p1.y);
if (subtype === 'horizontal') return Math.abs(p2.x - p1.x);
// Ohne explizites Subtype: dominante Achse (CAD-Standardverhalten)
return Math.abs(p2.x - p1.x) >= Math.abs(p2.y - p1.y)
? Math.abs(p2.x - p1.x)
: Math.abs(p2.y - p1.y);
}
if (type === 'aligned' && p1 && p2) {
return Math.hypot(p2.x - p1.x, p2.y - p1.y);
}
if (type === 'radius' && element) {
const r = (element.properties as Record<string, unknown>).radius as number | undefined;
return r ?? element.width / 2;
}
if (type === 'diameter' && element) {
const r = (element.properties as Record<string, unknown>).radius as number | undefined;
return (r ?? element.width / 2) * 2;
}
if (type === 'angular' && p1 && p2) {
const ang1 = Math.atan2(p1.y, p1.x);
const ang2 = Math.atan2(p2.y, p2.x);
let diff = Math.abs(ang2 - ang1) * 180 / Math.PI;
if (diff > 180) diff = 360 - diff;
return diff;
}
return 0;
}
/** Formatiert einen Masswert nach DimStyle (mit Typ-Praefix). */
export function formatDimValue(
value: number,
style: DimStyle,
type?: DimTypeName,
): string {
const num = value.toFixed(style.decimals);
switch (type) {
case 'radius': return `R${num}`;
case 'diameter': return `\u2300 ${num}`;
case 'angular': return `${num}\u00b0`;
default: return num;
}
}
@@ -0,0 +1,154 @@
/**
* Task CAD-2 Echte Hatch-Muster + Boundary-Erkennung.
*
* Standard-CAD-Schraffurmuster als Linien-Segment-Generatoren für
* eine BBox. computeBoundary liefert die umschlossene Fläche aus
* einer Kontur (Polygon-Punkte).
*/
export interface HatchSeg {
from: { x: number; y: number };
to: { x: number; y: number };
fill?: string;
closed?: boolean;
points?: Array<{ x: number; y: number }>;
}
export interface HatchPattern {
id: string;
label: string;
description: string;
defaultSpacing: number;
/** 45°-Ersatzwinkel für Linienfamilien (Grad). */
angles: number[];
/** Punkte-Muster (DOTS) statt Linien. */
dots?: boolean;
/** Vollflächige Füllung. */
solid?: boolean;
}
export type HatchPatternId = string;
export const HATCH_PATTERNS: HatchPattern[] = [
{ id: 'lines', label: 'Linien (45°)', description: 'Einfache 45°-Schraffur', defaultSpacing: 8, angles: [45] },
{ id: 'cross', label: 'Kreuz', description: 'Kreuzschraffur 45°/-45°', defaultSpacing: 8, angles: [45, -45] },
{ id: 'ANSI31', label: 'ANSI31 (45°)', description: 'ANSI-Standard 45°', defaultSpacing: 6, angles: [45] },
{ id: 'ANSI32', label: 'ANSI32 (Eisen)', description: 'ANSI Eisen 45°/-45°', defaultSpacing: 6, angles: [45, -45] },
{ id: 'ANSI37', label: 'ANSI37 (Beton)', description: 'ANSI Beton 45°/-45° dicht', defaultSpacing: 3, angles: [45, -45] },
{ id: 'NET', label: 'Netz', description: 'Horizontal + vertikal', defaultSpacing: 10, angles: [0, 90] },
{ id: 'GRASS', label: 'Gras', description: 'Kurze Gras-Striche', defaultSpacing: 6, angles: [75] },
{ id: 'DOTS', label: 'Punkte', description: 'Punktschraffur', defaultSpacing: 8, angles: [], dots: true },
{ id: 'SOLID', label: 'Voll', description: 'Vollflächige Füllung', defaultSpacing: 0, angles: [], solid: true },
];
/** Muster per ID (oder undefined). */
export function getHatchPattern(id: string | undefined): HatchPattern | undefined {
if (!id) return undefined;
return HATCH_PATTERNS.find((p) => p.id === id);
}
/**
* Erzeugt Schraffur-Segmente für eine BBox nach Muster.
* SOLID liefert EIN Segment mit fill-Flag; DOTS liefert Punkte-Muster.
*/
export function generateHatchLines(
patternId: string,
bbox: { minX: number; minY: number; maxX: number; maxY: number },
spacing: number,
): HatchSeg[] {
const pattern = getHatchPattern(patternId);
if (!pattern) return [];
const { minX, minY, maxX, maxY } = bbox;
const w = maxX - minX;
const h = maxY - minY;
if (pattern.solid) {
return [{ from: { x: minX, y: minY }, to: { x: maxX, y: maxY }, fill: '#888888' }];
}
if (pattern.dots) {
const out: HatchSeg[] = [];
for (let y = minY + spacing / 2; y < maxY; y += spacing) {
for (let x = minX + spacing / 2; x < maxX; x += spacing) {
out.push({
from: { x, y },
to: { x: x + 1, y: y + 1 },
points: [{ x, y }],
});
}
}
return out;
}
// Linienfamilien je Winkel
const out: HatchSeg[] = [];
for (const angleDeg of pattern.angles) {
const rad = (angleDeg * Math.PI) / 180;
const cosA = Math.cos(rad);
const sinA = Math.sin(rad);
const diag = Math.hypot(w, h);
const step = spacing;
const offsetCount = Math.ceil(diag / step) + 2;
for (let i = -offsetCount; i <= offsetCount; i++) {
const cx = minX + w / 2 + i * step;
const cy = minY + h / 2;
// Linie durch (cx,cy) in Richtung (cosA, sinA), begrenzt auf BBox
const p1 = { x: cx - cosA * diag, y: cy - sinA * diag };
const p2 = { x: cx + cosA * diag, y: cy + sinA * diag };
// Clipping an BBox (einfaches Cohen-Sutherland)
const cl = clipToBBox(p1, p2, bbox);
if (cl) out.push({ from: cl[0], to: cl[1] });
}
}
return out;
}
/** Cohen-Sutherland-artiges Clipping an BBox (vereinfacht). */
function clipToBBox(
p1: { x: number; y: number },
p2: { x: number; y: number },
b: { minX: number; minY: number; maxX: number; maxY: number },
): Array<{ x: number; y: number }> | null {
// Liang-Barsky
let t0 = 0, t1 = 1;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const clip = (p: number, q: number): boolean => {
if (p === 0) return q >= 0;
const r = q / p;
if (p < 0) {
if (r > t1) return false;
if (r > t0) t0 = r;
} else {
if (r < t0) return false;
if (r < t1) t1 = r;
}
return true;
};
if (!clip(-dx, p1.x - b.minX)) return null;
if (!clip(dx, b.maxX - p1.x)) return null;
if (!clip(-dy, p1.y - b.minY)) return null;
if (!clip(dy, b.maxY - p1.y)) return null;
return [
{ x: p1.x + t0 * dx, y: p1.y + t0 * dy },
{ x: p1.x + t1 * dx, y: p1.y + t1 * dy },
];
}
/**
* Boundary-Erkennung: BBox aus einer Kontur (Polygon-Punkte).
* Weniger als 2 Punkte → null.
*/
export function computeBoundary(
pts: Array<{ x: number; y: number }>,
): { minX: number; minY: number; maxX: number; maxY: number } | null {
if (!Array.isArray(pts) || pts.length < 2) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of pts) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
return { minX, minY, maxX, maxY };
}
+194
View File
@@ -0,0 +1,194 @@
/**
* Task CAD-3 - Fenster-/Kreuz-Selektion (Box-Select).
*
* boxSelectElements: reine Funktion. Links-nach-rechts ziehen =
* 'window' (Element muss VOLLSTAENDIG in der Box liegen),
* rechts-nach-links = 'crossing' (Element darf die Box beruehren).
* Im selectTool: Leerer down startet Box-Drag, up selektiert.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { boxSelectElements, boxModeFor } from '../src/plugins/builtin/core-modify';
import { selectTool } from '../src/plugins/builtin/core-modify';
import { CADDocument } from '../src/kernel/document/CADDocument';
import { createInMemoryDoc } from './helpers/inMemoryDoc';
import type { CADElement, Pt } from '../src/types/cad.types';
function mkCtx(elements: CADElement[]) {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
for (const el of elements) doc.addElement(el);
const selectedIds = new Set<string>();
const previews: (CADElement | null)[] = [];
const statuses: string[] = [];
const ctx: any = {
doc,
options: {},
setStatus: (m: string) => statuses.push(m),
setPreview: (el: CADElement | null) => previews.push(el),
selection: {
getIds: () => Array.from(selectedIds),
setIds: (ids: string[]) => {
selectedIds.clear();
for (const id of ids) selectedIds.add(id);
},
hitTest: (x: number, y: number, _tol: number) => {
for (const el of elements) {
const p = el.properties as Record<string, unknown>;
if (p.x1 !== undefined) {
const minX = Math.min(p.x1 as number, p.x2 as number);
const maxX = Math.max(p.x1 as number, p.x2 as number);
const minY = Math.min(p.y1 as number, p.y2 as number);
const maxY = Math.max(p.y1 as number, p.y2 as number);
if (x >= minX - 5 && x <= maxX + 5 && y >= minY - 5 && y <= maxY + 5) return el;
}
if (
x >= el.x - el.width / 2 && x <= el.x + el.width / 2 &&
y >= el.y - el.height / 2 && y <= el.y + el.height / 2
) return el;
}
return null;
},
},
};
return { ctx, doc, selectedIds, previews, statuses };
}
function pe(x: number, y: number, mods: { shift?: boolean } = {}): { world: Pt; shift: boolean; ctrl: boolean } {
return { world: { x, y }, shift: mods.shift ?? false, ctrl: false } as never;
}
function rect(id: string, x: number, y: number, w: number, h: number): CADElement {
return { id, type: 'rect', layerId: 'l', x, y, width: w, height: h, properties: {} } 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;
}
beforeEach(() => {
selectTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
});
// ─── boxModeFor: Richtung bestimmt Modus ──────────────────────
describe('CAD-3: boxModeFor', () => {
it('Links-nach-rechts = window', () => {
expect(boxModeFor({ x: 0, y: 0 }, { x: 100, y: 100 })).toBe('window');
});
it('Rechts-nach-links = crossing', () => {
expect(boxModeFor({ x: 100, y: 0 }, { x: 0, y: 100 })).toBe('crossing');
});
});
// ─── boxSelectElements: window (vollstaendig enthalten) ─────
describe('CAD-3: window-Selektion (vollstaendig enthalten)', () => {
it('Element komplett in Box wird selektiert', () => {
const els = [rect('r1', 50, 50, 20, 20)];
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
const hit = boxSelectElements(els, box, 'window');
expect(hit).toContain('r1');
});
it('Element, das die Box-Kante durchstoesst, wird NICHT selektiert (window)', () => {
// rect 90..110, Box 0..100: ragt raus
const els = [rect('r1', 100, 50, 20, 20)];
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
const hit = boxSelectElements(els, box, 'window');
expect(hit).not.toContain('r1');
});
it('Line mit beiden Endpunkten in Box wird selektiert', () => {
const els = [line('l1', 10, 10, 80, 80)];
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
expect(boxSelectElements(els, box, 'window')).toContain('l1');
});
});
// ─── boxSelectElements: crossing (Beruehrung reicht) ─────────
describe('CAD-3: crossing-Selektion (Beruehrung reicht)', () => {
it('Element, das die Box nur teilweise durchstoesst, WIRD selektiert', () => {
const els = [rect('r1', 100, 50, 20, 20)]; // 90..110
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
const hit = boxSelectElements(els, box, 'crossing');
expect(hit).toContain('r1');
});
it('Element ausserhalb der Box wird nicht selektiert', () => {
const els = [rect('r1', 500, 500, 20, 20)];
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
expect(boxSelectElements(els, box, 'crossing')).not.toContain('r1');
});
it('Element, das die Box nur beruehrt (Kante = Kante), wird selektiert', () => {
// rect endet exakt bei x=100 (Kante = Box-Kante)
const els = [rect('r1', 95, 50, 10, 20)]; // 90..100
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
expect(boxSelectElements(els, box, 'crossing')).toContain('r1');
});
});
// ─── Tool-Integration: Box-Drag im selectTool ────────────────
describe('CAD-3: selectTool Box-Drag', () => {
it('Leerer down + move + up fuehrt Box-Selektion aus (window: links nach rechts)', () => {
const inside = rect('r1', 50, 50, 20, 20);
const outside = rect('r2', 500, 500, 20, 20);
const { ctx, selectedIds, previews } = mkCtx([inside, outside]);
// down auf Leerraum (kein Element getroffen)
selectTool.handlers.down!(pe(0, 0), ctx);
// move zieht Box auf
selectTool.handlers.move!(pe(100, 100), ctx);
expect(previews.length).toBeGreaterThanOrEqual(1); // Box-Preview
// up schliesst ab
selectTool.handlers.up!(pe(100, 100), ctx);
expect(selectedIds.has('r1')).toBe(true);
expect(selectedIds.has('r2')).toBe(false);
});
it('Klick OHNE Zug (winzige Box) leert die Selektion (Legacy-Verhalten bleibt)', () => {
const inside = rect('r1', 50, 50, 20, 20);
const { ctx, selectedIds } = mkCtx([inside]);
selectedIds.add('r1');
// down + up am selben Punkt = kein Zug
selectTool.handlers.down!(pe(300, 300), ctx);
selectTool.handlers.up!(pe(300, 300), ctx);
expect(Array.from(selectedIds)).toHaveLength(0);
});
it('Shift + Box-Select addiert zur bestehenden Selektion', () => {
const a = rect('a', 50, 50, 20, 20);
const b = rect('b', 200, 50, 20, 20);
const { ctx, selectedIds } = mkCtx([a, b]);
selectedIds.add('a'); // schon selektiert
// Shift-Box um b (Shift an down UND up, wie der echte Dispatcher)
selectTool.handlers.down!(pe(150, 0, { shift: true }), ctx);
selectTool.handlers.up!(pe(250, 100, { shift: true }), ctx);
expect(selectedIds.has('a')).toBe(true);
expect(selectedIds.has('b')).toBe(true);
});
it('Status meldet Anzahl und Modus', () => {
const els = [rect('r1', 50, 50, 20, 20), rect('r2', 60, 60, 20, 20)];
const { ctx, statuses } = mkCtx(els);
selectTool.handlers.down!(pe(0, 0), ctx);
selectTool.handlers.up!(pe(100, 100), ctx);
const last = statuses[statuses.length - 1];
expect(last).toContain('2');
});
it('Box-Drag startet NICHT wenn ein Element getroffen wurde', () => {
const el = rect('r1', 50, 50, 20, 20);
const { ctx, selectedIds } = mkCtx([el]);
// down AUF das Element: normale Einzelselektion, kein Box-Start
selectTool.handlers.down!(pe(50, 50), ctx);
expect(selectedIds.has('r1')).toBe(true);
});
});
+313
View File
@@ -0,0 +1,313 @@
/**
* Task CAD-1 - Assoziative Bemaessung: DimStyles + 5 Typen.
*
* DimStyle: textHeight/arrowSize/unit/decimals/arrowStyle/offset.
* computeDimValue: berechnet Masswert aus Geometrie (assoziativ).
* Tools: dimLinear (H/V/aligned), dimAngular, dimRadius, dimDiameter.
* RenderAdapter: komplette Primitives mit Extension lines, Masslinien,
* Pfeilen und Text.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
DEFAULT_DIM_STYLE,
DIM_STYLES,
getDimStyle,
computeDimValue,
formatDimValue,
type DimStyle,
} from '../src/services/dimStyleService';
import {
dimLinearTool,
dimAlignedTool,
dimAngularTool,
dimRadiusTool,
dimDiameterTool,
} from '../src/plugins/builtin/core-annotate';
import { CADDocument } from '../src/kernel/document/CADDocument';
import { createInMemoryDoc } from './helpers/inMemoryDoc';
import { elementToPrimitives } from '../src/render/pixi/elementDrawers';
import type { CADElement, Pt } from '../src/types/cad.types';
function mkCtx(options: Record<string, unknown> = {}) {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
const statuses: string[] = [];
const previews: (CADElement | null)[] = [];
const ctx: any = {
doc,
options,
setStatus: (m: string) => statuses.push(m),
setPreview: (el: CADElement | null) => previews.push(el),
};
return { ctx, doc, statuses, previews };
}
function pe(x: number, y: number): { world: Pt } {
return { world: { x, y } } as never;
}
beforeEach(() => {
for (const t of [dimLinearTool, dimAlignedTool, dimAngularTool, dimRadiusTool, dimDiameterTool]) {
t.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
}
});
// ─── DimStyle-System ─────────────────────────────────────────
describe('CAD-1: DimStyles', () => {
it('DEFAULT_DIM_STYLE ist ISO-konform', () => {
expect(DEFAULT_DIM_STYLE.name).toBe('ISO-25');
expect(DEFAULT_DIM_STYLE.textHeight).toBe(2.5);
expect(DEFAULT_DIM_STYLE.arrowSize).toBe(2.5);
expect(DEFAULT_DIM_STYLE.unit).toBe('mm');
expect(DEFAULT_DIM_STYLE.decimals).toBe(0);
expect(DEFAULT_DIM_STYLE.arrowStyle).toBe('closed');
});
it('DIM_STYLES enthaelt Standard-Set (ISO-25, ISO-50, Architectural)', () => {
expect(DIM_STYLES.map((s) => s.name)).toContain('ISO-25');
expect(DIM_STYLES.map((s) => s.name)).toContain('ISO-50');
expect(DIM_STYLES.map((s) => s.name)).toContain('Architectural');
});
it('getDimStyle liefert Default fuer unbekannten Namen', () => {
expect(getDimStyle('gibts-nicht')).toBe(DEFAULT_DIM_STYLE);
expect(getDimStyle('ISO-50')!.textHeight).toBe(5);
});
});
// ─── computeDimValue: Assoziative Wert-Berechnung ───────────
describe('CAD-1: computeDimValue', () => {
it('linear horizontal: |x2-x1|', () => {
expect(computeDimValue('linear', { x: 0, y: 0 }, { x: 100, y: 50 })).toBe(100);
});
it('linear vertikal: |y2-y1|', () => {
expect(computeDimValue('linear', { x: 0, y: 0 }, { x: 50, y: 200 })).toBe(200);
});
it('aligned: Distanz zwischen Punkten', () => {
expect(computeDimValue('aligned', { x: 0, y: 0 }, { x: 30, y: 40 })).toBe(50);
});
it('radius: r aus Kreis-Element', () => {
const circle = { x: 0, y: 0, properties: { radius: 25 } } as unknown as CADElement;
expect(computeDimValue('radius', undefined, undefined, circle)).toBe(25);
});
it('diameter: 2*r aus Kreis-Element', () => {
const circle = { x: 0, y: 0, properties: { radius: 25 } } as unknown as CADElement;
expect(computeDimValue('diameter', undefined, undefined, circle)).toBe(50);
});
});
// ─── formatDimValue ──────────────────────────────────────────
describe('CAD-1: formatDimValue', () => {
it('mm ohne Dezimalstellen bei decimals=0', () => {
expect(formatDimValue(123.7, DEFAULT_DIM_STYLE)).toBe('124');
});
it('mm mit 2 Dezimalstellen', () => {
const s = { ...DEFAULT_DIM_STYLE, decimals: 2 };
expect(formatDimValue(123.456, s)).toBe('123.46');
});
it('Radius-Präfix bei radius-Typ', () => {
expect(formatDimValue(25, DEFAULT_DIM_STYLE, 'radius')).toBe('R25');
});
it('Durchmesser-Symbol bei diameter-Typ', () => {
expect(formatDimValue(50, DEFAULT_DIM_STYLE, 'diameter')).toBe('\u2300 50');
});
it('Grad bei angular', () => {
expect(formatDimValue(45, DEFAULT_DIM_STYLE, 'angular')).toBe('45\u00b0');
});
});
// ─── dimLinearTool ─────────────────────────────────────────
describe('CAD-1: dimLinearTool', () => {
it('2 Klicks erzeugen dimension mit dimType, berechnetem value und Style', () => {
const { ctx, doc } = mkCtx({ dimStyle: 'ISO-25', dimSubtype: 'horizontal' });
dimLinearTool.handlers.down!(pe(0, 0), ctx);
dimLinearTool.handlers.down!(pe(100, 50), ctx);
const els = doc.getAllElements();
expect(els).toHaveLength(1);
const el = els[0];
expect(el.type).toBe('dimension');
expect(el.properties.dimType).toBe('linear');
expect(el.properties.dimSubtype).toBe('horizontal');
expect(el.properties.value).toBe('100'); // |dx| = 100
expect(el.properties.dimStyle).toBe('ISO-25');
});
it('vertikal: |dy| wird gemessen', () => {
const { ctx, doc } = mkCtx({ dimSubtype: 'vertical' });
dimLinearTool.handlers.down!(pe(10, 0), ctx);
dimLinearTool.handlers.down!(pe(20, 200), ctx);
expect(doc.getAllElements()[0].properties.value).toBe('200');
});
it('horizontal missachtet y-Differenz', () => {
const { ctx, doc } = mkCtx({ dimSubtype: 'horizontal' });
dimLinearTool.handlers.down!(pe(0, 0), ctx);
dimLinearTool.handlers.down!(pe(100, 500), ctx);
expect(doc.getAllElements()[0].properties.value).toBe('100');
});
it('Preview zeigt Bemassung waehrend Drag', () => {
const { ctx, previews } = mkCtx();
dimLinearTool.handlers.down!(pe(0, 0), ctx);
dimLinearTool.handlers.move!(pe(80, 30), ctx);
expect(previews.length).toBeGreaterThanOrEqual(1);
const last = previews[previews.length - 1];
expect(last!.type).toBe('dimension');
});
});
// ─── dimAlignedTool ────────────────────────────────────────
describe('CAD-1: dimAlignedTool', () => {
it('misst Distanz entlang der Punkte-Verbindung (3-4-5)', () => {
const { ctx, doc } = mkCtx();
dimAlignedTool.handlers.down!(pe(0, 0), ctx);
dimAlignedTool.handlers.down!(pe(30, 40), ctx);
const el = doc.getAllElements()[0];
expect(el.properties.dimType).toBe('aligned');
expect(el.properties.value).toBe('50');
});
});
// ─── dimAngularTool (3 Punkte: Scheitel + 2 Punkte) ───────
describe('CAD-1: dimAngularTool', () => {
it('3 Klicks: 90 Grad zwischen zwei Schenkeln', () => {
const { ctx, doc } = mkCtx();
dimAngularTool.handlers.down!(pe(0, 0), ctx); // Scheitel
dimAngularTool.handlers.down!(pe(100, 0), ctx); // Schenkel 1
dimAngularTool.handlers.down!(pe(0, 100), ctx); // Schenkel 2
const el = doc.getAllElements()[0];
expect(el.properties.dimType).toBe('angular');
expect(el.properties.value).toBe('90\u00b0');
});
it('45 Grad wird korrekt gemessen', () => {
const { ctx, doc } = mkCtx();
dimAngularTool.handlers.down!(pe(0, 0), ctx);
dimAngularTool.handlers.down!(pe(100, 0), ctx);
dimAngularTool.handlers.down!(pe(70.7, 70.7), ctx); // ~45
const el = doc.getAllElements()[0];
expect(el.properties.value).toContain('45');
});
});
// ─── dimRadiusTool / dimDiameterTool (Klick auf Kreis) ────
describe('CAD-1: dimRadiusTool + dimDiameterTool', () => {
it('radius: Klick auf Kreis erzeugt R-Wert', () => {
const { ctx, doc } = mkCtx();
const circle = {
id: 'c1', type: 'circle', layerId: 'l',
x: 100, y: 100, width: 50, height: 50,
properties: { radius: 25 },
} as unknown as CADElement;
doc.addElement(circle);
// ctx.selection mocken (hitTest liefert den Kreis)
ctx.selection = {
hitTest: () => circle,
getIds: () => [],
setIds: () => {},
};
dimRadiusTool.handlers.down!(pe(100, 100), ctx);
const el = doc.getAllElements().find((e) => e.type === 'dimension');
expect(el).toBeDefined();
expect(el!.properties.dimType).toBe('radius');
expect(el!.properties.value).toBe('R25');
});
it('diameter: Klick auf Kreis erzeugt Durchmesser-Wert', () => {
const { ctx, doc } = mkCtx();
const circle = {
id: 'c1', type: 'circle', layerId: 'l',
x: 0, y: 0, width: 50, height: 50,
properties: { radius: 25 },
} as unknown as CADElement;
doc.addElement(circle);
ctx.selection = {
hitTest: () => circle,
getIds: () => [],
setIds: () => {},
};
dimDiameterTool.handlers.down!(pe(0, 0), ctx);
const el = doc.getAllElements().find((e) => e.type === 'dimension');
expect(el!.properties.dimType).toBe('diameter');
expect(el!.properties.value).toContain('50');
});
it('Klick ohne Kreis-Treffer: kein Element, Status erklaert', () => {
const { ctx, doc, statuses } = mkCtx();
ctx.selection = { hitTest: () => null, getIds: () => [], setIds: () => {} };
dimRadiusTool.handlers.down!(pe(0, 0), ctx);
expect(doc.getAllElements()).toHaveLength(0);
expect(statuses[statuses.length - 1]).toContain('Kreis');
});
});
// ─── RenderAdapter: komplette Bemassungs-Primitives ────────
describe('CAD-1: RenderAdapter mit Pfeilen', () => {
it('dimension erzeugt jetzt MEHR Primitives (Extension+Masslinie+Text+Pfeile)', () => {
const el = {
id: 'd1', type: 'dimension', layerId: 'l',
x: 50, y: -10, width: 100, height: 20,
properties: {
x1: 0, y1: 0, x2: 100, y2: 0,
dimType: 'linear', dimSubtype: 'horizontal',
value: '100', dimStyle: 'ISO-25',
},
} as unknown as CADElement;
const prims = elementToPrimitives(el);
// Vorher: 2 Primitives (Linie + Text). Jetzt mit Extension lines + Pfeilen: mehr
expect(prims.length).toBeGreaterThanOrEqual(5);
const lines = prims.filter((p) => p.kind === 'line');
const texts = prims.filter((p) => p.kind === 'text');
const polys = prims.filter((p) => p.kind === 'polyline');
expect(lines.length).toBeGreaterThanOrEqual(3); // Masslinie + 2 Extension lines
expect(texts.length).toBe(1); // Wert-Text
expect(polys.length).toBeGreaterThanOrEqual(2); // 2 Pfeilspitzen
});
it('Pfeilspitzen sind geschlossene Polygone an den Enden der Masslinie', () => {
const el = {
id: 'd1', type: 'dimension', layerId: 'l',
x: 50, y: -10, width: 100, height: 20,
properties: {
x1: 0, y1: 0, x2: 100, y2: 0,
dimType: 'linear', value: '100',
},
} as unknown as CADElement;
const prims = elementToPrimitives(el);
const arrows = prims.filter(
(p) => p.kind === 'polyline' && (p as { close?: boolean }).close === true,
);
expect(arrows.length).toBe(2);
});
it('radius-dimension rendert Linie vom Zentrum nach aussen + Pfeil', () => {
const el = {
id: 'd2', type: 'dimension', layerId: 'l',
x: 100, y: 100, width: 0, height: 0,
properties: {
dimType: 'radius', value: 'R25',
centerX: 100, centerY: 100,
arrowX: 120, arrowY: 100,
},
} as unknown as CADElement;
const prims = elementToPrimitives(el);
expect(prims.length).toBeGreaterThanOrEqual(2);
expect(prims.some((p) => p.kind === 'line')).toBe(true);
});
});
+7 -5
View File
@@ -113,14 +113,16 @@ describe('elementToPrimitives', () => {
expect(elementToPrimitives(el('x', 'definitely-unknown'))).toEqual([]); expect(elementToPrimitives(el('x', 'definitely-unknown'))).toEqual([]);
}); });
it('dimension: Linie + Label aus value/text', () => { it('dimension: komplette Bemassung (Extension+Masslinie+Pfeile+Text, CAD-1)', () => {
const prims = elementToPrimitives(el('d', 'dimension', { const prims = elementToPrimitives(el('d', 'dimension', {
x1: 0, y1: 0, x2: 100, y2: 0, value: '250 cm', x1: 0, y1: 0, x2: 100, y2: 0, value: '250 cm',
}, { x: 50, y: 0 })); }, { x: 50, y: 0 }));
expect(prims.length).toBe(2); // CAD-1: 2 Extension lines + 1 Masslinie + 2 Pfeilspitzen + 1 Text = 6
expect(prims[0].kind).toBe('line'); expect(prims.length).toBe(6);
const txt = prims[1]; expect(prims.filter((p) => p.kind === 'line').length).toBe(3);
if (txt.kind !== 'text') throw new Error('erwartete text'); expect(prims.filter((p) => p.kind === 'polyline').length).toBe(2);
const txt = prims.find((p) => p.kind === 'text');
if (!txt || txt.kind !== 'text') throw new Error('erwartete text');
expect(txt.text).toBe('250 cm'); expect(txt.text).toBe('250 cm');
}); });
+108
View File
@@ -0,0 +1,108 @@
/**
* Task CAD-2 - Echte Hatch-Muster + Boundary-Erkennung.
*
* HATCH_PATTERNS: Standard-CAD-Muster (ANSI31, NET, GRASS etc.)
* als Linien-Segment-Generatoren fuer eine BBox.
* computeBoundary: umschlossene Flaeche aus Kontur-Punkten.
*/
import { describe, it, expect } from 'vitest';
import {
HATCH_PATTERNS,
getHatchPattern,
generateHatchLines,
computeBoundary,
type HatchPatternId,
} from '../src/services/hatchPatternService';
describe('CAD-2: HATCH_PATTERNS Bibliothek', () => {
it('enthaelt die Standard-CAD-Muster', () => {
const ids = HATCH_PATTERNS.map((p) => p.id);
expect(ids).toContain('ANSI31');
expect(ids).toContain('ANSI32');
expect(ids).toContain('ANSI37');
expect(ids).toContain('NET');
expect(ids).toContain('GRASS');
expect(ids).toContain('DOTS');
expect(ids).toContain('SOLID');
expect(ids).toContain('lines'); // Alte bleiben erhalten
expect(ids).toContain('cross');
});
it('jedes Muster hat id, label, description; spacing nur bei Nicht-SOLID', () => {
for (const p of HATCH_PATTERNS) {
expect(p.id.length).toBeGreaterThan(0);
expect(p.label.length).toBeGreaterThan(0);
// SOLID braucht keinen Abstand (Vollfuellung)
if (!p.solid) {
expect(p.defaultSpacing).toBeGreaterThan(0);
}
}
});
it('getHatchPattern liefert undefined fuer unbekannte ID', () => {
expect(getHatchPattern('gibts-nicht')).toBeUndefined();
expect(getHatchPattern('ANSI31')).toBeDefined();
expect(getHatchPattern('ANSI31')!.label).toContain('1');
});
});
describe('CAD-2: generateHatchLines (Muster-Geometrie)', () => {
const bbox = { minX: 0, minY: 0, maxX: 100, maxY: 50 };
const spacing = 10;
it('ANSI31 (45 Grad): erzeugt Diagonal-Linien', () => {
const lines = generateHatchLines('ANSI31', bbox, spacing);
expect(lines.length).toBeGreaterThan(3);
for (const seg of lines) {
const dx = seg.to.x - seg.from.x;
const dy = seg.to.y - seg.from.y;
expect(Math.abs(dy / dx)).toBeCloseTo(1, 1); // 45 Grad
}
});
it('NET: erzeugt horizontale UND vertikale Linien', () => {
const lines = generateHatchLines('NET', bbox, spacing);
const horiz = lines.filter((l) => l.from.y === l.to.y);
const vert = lines.filter((l) => l.from.x === l.to.x);
expect(horiz.length).toBeGreaterThan(0);
expect(vert.length).toBeGreaterThan(0);
});
it('DOTS: erzeugt kleine Kreise (als 4-Punkt-Polygone)', () => {
const lines = generateHatchLines('DOTS', bbox, spacing);
expect(lines.length).toBeGreaterThan(0);
// DOTS sind als polyline-segments repraesentiert
});
it('SOLID: erzeugt EINE grosse Flaeche (fill)', () => {
const lines = generateHatchLines('SOLID', bbox, spacing);
expect(lines).toHaveLength(1);
expect((lines[0] as { fill?: string }).fill).toBeDefined();
});
it('unbekanntes Muster: leeres Array (renderer-sicher)', () => {
expect(generateHatchLines('gibts-nicht', bbox, spacing)).toEqual([]);
});
});
describe('CAD-2: computeBoundary (Boundary-Erkennung)', () => {
it('Rechteck-Kontur: BBox wird korrekt berechnet', () => {
const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 50 }, { x: 0, y: 50 }];
const b = computeBoundary(pts);
expect(b).toEqual({ minX: 0, minY: 0, maxX: 100, maxY: 50 });
});
it('Dreieck: BBox umschliesst alle Punkte', () => {
const pts = [{ x: 10, y: 0 }, { x: 90, y: 80 }, { x: 10, y: 80 }];
const b = computeBoundary(pts);
expect(b.minX).toBe(10);
expect(b.maxX).toBe(90);
expect(b.minY).toBe(0);
expect(b.maxY).toBe(80);
});
it('leere/wenige Punkte: null-Boundary', () => {
expect(computeBoundary([])).toBeNull();
expect(computeBoundary([{ x: 0, y: 0 }])).toBeNull();
});
});
+222
View File
@@ -0,0 +1,222 @@
/**
* Task CAD-4 OTRACK + DIVIDE/MEASURE.
*
* OTRACK: Tracking-Vektoren von Fang-Punkten (horizontal/vertikal/
* Winkelmultiples), SnapEngine-Erweiterung.
* DIVIDE: n gleichmaeßige Punkte auf einem Pfad.
* MEASURE: Punkte im festen Abstand auf einem Pfad.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { SnapEngine, computeTrackPoint } from '../src/canvas/SnapEngine';
import { divideTool, measureTool, pointsAlongPath } from '../src/plugins/builtin/core-modify';
import { CADDocument } from '../src/kernel/document/CADDocument';
import { createInMemoryDoc } from './helpers/inMemoryDoc';
import type { CADElement, Pt } from '../src/types/cad.types';
function mkCtx(elements: CADElement[], options: Record<string, unknown> = {}) {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
for (const el of elements) doc.addElement(el);
const selectedIds = new Set<string>();
const statuses: string[] = [];
const ctx: any = {
doc,
options,
setStatus: (m: string) => statuses.push(m),
setPreview: (_el: CADElement | null) => {},
selection: {
getIds: () => Array.from(selectedIds),
setIds: (ids: string[]) => {
selectedIds.clear();
for (const id of ids) selectedIds.add(id);
},
hitTest: (x: number, y: number, _tol: number) => {
for (const el of elements) {
const p = el.properties as Record<string, unknown>;
if (Array.isArray(p.points)) {
const pts = p.points as Pt[];
for (let i = 0; i < pts.length - 1; i++) {
const dx = pts[i + 1].x - pts[i].x;
const dy = pts[i + 1].y - pts[i].y;
const len = Math.hypot(dx, dy) || 1;
const t = Math.max(0, Math.min(1, ((x - pts[i].x) * dx + (y - pts[i].y) * dy) / (len * len)));
const px = pts[i].x + t * dx;
const py = pts[i].y + t * dy;
if (Math.hypot(x - px, y - py) < 10) return el;
}
}
if (
x >= el.x - el.width / 2 - 5 && x <= el.x + el.width / 2 + 5 &&
y >= el.y - el.height / 2 - 5 && y <= el.y + el.height / 2 + 5
) return el;
}
return null;
},
},
};
return { ctx, doc, selectedIds, statuses };
}
function pe(x: number, y: number, mods: { shift?: boolean } = {}): { world: Pt; shift: boolean; ctrl: boolean } {
return { world: { x, y }, shift: mods.shift ?? false, ctrl: false } as never;
}
function path(pts: Array<[number, number]>): CADElement {
return {
id: 'path-1', type: 'polyline', layerId: 'l',
x: pts.reduce((s, p) => s + p[0], 0) / pts.length,
y: pts.reduce((s, p) => s + p[1], 0) / pts.length,
width: Math.max(...pts.map(p => p[0])) - Math.min(...pts.map(p => p[0])),
height: Math.max(...pts.map(p => p[1])) - Math.min(...pts.map(p => p[1])),
properties: { points: pts.map(([x, y]) => ({ x, y })) },
} as unknown as CADElement;
}
beforeEach(() => {
divideTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
measureTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
});
// ─── OTRACK: computeTrackPoint ────────────────────────────────
describe('CAD-4: computeTrackPoint (OTRACK)', () => {
it('horizontal von Basis (100,100) bei Cursor (200,90): Track-Punkt (200,100)', () => {
const result = computeTrackPoint({ x: 100, y: 100 }, { x: 200, y: 90 });
expect(result).not.toBeNull();
expect(result!.x).toBeCloseTo(200, 6);
expect(result!.y).toBeCloseTo(100, 6);
});
it('vertikal von Basis (100,100) bei Cursor (90,200): Track-Punkt (100,200)', () => {
const result = computeTrackPoint({ x: 100, y: 100 }, { x: 90, y: 200 });
expect(result).not.toBeNull();
expect(result!.x).toBeCloseTo(100, 6);
expect(result!.y).toBeCloseTo(200, 6);
});
it('45-Grad-Richtung wird verfolgt', () => {
// Basis (0,0), Cursor (100,80): 45 Grad heißt y=x → Track (100,100)
const result = computeTrackPoint({ x: 0, y: 0 }, { x: 100, y: 80 }, [0, 45, 90, 135, 180, 225, 270, 315]);
expect(result).not.toBeNull();
expect(result!.x).toBeCloseTo(100, 6);
expect(result!.y).toBeCloseTo(100, 6);
});
it('Cursor genau auf der Basis: kein Track (null)', () => {
expect(computeTrackPoint({ x: 100, y: 100 }, { x: 100, y: 100 })).toBeNull();
});
it('ohne Winkel-Option: nur 0/90/180/270 (Standard-Tracking)', () => {
// 30-Grad-Cursor nahe horizontal: snappt auf horizontal
const result = computeTrackPoint({ x: 0, y: 0 }, { x: 100, y: 15 });
expect(result).not.toBeNull();
expect(result!.x).toBeCloseTo(100, 6);
expect(result!.y).toBeCloseTo(0, 6);
});
});
// ─── pointsAlongPath (reine Funktion) ─────────────────────────
describe('CAD-4: pointsAlongPath', () => {
it('DIVIDE: 4 Segmente auf 100-Einheiten-Pfad → 5 Punkte (inkl. Enden)', () => {
const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }];
const result = pointsAlongPath(pts, 4);
expect(result).toHaveLength(5);
expect(result[0]).toEqual({ x: 0, y: 0 });
expect(result[2]).toEqual({ x: 50, y: 0 });
expect(result[4]).toEqual({ x: 100, y: 0 });
});
it('MEASURE: Abstand 25 auf 100-Einheiten-Pfad → 5 Punkte', () => {
const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }];
const result = pointsAlongPath(pts, 0, 25);
expect(result).toHaveLength(5);
expect(result[3]).toEqual({ x: 75, y: 0 });
});
it('MEASURE mit Rest: 30 auf 100 → 4 Punkte (Rest 10 verworfen)', () => {
const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }];
const result = pointsAlongPath(pts, 0, 30);
expect(result).toHaveLength(4);
expect(result[3]).toEqual({ x: 90, y: 0 });
});
it('gebogener L-Pfad: Punkte folgen der Geometrie', () => {
const pts = [{ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 50, y: 50 }];
const result = pointsAlongPath(pts, 3); // 3 Segmente auf 100 Gesamtlänge → 4 Punkte
expect(result).toHaveLength(4);
expect(result[0]).toEqual({ x: 0, y: 0 });
expect(result[2].x).toBeCloseTo(50, 0);
expect(result[3]).toEqual({ x: 50, y: 50 });
});
});
// ─── divideTool ────────────────────────────────────────────
describe('CAD-4: divideTool', () => {
it('Klick auf Pfad + count=4 platziert 5 Punkte', () => {
const p = path([[0, 0], [100, 0]]);
const { ctx, doc } = mkCtx([p], { count: 4 });
divideTool.handlers.down!(pe(50, 0), ctx);
const points = doc.getAllElements().filter((e) => e.type === 'circle' && e.properties.point);
expect(points).toHaveLength(5);
});
it('Punkte liegen auf defpoints-Layer mit construction:true', () => {
const p = path([[0, 0], [100, 0]]);
const { ctx, doc } = mkCtx([p], { count: 2 });
divideTool.handlers.down!(pe(50, 0), ctx);
const points = doc.getAllElements().filter((e) => e.properties.construction);
expect(points.length).toBeGreaterThanOrEqual(3);
expect(points.every((e) => e.layerId === 'defpoints')).toBe(true);
});
it('Klick ohne Pfad-Treffer: kein Element, Status erklaert', () => {
const { ctx, doc, statuses } = mkCtx([], { count: 2 });
divideTool.handlers.down!(pe(500, 500), ctx);
expect(doc.getAllElements()).toHaveLength(0);
expect(statuses[statuses.length - 1]).toContain('Polyline');
});
it('ALLES in EINER Transaktion (1 Undo entfernt alle Punkte)', () => {
const p = path([[0, 0], [100, 0]]);
const { ctx, doc } = mkCtx([p], { count: 4 });
divideTool.handlers.down!(pe(50, 0), ctx);
expect(doc.getAllElements().length).toBeGreaterThan(1);
doc.undo();
expect(doc.getAllElements()).toHaveLength(1); // Nur der Pfad bleibt
});
});
// ─── measureTool ────────────────────────────────────────────
describe('CAD-4: measureTool', () => {
it('Klick auf Pfad + Abstand 25 platziert 5 Punkte', () => {
const p = path([[0, 0], [100, 0]]);
const { ctx, doc } = mkCtx([p], { spacing: 25 });
measureTool.handlers.down!(pe(50, 0), ctx);
const points = doc.getAllElements().filter((e) => e.type === 'circle' && e.properties.point);
expect(points).toHaveLength(5);
});
it('count=0 (keine Segmente) mit spacing nutzt Abstand', () => {
const p = path([[0, 0], [100, 0]]);
const { ctx, doc } = mkCtx([p], { spacing: 20 });
measureTool.handlers.down!(pe(50, 0), ctx);
const points = doc.getAllElements().filter((e) => e.type === 'circle' && e.properties.point);
expect(points).toHaveLength(6); // 0, 20, 40, 60, 80, 100
});
});
// ─── Manifeste ──────────────────────────────────────────────
describe('CAD-4: Manifeste', () => {
it('ids und Optionen korrekt', () => {
expect(divideTool.manifest.id).toBe('divide');
expect(measureTool.manifest.id).toBe('measure');
const divKeys = (divideTool.optionsSchema ?? []).map((f) => f.key);
expect(divKeys).toContain('count');
const meaKeys = (measureTool.optionsSchema ?? []).map((f) => f.key);
expect(meaKeys).toContain('spacing');
});
});
+2
View File
@@ -807,6 +807,8 @@ describe('A3 pilot: select tool', () => {
selectTool.handlers.down!(pe(10, 10), ctx); selectTool.handlers.down!(pe(10, 10), ctx);
expect(getIds()).toEqual(['a']); expect(getIds()).toEqual(['a']);
selectTool.handlers.down!(pe(999, 999), ctx); selectTool.handlers.down!(pe(999, 999), ctx);
// CAD-3: Leerklick leert erst bei up (Klick ohne Zug) — Box-Select-Follow-up
selectTool.handlers.up!(pe(999, 999), ctx);
expect(getIds()).toEqual([]); expect(getIds()).toEqual([]);
expect(coreModifyPlugin.manifest.enabledByDefault).toBe(true); expect(coreModifyPlugin.manifest.enabledByDefault).toBe(true);