Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b5905506f | |||
| d5acf76d0b | |||
| 2a05257ab5 | |||
| cd5d4326db | |||
| 1d129042d4 | |||
| 180aa9b03a | |||
| f9c67a7d78 | |||
| 02ed6a5ff0 | |||
| 4e6edb51ed | |||
| 685757303f | |||
| 5aca4569ba | |||
| 0e4f9fe160 | |||
| af9f53c61f | |||
| f3a8b9e346 | |||
| 480368a5da | |||
| c4a76fb74e | |||
| 933dfcbac2 | |||
| 51d9c2f208 | |||
| f7fedec0fa | |||
| 1837917537 | |||
| 875dbd6d22 | |||
| 8141be4b80 | |||
| 47c029b4b6 | |||
| 29234ea906 | |||
| 4398c675e4 | |||
| 748ba01083 | |||
| 3ecc99a5e8 | |||
| ee4142b6d1 | |||
| cefba89ebb | |||
| 44a9aec107 | |||
| bcb0fcefe1 | |||
| 7fe6cd127e | |||
| 0ffe954559 | |||
| 7331f5fb88 | |||
| 74c47d7e77 | |||
| 64c25f2019 | |||
| 099f65414f | |||
| 89df306078 | |||
| 11a0dcb4e8 | |||
| 33f6f2d21b | |||
| 44a60fa718 | |||
| 17b62ee36a |
+95
-10
@@ -1,11 +1,62 @@
|
||||
# web-cad v2.0 — ROADMAP & ARBEITSANWEISUNGEN
|
||||
# web-cad v2.0+ — ROADMAP & ARBEITSANWEISUNGEN
|
||||
|
||||
> **Dies ist die Steuerdatei für den Umbau.** Sie enthält Status, Zielarchitektur (Frontend + Backend), Regeln und exakte Arbeitsaufträge (Tasks).
|
||||
> Bei jedem Fortschritt: Task-Status hier aktualisieren + Eintrag in Teil 7 (Fortschritts-Log).
|
||||
>
|
||||
> 📎 Hintergrund & Begründungen der Architektur-Entscheidungen: siehe [umbauplan-v2.md](./umbauplan-v2.md) — bei Abweichungen gilt DIES Dokument.
|
||||
>
|
||||
> Stand: 2026-08-26 · Basis: main @ `126277a` · Planer: Agent Zero · Umsetzung: Agent(en) beliebiger Stärke
|
||||
> **Dies ist die Steuerdatei.** Status, Architektur, Regeln, Arbeitsaufträge.
|
||||
> Bei jedem Fortschritt: Task-Status aktualisieren + Log-Eintrag in Teil 7.
|
||||
> Hintergrund: [umbauplan-v2.md](./umbauplan-v2.md) · [BAUPLAN.md](./BAUPLAN.md)
|
||||
|
||||
---
|
||||
|
||||
# ÜBERGABE-STAND (Stand: 2026-09-15)
|
||||
|
||||
**Wo steht das Projekt?**
|
||||
|
||||
- **Release 2.0.0 LIVE:** https://web-cad-neu.server.media-on.de (Coolify/Hetzner, Backend healthy, Frontend 200)
|
||||
- **Frontend:** 866/866 Tests grün, tsc Exit 0, Build Exit 0
|
||||
- **Backend:** 287/287 Tests grün, tsc Exit 0
|
||||
- **Prod-Daten:** 7 Nutzer, 5 Projekte, 511 Elemente
|
||||
- **Tag:** v2.0.0 auf Forgejo main
|
||||
|
||||
**Alles fertig (A-I + CAD-1 bis CAD-11):**
|
||||
|
||||
- **A:** Plugin-System v2 (25+ Tools, Yjs-UndoManager, CADDocument-Fassade)
|
||||
- **B:** Bug-Fixes (BUG-1-Kollisionsklasse geschlossen)
|
||||
- **C:** PixiJS v8 WebGL-Renderer (Default), Snap-Marks, Selection-Handles
|
||||
- **D:** Alle 12 CAD-Werkzeuge (Ellipse, Spline, Point/XLine/Ray, MTEXT, GripEditing, Arrays, Snaps, BlockAttribute)
|
||||
- **E:** Event-Tools komplett (Bestuhlung, Traversen magnetisch, Ecken variabel, Kreis-Traversen, BOM)
|
||||
- **F:** Bibliothek + DXF/SVG/.wcadlib Import/Export, 3 Builtin-Kataloge
|
||||
- **G:** Layouts + PDF-Export + Browser-Print
|
||||
- **H:** simple/pro-Modus, 6 Templates, Rollen-Guard, i18n de/en, Flächenberechnung
|
||||
- **I:** CI mit Performance-Gate, Docs, v2.0.0 live
|
||||
- **CAD-1 bis CAD-11:** Bemaßung (DimStyles, 5 Typen), Hatch-Muster (9), Box-Select, OTRACK, MLINE, Block-Editor, Hintergrund-Fix, Traversen-System komplett (magnetisch, chordCount, variable Winkel, Kreis), Stückliste/BOM mit CSV, Ribbon-Kuratisierung (4 Tabs)
|
||||
|
||||
**Architektur-Regeln (MUSS eine neue KI kennen):**
|
||||
|
||||
1. `kernel/**` importiert niemals React
|
||||
2. `render/pixi/**` kennt keine React-Components
|
||||
3. Eine Werkzeug-Aktion = eine `doc.transact()` = ein Undo-Schritt
|
||||
4. Session-State als Modul-Member (nicht in options)
|
||||
5. Koordinaten: rect x/y = Zentrum, circle el.x/el.y = Zentrum, Winkel in Grad (0° = 3 Uhr, CCW)
|
||||
6. TDD: Tests zuerst rot, dann implementieren, dann grün. Suite MUSS vor jedem Commit grün sein.
|
||||
7. Commit-Format: `task(<ID>): <beschreibung>` + Roadmap-Log-Eintrag
|
||||
|
||||
**Offene Aufgaben:**
|
||||
|
||||
### MEILENSTEIN 1: Produktive Basis (CAD-12 — NÄCHSTER TASK)
|
||||
- [x] CAD-12a: Autosave (CRDT-Sync alle 30s) *(2026-09-16; Log CAD-12)*
|
||||
- [x] CAD-12b: Keyboard-Shortcuts (ESC, Delete, Strg+A/D, F7/F8/F3) *(2026-09-16; Log CAD-12)*
|
||||
- [x] CAD-12c: Visueller UI-Audit (Playwright-Screenshots, Vision-Modell) *(2026-09-16; Log CAD-12c; Rest: Ribbon-Kompaktlayout)*
|
||||
|
||||
### MEILENSTEIN 2: Alltagstauglichkeit (CAD-13)
|
||||
- [ ] CAD-13a: OTRACK in Interaktions-Engine integrieren
|
||||
- [ ] CAD-13b: Properties-Panel für Event-Elemente
|
||||
- [ ] CAD-13c: Performance-Benchmark 5000+ Elemente
|
||||
- [ ] CAD-13d: DXF-Import mit echten AutoCAD-Dateien testen
|
||||
|
||||
### MEILENSTEIN 3: Ernsthaftes CAD (CAD-14+)
|
||||
- [ ] CAD-14a: Undo-Redo über alle App-Aktionen
|
||||
- [ ] CAD-14b: Druckvorschau + Plot-Styles
|
||||
- [ ] CAD-14c: Multi-Projekt-Verwaltung
|
||||
- [ ] CAD-14d: Optional: 2.5D-Ansicht (Höhen für Rigging-Pläne)
|
||||
|
||||
---
|
||||
|
||||
@@ -647,7 +698,7 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
|
||||
## 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] 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 +718,25 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
|
||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-16 | CAD-14 Phase 4a+4b | WERKZEUG-OPTIONEN + SIDEBAR-ZUGANG: (1) KARTIERUNGSFUND: Der InteractionDispatcher bekam KEIN getOptions — V2-Werkzeuge erhielten IMMER leere Optionen; chordCount/Laenge/Breite/Winkel liefen still ins Leere (NUR in bomService referenziert, keine UI, kein Store) — die in CAD-8/11 gemeldeten einstellbaren Optionen waren im V2-Pfad nicht erreichbar; (2) PHASE 4a: toolOptionsService NEU (zentraler Store: set/get/getOptions, defaultsFromSchema, subscribe, reset — 6 Tests), Dispatcher-Deps.getOptions verdrahtet, Registry-Defaults beim Mount aus optionsSchemas gefuellt, ToolOptionsPanel NEU (rendert optionsSchema des aktiven V2-Werkzeugs: number/color/select/boolean, schreibt in den Store) in RightSidebar tool-Tab ueber PropertiesPanel; (3) PHASE 4b: LeftSidebar dynamische Sektion Werkzeuge (Plugins) NEU — ALLE Registry-Werkzeuge ohne statischen Button (truss, boxcorner, circle-truss, measure-area, block-edit...) sind wieder erreichbar nach dem Ribbon-Rueckbau (Funktionsverlust behoben), ein Ort fuer ALLE Werkzeuge; (4) BEWEIS-TEST erweitert um ABNAHME 4: chordCount=3 im Options-Panel setzen, Traverse ziehen, properties_json in DB muss chordCount=3 enthalten — GRUEN; Test-Lesefehler behoben: rohe DB-API liefert properties_json (String, listElements = SELECT *) statt properties; Timeout 120s | Frontend: 899 Tests (+6 toolOptionsService), tsc 0, Build 0, Beweis-Test GRUEN mit 4 Abnahmen | 2a05257, d5acf76 | PHASE 4a+4b COMPLETE: Optionen erreichen die Werkzeuge, alle Tools in der Sidebar erreichbar |
|
||||
| 2026-09-16 | CAD-14 Phase 3 | V2-SELECT + APP-KANAL: selectTool-Plugin NEU (core-drawing): Klick waehlt via Bridge-hitTest, Leerklick leert Auswahl, Shift additiv, Drag = Box-Select (Zentrum-in-BBox ueber doc.getAllElements), gestrichelte Box-Vorschau; ToolSelectionBridge erweitert um onChange (App-Kanal); CanvasArea: Bridge-onChange — onSelectionChangeRef (Stale-Closure-Sicher wie onElementCreatedRef), Pixi-Handles-Hook bleibt an selectByIds; Beweis-Test erweitert um ABNAHME 3: V2-Select klickt auf die gezeichnete Linie — __v2GetSelection meldet genau 1 selektiert; Test-Timeout 60s; Verbleibend Phase 4: Legacy-InteractionEngine-Code loeschen (1173 Zeilen, nach Layer-Panel-Anpassung) | Frontend: 893 Tests (+6 selectToolV2), tsc 0, Build 0, Beweis-Test GRUEN (3 Abnahmen: Pixi=1, DB=1, Auswahl=1) | 1d12904 | PHASE 3 COMPLETE |
|
||||
| 2026-09-16 | CAD-14 Phase 1+2 | EINSYSTEM-SCHNITT (Nutzer-Anweisung: "Natuerlich die neue Engine, bau den alten Scheiss aus"): KOMPLETTE KARTIERUNG vorab (das fehlte zuvor): (1) SMOKING GUN im Code: Tool-Sync gab BEIDEN Engines dasselbe Werkzeug (Kommentar: "Legacy bleibt parallel aktiv") — jeder Klick lief doppelt, auf zwei Canvas mit unterschiedlichem Größen/DPR-Handling — doppelte Elemente UND Positionsdrift; (2) Beweis-Test E2E (playwright-tests/one-engine-proof.test.ts): echtes Drag-Protokoll, misst Pixi-cachedElements UND DB — ROT-Zustand belegte 4 Elemente statt 1; (3) PHASE 1 Exklusivitaet: V2-Dispatcher exklusiv fuer V2-Werkzeuge, Legacy-Engine bekommt setTool(null) (ToolState.activeTool null-faehig), Doppelverarbeitung am Ursprung getoetet — Messung danach: 2 statt 4 (zweite Quelle: Pixi-Doppelpfad); (4) PHASE 2 Stale-Closure-Fix: onElementCreatedRef (Ref-Muster wie selectedElementIdsRef) — der einmal registrierte v2Doc.onChanged-Handler hielt eine alte drawingId-Closure (still kein DB-Save); (5) Debug-Expose __v2Debug.docCount/announcedCount: Kette live gemessen doc=1 — ann=1 — cached=1 — DB=1 VOLLSTAENDIG; (6) ABNAHME-TEST GRUEN: echtes Drag erzeugt GENAU 1 Element im Renderer UND genau 1 in der DB (frisches Projekt+Zeichnung per API, eindeutige Zuordnung). Verbleibend Phase 3: Legacy-InteractionEngine-Code loeschen (1173 Zeilen), V2-Select-Tool (Auswahl direkt auf V2-Dokument), Layer-Panel an v2Doc | Frontend: 887 Tests, tsc 0, Build 0, E2E-Beweis GRUEN | 02ed6a5, f9c67a7 | PHASE 1+2 COMPLETE: Zeichnen = 1 Element an Klick-Position, in DB persistiert |
|
||||
| 2026-09-16 | CAD-13-ui | DOPPELTE WERKZEUGE entfernt (Nutzer-Meldung: "Werkzeuge doppelt oben, die sind schon in der Sidebar"): Der CAD-10-Zeichnen-Tab im Ribbon war Redundanz zur linken Werkzeug-Sidebar. FIX: Zeichnen-Tab komplett aus Ribbon entfernt (tabs-Array, renderV2Group, V2_GROUP_LABELS, render-Block — 87 Zeilen geloescht), RibbonTab-Typ auf start/view/ki, Stueckliste-Button in Ansicht-Tab-Extras verschoben, uiMode-Tests auf Registry-Filterlogik verlagert (Sidebar ist statisch, Filter lebt in pluginRegistry-tags). LIVE VERIFIZIERT: Ribbon-Tags = [Start, Ansicht, KI], Werkzeuge nur noch in der Sidebar wie in echten CADs (AutoCAD-Muster), visuell bestaetigt | Frontend: 884 Tests, tsc 0, Build 0 | 6857573 | LIVE VERIFIZIERT: keine Doppelung mehr |
|
||||
| 2026-09-16 | CAD-13-fix | DOPPEL-ZEICHNEN behoben (Nutzer-Meldung: "Alles wird doppelt gezeichnet"): Ursache: Legacy-Canvas2D-RenderEngine zeichnet ALLE Elemente UND das Pixi-Overlay rendert dieselben Elemente darueber (Task C2 „Parallel“ war nie abgeschaltet, Legacy-Entsorgung Q3 nie durchgefuehrt). FIX in 3 Schritten: (1) RenderEngine.skipElementRendering-Flag NEU — wenn true, zeichnet Legacy nur Grid/Hintergrund/Lineal, keine Elemente; gesetzt wenn Pixi erfolgreich init (defensiv, TDD 3 Tests); (2) FALLBACK abgesichert: Bei Pixi-Fehler (waitForReady-catch oder init-exception) wird Flag zurueckgesetzt + Legacy rendert sofort wieder Elemente — KEIN Fall wo nichts sichtbar ist; (3) PIXI-MIRROR: Bestands-Elemente aus DB flossen NIE in Pixi (nur neue V2-Elemente via v2Doc.onChanged) — cachedElements war 0, Alt-Projekte waeren leer gewesen; neuer Mirror im elements-Effekt spiegelt alle DB-Elemente in Pixi (Referenzdiff wie syncPixi, removeElement fuern Geloeschte, idempotent da drawElement zuerst removeElement). LIVE-VERIFIZIERT: cachedElements=35 im Buehnen-Projekt, Screenshot zeigt saubere Einfach-Zeichnung ohne Doppelkonturen | Frontend: 884 Tests + 3 renderEngineSkip, tsc 0, Build 0 | af9f53c, 0e4f9fe | LIVE VERIFIZIERT: doppelt-zeichnen behoben |
|
||||
| 2026-09-16 | CAD-12c | Visueller UI-Audit mit Vision-Modell (Playwright-Screenshots aller 4 Tabs, 2 Re-Audits): BEFUNDE: (1) Autosave-Indikator sichtbar und korrekt, (2) 4-Tab-Struktur sauber, (3) Emoji-Toggle im Topbar durch Text-Button Einfach/Pro ersetzt, (4) KRITISCH: Zeichnen-Tab abgeschnitten — ribbon hatte feste Hoehe + overflow:hidden, nur 1 von 3 Gruppen sichtbar; FIX: dynamische Hoehe (auto/min-height/max 220px + scroll), ribbon-content mit flex-wrap + overflow-x auto, Gruppen stapeln vertikal, Buttons fliessen mit flex-wrap; (5) Gruppenlabels bei kleiner Breite ausgeblendet (media-query, ok). VERBLEIBEND: 29-Tool-Zeichnen-Gruppe passt nicht komplett in eine Reihe — Ribbon per Scroll erreichbar, Kompaktlayout (kleinere Buttons/2-spaltig) als eigener Feinschliff-Task in Meilenstein 2 | Frontend: 884/884 Tests, tsc 0, Build 0, 2 Deploy-Verifikationen (Backend healthy, Frontend 200) | c4a76fb, 480368a | CAD-12c COMPLETE, MEILENSTEIN 1 FERTIG |
|
||||
| 2026-09-16 | CAD-12a+b | Autosave + Keyboard-Shortcuts: (1) autosaveService NEU — createAutosaveController mit 30s Interval, Status-Anzeige (Autom. gespeichert/Speichert/Offline) im Topbar via setSavedStatus, performFullSave pusht alle Elemente in SQLite-Tabellen (Redundanz zum CRDT-LevelDB das bereits automatisch speichert), markChanges fuer dirty-tracking, destroy fuer Cleanup; (2) keyboardShortcuts NEU — createKeyboardShortcuts Handler: ESC=Tool-Abbrechen, Delete/Backspace=Auswahl loeschen, Strg+A=Alles auswaehlen, Strg+D=Auswahl duplizieren (CRDT-setElement mit +20 Offset), F7=Grid, F8=Ortho, F3=Snap; Input-Felder werden ignoriert (kein Shortcut in Textfeldern); (3) Beide in App.tsx useEffect verdrahtet (nur wenn drawingId+token vorhanden), destroy bei Cleanup; (4) BOMDialog-Render-Verdrahtung die in CAD-9 fehlte ergaenzt (Action + Render) | Frontend: 884/884 Tests (+18), tsc 0, Build 0 | git | CAD-12a+b COMPLETE; naechster: CAD-12c (visueller UI-Audit mit Vision) |
|
||||
| 2026-09-13 | CAD-11 | Traversen Professional-Upgrade (Nutzer-Anforderungen): (1) chordCount NEU — Anzahl Gurtrohre 1-4 einstellbar (Option number 1-4, Default 2), Rendering gleichmaessig ueber die Breite verteilt (1=Mittellinie, 2=aeussere, 3/4=dichter), BOM gruppiert nach chordCount (2-rohr vs 4-rohr getrennt) mit Detail im Label; (2) FREIE LAENGENEINGABE — length von Select auf number geaendert (10-1000cm), Presets als Voreinstellungen ueber Optionsfeld, nicht mehr auf 4 Werte begrenzt; (3) VARIABLE ECK-WINKEL — angle-Option 15-165 Grad (Default 90), boxcornerEndpoints nutzt properties.angle fuer rot+angle statt fix rot+90, Rendering mit rot+angle Geometrie (8 Punkte), magnetisches Andocken arm-spezifisch; (4) KREIS-TRAVERSE NEU — circleTrussTool (Durchmesser 50-1500, Breite, Gurtrohre 1-4), Klick-Platzierer, circletruss-ElementType mit Aussen-/Innenkreis + Gurtrohr-Kreisen; (5) BOM-UPDATE — Kreis-Traversen mit Pi-d-Umfang als Gesamtlaenge, Eck-Winkel im Label wenn abweichend von 90, chordCount-Label | Frontend: 866 Tests (+21 trussPro, 2 trussUpgrade auf neues Verhalten angepasst), tsc 0, Build 0 | 875dbd6 | CAD-11 COMPLETE |
|
||||
| 2026-09-02 | CAD-10 | Ribbon-Kuratisierung (Nutzer-Kritik: verstreute Menues): VOLLSTANDIGER REWRITE von RibbonBar.tsx (369 auf 242 Zeilen): (1) 4 Tabs statt 7 — Start (Datei/Bearbeiten), Zeichnen (ALLE Werkzeuge), Ansicht, KI; (2) Zeichnen-Tab zeigt alle 48 V2-Werkzeuge in 3 logischen Gruppen: Zeichnen & Aendern (29 canvas-Tools), Bemassung & Text (9 format-Tools), Bestuhlung & Traversen (9 insert-Tools + Stueckliste-Button direkt neben den Traversen-Werkzeugen); (3) Ansicht-Tab konsolidiert: Zoom-Fit/100%/Raster/Layer + Hintergrund/Bild + Farben/Lineal + Historie/Gaeste; (4) Duplikate entfernt (Raster war in 2 Tabs); (5) RibbonTab-Typ auf start/draw/view/ki reduziert, uiMode-Test + Components-Tab-Test angepasst; (6) durchgaengig SVG-Icons; Zwischenfaelle: 2-mal Quoting-/Heredoc-Fehler beim Test-Patch (per sed behoben) + Session-Hang (reset) | Frontend: 845/845 Tests, tsc 0, Build EXIT 0 | 47c029b | CAD-10 COMPLETE |
|
||||
| 2026-08-31 | CAD-9 | Stueckliste (BOM) fuer Traversen: (1) bomService NEU — buildBOM: gruppiert alle truss-Elemente nach (Laenge x trussWidth) und boxcorner nach (armLength x trussWidth) in EINE Position je Mass mit Stueckzahl; Gesamt-Traverse-Meter mit Eck-Konvention BEIDE Arme zaehlen (50er-Ecke = 1.00m — eigener Test verifiziert die Konvention); Sortierung: Traversen vor Eck-Traversen, je Typ Laenge absteigend; Nicht-Traversen (chair etc.) still ignoriert; leerer Bestand → leere Liste ohne Crash; (2) exportBOMCsv: Semikolon-Separatoren (Excel-DE oeffnet direkt mehrspaltig), Spalten Pos/Typ/Laenge_cm/Breite_cm/Anzahl/Gesamt_cm/Bezeichnung, SUMME-Zeile nur wenn Positionen vorhanden (leere BOM = nur Header); (3) BOMDialog-Komponente: Modal-Tabelle mit allen Spalten + Summenzeile hervorgehoben, CSV-Download als Datei (UTF-8-BOM fuer Excel-Umlaute, Datum im Dateinamen), Empty-State mit Hinweis auf Traversen-Tools; (4) Ribbon-Button „Stueckliste“ in Extras-Gruppe neben Gäste, App-Verdrahtung ueber documentService.getActiveDocument().getAllElements(); Test-Zwischenfaelle selbst korrigiert: Gesamtlaenge-Erwartung 7.0m war falsch gerechnet (Ecken zaehlen beide Arme → 8.0m korrekt, konsistent mit Both-Arms-Test) + SUMME-Zeile bei leerer BOM unterdrueckt | Frontend: 845/845 Tests (+13 bomExport.test.ts), tsc 0, Build EXIT 0 | 4398c67 | CAD-9 COMPLETE |
|
||||
| 2026-08-31 | CAD-8 | Traversen-Upgrade (4 Nutzer-Anforderungen): (1) LAENGEN-PRESETS: Select auf genau 50/100/200/300 cm umgestellt; (2) BREITE: neue trussWidth-Option (number 10-60, Standard 29cm) — in makeTrussElement (rotationsbewusste BBox) + Rendering + Status-Meldungen; (3) DREHBAR: Truss-Koerper komplett als ROTIERTES Polygon neu implementiert (4 Ecken über Längs-/Normalen-Richtung statt achsenparalleles rect — vorher sah 90° falsch aus) + Gurtlinien mitgedreht; Boxcorner genauso rotationsbewusst (L-Form-Polygon mit 6 Punkten aus zwei Armen); Drehung im PropertiesPanel (rotation-Feld existierte bereits) wirkt jetzt korrekt in der Geometrie; (4) BOXCORNER: neues ElementType + Eck-Traverse-Tool (armLength/trussWidth-Optionen, Drag-Richtung, L-Schwerpunkt am Vertex) + boxcornerEndpoints (Armenden bei rot und rot+90) + cornerDockToDock (Ecke dockt an Traversen-Enden UND Ecken-Armenden, Gegenrichtung am Truss-Start) + snapTrussToDock erweitert: Traversen docken an Ecken-Armenden mit ARM-SPEZIFISCHER Richtung (Arm1=rot, Arm2=rot+90 statt pauschal rot+180 — Test deckte auf) — Rahmen aus Traversen+Ecken bauen sich komplett magnetisch; Zwischenfaelle: Python-Anchor-Abbruch (Unicode) kostete einen Lauf (alle event-tools-Edits neu mit ASCII-Anchor), L-Form hat 6 nicht 8 Punkte (natuerliche Geometrie, Test korrigiert) | Frontend: 832/832 Tests (+25 trussUpgrade.test.ts), tsc 0, Build EXIT 0 | 3ecc99a | CAD-8 COMPLETE |
|
||||
| 2026-08-31 | CAD-7 | Magnetisches Traversen-Tool: (1) ElementType „truss“ NEU + (2) trussEndpoints (Endpunkte rotationsbewusst über Grad-Winkel) + snapTrussToDock (magnetisches Andocken, rein): sucht unter allen truss-Elementen den Endpunkt in 25er Toleranz, uebernimmt dessen Rotation UND dockt exakt an — am ENDPUNKT-0 (Start) in GEGENRICHTUNG (rot+180, weg von der Anker-Traverse), am Endpunkt-1 vorwärts (Reihen bauen sich selbst); bei mehreren Kandidaten gewinnt der nächstgelegene; (3) trussTool: Drag-Interaktion (down startet, move zeigt magnetische Live-Vorschau — dockt der Start an einem Traversen-Ende wird sofort die gedockte Geometrie angezeigt, up committet in EINER Transaktion), Laengen-Option 100/200/290cm, ohne bestehende Traversen freie Platzierung mit Richtung aus Drag; (4) Rendering: truss-Case im Pixi-Drawer — Balken-Rechteck + 2 Gurtlinien entlang der Rotation (Fachwerk-Profil-Look); Zwischenfall selbst erkannt: erster Docking-Ansatz extendede IMMER vorwärts → am Startpunkt überlappte die neue Traverse die alte (Test deckte auf: Rotation 180 erwartet, 0 geliefert) — Endpunkt-Index-Unterscheidung eingeführt | Frontend: 807/807 Tests (+18 trussTool.test.ts), tsc 0, Build EXIT 0 | cefba89 | CAD-7 COMPLETE — CAD-6+Cad-7 decken beide Nutzer-Wuensche (Grundrisse + Traversen) ab |
|
||||
| 2026-08-31 | CAD-6 | Hintergrund-Grundrisse (mit Maßstab) — echter BUG-Fix: (1) Befund: Die komplette Infrastruktur existierte (BackgroundImport-Dialog, BackgroundService mit calibrateScale-Maßstabs-Kalibrierung, bgConfig in Yjs via bgConfig-Map inkl. CRDT-Sync, bgConfig als CanvasArea-Prop) — ABER CanvasArea nutzte bgConfig NIE: kein setOptions(backgroundSrc), keine Pixi-Zeichnung; importierte Grundrisse wurden im Dialog konfiguriert, aber auf der Zeichenfläche GAR NICHT gerendert; (2) Fix: CanvasArea-Effect erweitert — renderEngine.setOptions mit backgroundSrc (visible-Filter), backgroundScale, backgroundOffsetX/Y, backgroundRotation (Grad→Radiant), backgroundOpacity + render()-Trigger; bgConfig in die useEffect-Dependencies aufgenommen (Position/Scale/Opacity-Änderungen rendern sofort); (3) PixiRenderer.drawBackground: echte Sprite-Implementierung statt Stub — Bild-Cache (Neuaufbau nur bei Quellwechsel), addChildAt(sprite, 0) unter ALLEN Elementen, alpha/scale/position/rotation aus BackgroundDrawConfig, ohne Bild wird das Sprite sauber entfernt (Hintergrund aus); Legacy-RenderEngine.drawBackground hatte bereits korrekte Transform-Parität (Zeichenreihenfolge Grid→Background→Elemente); Layer-Baumansicht: bereits vorhanden (LayerPanel baut echten Baum via parentId mit Unterebenen/Gruppen/Elementen) — keine Änderung nötig; tsc-Fix selbst erkannt: Container heißt root nicht world | Frontend: 789/789 Tests (+5 backgroundRendering.test.ts), tsc 0, Build EXIT 0 | siehe git | CAD-6 COMPLETE; nächster Task CAD-7 (magnetisches Traversen-Tool) |
|
||||
| 2026-08-31 | CAD-5 | MLINE + Block-Editor: (1) mlineTool NEU in core-drawing (13 Tools) — Mehrfachlinie für Wände/Rohre: Klick-Sequenz sammelt Mittellinien-Punkte, offsetPolyline-Helfer berechnet ZWEI parallele Punktketten ±gap/2 entlang der Normalen (an inneren Punkten Mittelung der angrenzenden Segment-Normalen = Miter-Joins wie bei echten Wänden), gap-Option (Gesamtstärke), optionale Endkappen (cap-Checkbox) verbinden beide Linien an Start/Ende als line-Elemente mit mlineCap-Flag, ENTER committet ab 2 Punkten (boolean-Konsum), ALLE Teile (2 Linien + ggf. 2 Kappen) in EINER doc.transact = 1 Undo-Schritt, mline:true+gap-Metadaten, L-förmige Pfade folgen der Geometrie (gleiche Punktanzahl); (2) blockEditorService NEU — openBlockForEdit legt markierte KOPIEN der Block-Elemente ins Dokument (Original unangetastet, BLOCK_EDIT_MARKER-Flag + __blockEdit-IDs), saveBlockEdit schreibt die bearbeiteten Kopien als neue Elementliste in die Definition zurück (Marker entfernt) und räumt Edit-Kopien auf, discardBlockEdit verwirft alles; (3) blockEditTool NEU in core-modify (26 Tools) — Klick 1 auf Block-Instanz öffnet (via selection.hitTest + blockId-Referenz, Status führt), Kopien normal bearbeiten mit allen Werkzeugen, Klick 2 speichert zurück, cancel verwirft; Zwischenf\e4lle selbst gelöst: BLOCK_EDIT_MARKER fehlte als Re-Export in core-modify (Test bekam undefined) + Service-State-Leak zwischen Tests (afterEach-Discard) + Kappen-Test a/b-Vorzeichenkonvention reihenfolge-agnostisch gemacht | Frontend: 784/784 Tests (+21 mlineBlockEditor.test.ts), tsc 0, Build EXIT 0 | 0ffe954 | **CAD-5 COMPLETE — POST-2.0 CAD-AUSBAU VOLLSTÄNDIG** (CAD-1 bis CAD-5: Bemassung, Hatch, Box-Select, OTRACK/DIVIDE/MEASURE, MLINE/Block-Editor) |
|
||||
| 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 A–H & 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 A–I 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 | 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 D1–D12: 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 +822,20 @@ 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: **I3** (Release 2.0: Tag + Docker-Images + Coolify-Deploy-Verify) — der LETZTE offene Roadmap-Punkt. Alle Phasen A–H + I1 + I2 sind komplett.*
|
||||
*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).*
|
||||
*Post-2.0 CAD-Ausbau: CAD-1 bis CAD-11 fertig (inkl. Traversen Professional: Gurtrohre, freie Laenge, variable Winkel, Kreis-Traversen). Neue Anforderungen ueber neue Roadmap-Eintraege.*
|
||||
*Status 2026-09-13: RELEASE 2.0.0 LIVE + CAD-1 bis CAD-11 fertig. FE 866 Tests, BE 287/287, tsc/Build grün. 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)*
|
||||
- [x] CAD-5 Multiline (MLINE) + Block-Editor *(2026-08-31: mlineTool (Doppel-Linie über Normalen-Offset ±gap/2 mit Miter an Zwischenpunkten, optionale Endkappen, ENTER-Commit ab 2 Punkten, 1 Transaktion) + blockEditorService (openBlockForEdit markierte Kopien / saveBlockEdit zurückschreiben / discardBlockEdit verwerfen) + blockEditTool (Klick öffnet, Klick speichert, cancel verwirft); Log CAD-5)*
|
||||
- [x] CAD-6 Hintergrund-Grundrisse mit Maßstab rendern *(2026-08-31: BUG-Fix — bgConfig wurde von App an CanvasArea uebergeben aber NIE genutzt: Hintergrund-Import funktionierte im Dialog, wurde aber nie gezeichnet; CanvasArea-Effect setzt jetzt backgroundSrc/Scale/OffsetX/Y/Rotation/Opacity via renderEngine.setOptions + render; PixiRenderer.drawBackground als echte Sprite-Implementierung (Bild-Cache, addChildAt Index 0 unter allen Elementen, alpha/rotation/scale); Layer-Baumansicht existierte bereits mit Unterebenen; Log CAD-6)*
|
||||
- [x] CAD-7 Magnetisches Traversen-Tool (Truss) *(2026-08-31: trussTool mit Drag-Richtung, Laengen-Optionen 100/200/290cm, snapTrussToDock fuer magnetisches End-zu-End-Andocken mit Rotations-Vererbung + Gegenrichtung am Startpunkt, truss-ElementType + Rendering mit Gurtlinien; Log CAD-7)*
|
||||
- [x] CAD-8 Traversen-Upgrade *(2026-08-31: Laengen-Presets 50/100/200/300cm, Breite einstellbar Standard 29cm, ROTIERBARES Rendering (Koerper als richtungs-Polygon statt achsenparallel), Boxcorner-Eck-Traverse als eigenes magnetisches Element mit beidseitigem Andocken; Log CAD-8)*
|
||||
- [x] CAD-9 Stueckliste Traversen (BOM) *(2026-08-31: bomService mit buildBOM (Gruppierung Traversen nach Laenge x Breite, Eck-Traversen nach armLength x Breite, Stueckzahlen, Gesamt-Traverse-Meter mit BEIDEN Ecken-Armen, Sortierung Traversen vor Ecken nach Laenge absteigend), exportBOMCsv (Semikolon-CSV Excel-DE-freundlich, SUMME-Zeile nur bei Inhalt), BOMDialog-Komponente (Tabelle + Summenzeile + CSV-Download, BOM-freundliche UTF-8-BOM-Datei), Ribbon-Button Stueckliste in Extras; Log CAD-9)*
|
||||
- [x] CAD-10 Ribbon-Kuratisierung: 4 Tabs statt 7 *(2026-09-02: Start/Zeichnen/Ansicht/KI statt 7 verstreute Tabs; alle V2-Werkzeuge im Zeichnen-Tab in logischen Gruppen: Zeichnen & Aendern / Bemassung & Text / Bestuhlung & Traversen inkl. Stueckliste-Button direkt daneben; Ansicht-Tab konsolidiert Zoom/Raster/Layer/Hintergrund/Historie/Gaeste; Duplikate entfernt (Raster doppelt); durchgaengig SVG-Icons; Log CAD-10)*
|
||||
- [x] CAD-11 Traversen Professional-Upgrade *(2026-09-13: chordCount 1-4 Gurtrohre mit einstellbarem Rendering, freie Laengeneingabe 10-1000cm statt Select, variable Eck-Winkel 15-165 Grade, Kreis-Traverse mit Durchmesser/Breite/Gurtrohren, BOM mit chordCount-Gruppierung + Pi-d-Umfang; Log CAD-11)*
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* CAD-14 Beweis-Test: Der Workflow, den der Nutzer jeden Tag macht.
|
||||
* 1. Werkzeug waehlen (Sidebar), 2. zeichnen (2 Klicks),
|
||||
* 3. EXAKT EIN Element an der geklickten Position erwarten.
|
||||
* ROT-Zustand (jetzt): 2 Elemente (Legacy + V2 je eins) = Doppelung belegt.
|
||||
* GRUEN nach Schnitt: 1 Element, Position = Klickposition.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const BASE_URL = process.env.FRONTEND_URL || 'https://web-cad-neu.server.media-on.de';
|
||||
const API_URL = process.env.API_URL || 'https://web-cad-neu.server.media-on.de';
|
||||
|
||||
async function api(method: string, endpoint: string, body?: unknown, token?: string) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const res = await fetch(`${API_URL}${endpoint}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text ? JSON.parse(text) : null };
|
||||
}
|
||||
|
||||
interface VerifyWindow {
|
||||
__v2Debug?: { info: () => { cachedElements: number; ready: boolean } | null };
|
||||
}
|
||||
|
||||
test('zeichnen erzeugt GENAU EIN Element an der Klick-Position', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await page.goto(BASE_URL);
|
||||
await page.fill('input[type=email]', 'admin@media-on.de');
|
||||
await page.fill('input[type=password]', 'WebCAD2026!');
|
||||
await page.click('button[type=submit]');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// CAD-14: Frisches Projekt + Zeichnung -> Basis 0, Zuordnung eindeutig
|
||||
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
|
||||
expect(token).toBeTruthy();
|
||||
const uniq = Date.now();
|
||||
const projRes = await fetch(`${BASE_URL}/api/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name: `CAD14-${uniq}`, description: 'proof' }),
|
||||
});
|
||||
const proj = await projRes.json();
|
||||
expect(proj?.id).toBeTruthy();
|
||||
const drawingRes = await fetch(`${BASE_URL}/api/projects/${proj.id}/drawings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name: `CAD14-Proof-${uniq}` }),
|
||||
});
|
||||
const drawing = await drawingRes.json();
|
||||
expect(drawing?.id).toBeTruthy();
|
||||
const drawingId: string = drawing.id;
|
||||
const beforeDbCount = 0;
|
||||
|
||||
// Karte des frischen Projekts oeffnen (eindeutig per Name) — Dashboard
|
||||
// laedt die Liste beim Mount, daher Reload nach API-Anlage.
|
||||
await page.reload();
|
||||
await page.waitForTimeout(2500);
|
||||
const card = page.locator(`.dashboard-project-card, [class*=project-card]`).filter({ hasText: `CAD14-${uniq}` }).first();
|
||||
await card.click();
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const verify = () => page.evaluate(() => {
|
||||
const w = window as unknown as VerifyWindow;
|
||||
return w.__v2Debug?.info?.() ?? null;
|
||||
});
|
||||
|
||||
const before = await verify();
|
||||
const beforeCount = before?.cachedElements ?? -1;
|
||||
expect(beforeCount, 'Pixi muss ready sein').toBeGreaterThanOrEqual(0);
|
||||
|
||||
// DB-Basis ist 0 (frische Zeichnung)
|
||||
|
||||
// Linie-Werkzeug aus der SIDEBAR (der richtige Ort)
|
||||
await page.locator('[data-tool="line"]').first().click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Auf die Zeichenflaeche klicken: zwei Klicks = eine Linie
|
||||
const canvas = page.locator('canvas').last();
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error('Kein Canvas gefunden');
|
||||
const cx = box.x + box.width / 2;
|
||||
const cy = box.y + box.height / 2;
|
||||
// CAD-14: line ist ein DRAG-Werkzeug (down -> move -> up committet).
|
||||
const startX = cx - 60;
|
||||
const startY = cy - 30;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX + 120, startY + 60, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const after = await verify();
|
||||
const afterCount = after?.cachedElements ?? -1;
|
||||
|
||||
// ABNAHME 1: genau +1 Element im Renderer
|
||||
expect(afterCount - beforeCount, `Erwartet genau 1 neues Element, got ${afterCount - beforeCount}`).toBe(1);
|
||||
|
||||
// ABNAHME 2: das Element MUSS in der DB persistiert sein (Autosave/Save-Kette)
|
||||
// drawingId aus Scope (frisch angelegt)
|
||||
// Autosave braucht bis zu 30s — poll bis zu 40s
|
||||
// CAD-14: ALLE Drawings des Proof-Projekts pruefen (die App koennte in
|
||||
// eine auto-angelegte Zeichnung schreiben statt in meine).
|
||||
let dbCount = -1;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.waitForTimeout(5000);
|
||||
const drs = await (await fetch(`${BASE_URL}/api/projects/${proj.id}/drawings`, { headers: { Authorization: `Bearer ${token}` } })).json();
|
||||
let total = 0;
|
||||
for (const d of drs) {
|
||||
const raw = await (await fetch(`${BASE_URL}/api/drawings/${d.id}/elements`, { headers: { Authorization: `Bearer ${token}` } })).json();
|
||||
total += raw.length;
|
||||
// CAD-14: rohe DB-Zeile hat properties_json (String) — parse und
|
||||
// melde Elemente mit chordCount (fuer die Options-Abnahme unten).
|
||||
for (const e of raw) {
|
||||
const parsed = typeof e.properties_json === 'string' ? JSON.parse(e.properties_json) : (e.properties_json ?? {});
|
||||
if (parsed && parsed.chordCount !== undefined) {
|
||||
console.log(`ELEMENT MIT CHORDCOUNT in ${d.id}:`, parsed.chordCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
dbCount = total;
|
||||
if (total >= 1) break;
|
||||
}
|
||||
expect(dbCount, `Genau 1 Element muss in der DB landen (vor ${beforeDbCount}, nach ${dbCount})`).toBe(1);
|
||||
|
||||
// ABNAHME 3: V2-Select-Tool klickt auf die Linienmitte — Auswahl muss
|
||||
// auf dem V2-Dokument landen (Karte: __v2GetSelection).
|
||||
await page.locator('[data-tool="select"]').first().click();
|
||||
await page.waitForTimeout(300);
|
||||
const midX = startX + 60;
|
||||
const midY = startY + 30;
|
||||
await page.mouse.click(midX, midY);
|
||||
await page.waitForTimeout(500);
|
||||
const sel = await page.evaluate(() => {
|
||||
const w = window as unknown as { __v2GetSelection?: () => { ids: string[] } };
|
||||
return w.__v2GetSelection?.() ?? null;
|
||||
});
|
||||
expect(sel, 'Selection-Bridge muss erreichbar sein').toBeTruthy();
|
||||
expect(sel!.ids.length, `Genau 1 Element muss ausgewaehlt sein, got ${sel!.ids.length}`).toBe(1);
|
||||
|
||||
// ABNAHME 4: Tool-Optionen erreichen das Werkzeug (Phase 4a).
|
||||
// Traversen-Werkzeug waehlen, chordCount=3 im Options-Panel setzen,
|
||||
// Traverse ziehen, properties.chordCount muss 3 sein.
|
||||
await page.locator('[data-v2tool="truss"], [data-tool="truss"]').first().click();
|
||||
await page.waitForTimeout(300);
|
||||
const chordInput = page.locator('.tool-options-panel input[type=number]').first();
|
||||
// chordCount-Feld ueber Label finden (Gurtrohre)
|
||||
const chordRow = page.locator('.toolopt-row', { hasText: 'Gurtrohre' });
|
||||
await chordRow.locator('input').fill('3');
|
||||
await page.waitForTimeout(300);
|
||||
// Traverse ziehen (wie Linie)
|
||||
const tStart = cx - 160;
|
||||
const tStartY = cy + 120;
|
||||
await page.mouse.move(tStart, tStartY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(tStart + 150, tStartY, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800);
|
||||
const trussEl = await page.evaluate(() => {
|
||||
const w = window as unknown as { __cad14Truss?: { id: string; chordCount: number } | null };
|
||||
return w.__cad14Truss ?? null;
|
||||
});
|
||||
// Alternativ: ueber die DB pruefen (Autosave-Poll) — rohe DB-Zeile hat
|
||||
// properties_json (String), nicht properties.
|
||||
let chordInDb: unknown = null;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.waitForTimeout(5000);
|
||||
const drs = await (await fetch(`${BASE_URL}/api/projects/${proj.id}/drawings`, { headers: { Authorization: `Bearer ${token}` } })).json();
|
||||
let found = false;
|
||||
for (const d of drs) {
|
||||
const raw = await (await fetch(`${BASE_URL}/api/drawings/${d.id}/elements`, { headers: { Authorization: `Bearer ${token}` } })).json();
|
||||
for (const e of raw) {
|
||||
const parsed = typeof e.properties_json === 'string' ? JSON.parse(e.properties_json) : (e.properties_json ?? {});
|
||||
if (String(e.type) === 'truss' && (parsed as any)?.chordCount === 3) {
|
||||
chordInDb = parsed.chordCount;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
if (chordInDb !== null) break;
|
||||
}
|
||||
expect(String(chordInDb), 'chordCount=3 muss beim gezeichneten Element ankommen').toBe('3');
|
||||
});
|
||||
@@ -18,6 +18,10 @@ import StatusBar from './components/StatusBar';
|
||||
import LayoutBar from './components/LayoutBar';
|
||||
import MobileDrawers from './components/MobileDrawers';
|
||||
import BackgroundImport from './components/BackgroundImport';
|
||||
import BOMDialog from './components/BOMDialog';
|
||||
import { createAutosaveController } from './services/autosaveService';
|
||||
import { createKeyboardShortcuts } from './services/keyboardShortcuts';
|
||||
import { getActiveDocument } from './kernel/document/documentService';
|
||||
import HistoryPanel from './components/HistoryPanel';
|
||||
import V2HistoryPanel from './components/V2HistoryPanel';
|
||||
import GuestPanel from './components/GuestPanel';
|
||||
@@ -178,8 +182,78 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
|
||||
// History panel
|
||||
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
||||
const [bomDialogOpen, setBomDialogOpen] = useState(false);
|
||||
const [guestPanelOpen, setGuestPanelOpen] = useState(false);
|
||||
|
||||
// ── Task CAD-9: BOM-Action ──
|
||||
useEffect(() => {
|
||||
if (!drawingId || !token) return;
|
||||
// noop: BOM-Action wird über handleRibbonAction verdrahtet (unten)
|
||||
}, [drawingId, token]);
|
||||
|
||||
// ── Task CAD-12a/12b: Autosave + Keyboard-Shortcuts ──
|
||||
useEffect(() => {
|
||||
if (!drawingId || !token) return;
|
||||
|
||||
// Autosave: Status im Topbar sichtbar machen (CAD-12a)
|
||||
const autosave = createAutosaveController({
|
||||
isConnected: () => collab.status === 'connected',
|
||||
hasUnsavedChanges: () => elements.length > 0,
|
||||
performFullSave: async () => {
|
||||
await Promise.all(
|
||||
elements.map((el) =>
|
||||
updateElement(token, el.id, el).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
},
|
||||
onStatusChange: (s) => {
|
||||
if (s.state === 'saving') setSavedStatus('Speichert…');
|
||||
else if (s.state === 'saved') setSavedStatus('Autom. gespeichert');
|
||||
else if (s.state === 'offline') setSavedStatus('Offline');
|
||||
},
|
||||
});
|
||||
|
||||
// Keyboard-Shortcuts (CAD-12b)
|
||||
const ks = createKeyboardShortcuts({
|
||||
escape: () => setActiveTool('select'),
|
||||
delete: () => {
|
||||
const ids = selectedElementIdsRef.current;
|
||||
if (ids.length > 0) handleElementsDeleted(ids);
|
||||
},
|
||||
selectAll: () => {
|
||||
setSelectedElementIds(elements.map((el) => el.id));
|
||||
},
|
||||
duplicate: () => {
|
||||
const ids = selectedElementIdsRef.current;
|
||||
if (ids.length > 0) {
|
||||
elements
|
||||
.filter((el) => ids.includes(el.id))
|
||||
.forEach((el) => {
|
||||
const copy = {
|
||||
...el,
|
||||
id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
||||
x: el.x + 20,
|
||||
y: el.y + 20,
|
||||
} as typeof el;
|
||||
collab.setElement(copy);
|
||||
});
|
||||
}
|
||||
},
|
||||
toggleGrid: () => setGridEnabled((g) => !g),
|
||||
toggleOrtho: () => setOrthoEnabled((o) => !o),
|
||||
toggleSnap: () => setSnapEnabled((s) => !s),
|
||||
});
|
||||
|
||||
const keyHandler = (e: KeyboardEvent) => ks.handler(e);
|
||||
document.addEventListener('keydown', keyHandler);
|
||||
|
||||
return () => {
|
||||
autosave.destroy();
|
||||
document.removeEventListener('keydown', keyHandler);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [drawingId, token, collab.status]);
|
||||
|
||||
// Export format selector (cross-browser replacement for nwsave)
|
||||
const [exportFormatOpen, setExportFormatOpen] = useState(false);
|
||||
|
||||
@@ -1164,6 +1238,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
|
||||
// ─── Background ───
|
||||
if (action === 'background-import') { setBgImportOpen(true); return; }
|
||||
if (action === 'bom') { setBomDialogOpen(true); return; }
|
||||
|
||||
// ─── KI actions ───
|
||||
if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; }
|
||||
@@ -1657,6 +1732,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
<RightSidebar
|
||||
activePanel={activeRightPanel}
|
||||
onPanelChange={setActiveRightPanel}
|
||||
activeToolId={activeTool}
|
||||
selectedElement={selectedElement}
|
||||
layers={layers}
|
||||
blocks={blocks}
|
||||
@@ -1749,6 +1825,12 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
onApply={handleBgApply}
|
||||
backgroundService={bgServiceRef.current}
|
||||
/>
|
||||
{bomDialogOpen && getActiveDocument() && (
|
||||
<BOMDialog
|
||||
elements={getActiveDocument()!.getAllElements()}
|
||||
onClose={() => setBomDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{historyPanelOpen && (V2_TOOLS_ENABLED ? (
|
||||
<V2HistoryPanel
|
||||
onClose={() => setHistoryPanelOpen(false)}
|
||||
|
||||
@@ -55,6 +55,14 @@ export class RenderEngine {
|
||||
/** src des zuletzt erfolgreich geladenen Hintergrundbilds. */
|
||||
private bgSrcLoaded = '';
|
||||
private dpr = 1;
|
||||
/** CAD-13-fix: true = Legacy-Canvas zeichnet KEINE Elemente (Pixi-Overlay zeichnet sie). Grid/Hintergrund bleiben. */
|
||||
skipElementRendering = false;
|
||||
/** CAD-14: Elemente-Spiegel fuer Auswahl-Griffe im Pixi-Modus (RenderEngine zeichnet Elemente nicht, aber die Griffe). */
|
||||
private overlayElements: CADElement[] = [];
|
||||
/** CAD-14: Setzt die Elemente, deren Auswahl-Griffe im Overlay gezeichnet werden. */
|
||||
setOverlayElements(els: CADElement[]): void {
|
||||
this.overlayElements = els;
|
||||
}
|
||||
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
@@ -161,11 +169,20 @@ export class RenderEngine {
|
||||
if (this.options.showGrid) this.drawGrid(w, h);
|
||||
if (this.options.backgroundSrc) this.drawBackground();
|
||||
this.drawRuler(w, h);
|
||||
|
||||
// CAD-14: skipElementRendering = Pixi rendert die Elemente. Das Legacy-
|
||||
// Canvas zeichnet KEINE Elemente mehr — ABER weiterhin alle Overlays
|
||||
// (Vorschau, Auswahl-Griffe, Auswahl-Rechteck, Snap-Punkte, Ortho),
|
||||
// sonst waere Auswahl im Pixi-Modus unsichtbar.
|
||||
const skipElements = this.skipElementRendering;
|
||||
|
||||
const viewport = this.zoomPan.getViewport();
|
||||
const visibleElements = this.spatialIndex.search({
|
||||
minX: viewport.minX, minY: viewport.minY,
|
||||
maxX: viewport.maxX, maxY: viewport.maxY,
|
||||
});
|
||||
const visibleElements = skipElements
|
||||
? []
|
||||
: this.spatialIndex.search({
|
||||
minX: viewport.minX, minY: viewport.minY,
|
||||
maxX: viewport.maxX, maxY: viewport.maxY,
|
||||
});
|
||||
|
||||
const layers = this.layerManager.getLayers();
|
||||
for (const layer of layers) {
|
||||
@@ -176,6 +193,21 @@ export class RenderEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// CAD-14: Auswahl-Griffe auch fuer Pixi-gerenderte Elemente weiterhin
|
||||
// auf dem Overlay-Canvas zeigen (Elemente selbst kommen von Pixi).
|
||||
if (skipElements && this.selection.selectedIds.size > 0) {
|
||||
const hs = this.zoomPan.getScale();
|
||||
const hox = this.zoomPan.getTransform().e;
|
||||
const hoy = this.zoomPan.getTransform().f;
|
||||
const hsx = (val: number) => val * hs + hox;
|
||||
const hsy = (val: number) => val * hs + hoy;
|
||||
for (const el of this.overlayElements) {
|
||||
if (this.selection.selectedIds.has(el.id)) {
|
||||
this.drawSelectionHandles(el, hsx, hsy, hs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw preview element with dashed style
|
||||
if (this.previewElement) {
|
||||
this.ctx.save();
|
||||
|
||||
@@ -494,3 +494,44 @@ export class SnapEngine {
|
||||
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 };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Task CAD-9 – Stuecklisten-Dialog fuer Traversen-Systeme.
|
||||
*
|
||||
* Zeigt die gruppierte Stueckliste (Traversen + Eck-Traversen)
|
||||
* als Tabelle mit Summenzeile; CSV-Download als Datei.
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import { buildBOM, exportBOMCsv } from '../services/bomService';
|
||||
|
||||
interface BOMDialogProps {
|
||||
elements: import('../types/cad.types').CADElement[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const BOMDialog: React.FC<BOMDialogProps> = ({ elements, onClose }) => {
|
||||
const bom = useMemo(() => buildBOM(elements), [elements]);
|
||||
|
||||
const handleDownload = () => {
|
||||
const csv = exportBOMCsv(bom);
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `stueckliste-traversen-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bom-dialog-overlay" onClick={onClose}>
|
||||
<div className="bom-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="bom-dialog-header">
|
||||
<h3>Stückliste – Traversen</h3>
|
||||
<button className="bom-close" onClick={onClose} title="Schließen">✕
|
||||
</button>
|
||||
</div>
|
||||
{bom.rows.length === 0 ? (
|
||||
<div className="bom-empty">
|
||||
Keine Traversen oder Eck-Traversen in der Zeichnung gefunden.
|
||||
<br />
|
||||
Platziere zuerst Traversen mit dem Traverse- oder Eck-Traverse-Werkzeug.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="bom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pos</th>
|
||||
<th>Typ</th>
|
||||
<th>Länge (cm)</th>
|
||||
<th>Breite (cm)</th>
|
||||
<th>Anzahl</th>
|
||||
<th>Gesamt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bom.rows.map((r) => (
|
||||
<tr key={`${r.pos}-${r.label}`}>
|
||||
<td>{r.pos}</td>
|
||||
<td>{r.type}</td>
|
||||
<td>{r.length}</td>
|
||||
<td>{r.width}</td>
|
||||
<td>{r.count}</td>
|
||||
<td>{(r.totalCm / 100).toFixed(2)} m</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bom-sum-row">
|
||||
<td colSpan={5}>
|
||||
<strong>SUMME</strong>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{bom.totalLengthM.toFixed(2)} m</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="bom-dialog-actions">
|
||||
<button className="bom-btn-primary" onClick={handleDownload}>
|
||||
CSV herunterladen
|
||||
</button>
|
||||
<button className="bom-btn-secondary" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMDialog;
|
||||
@@ -20,6 +20,7 @@ import { setActiveDocument, getActiveDocument } from '../kernel/document/documen
|
||||
import { V2_TOOLS_ENABLED, USE_PIXI_RENDERER } from '../kernel/featureFlags';
|
||||
import { PixiRenderer } from '../render/pixi/PixiRenderer';
|
||||
import { pluginRegistry } from '../plugins';
|
||||
import { getToolOptions, setToolOptionsDefaults, defaultsFromSchema } from '../services/toolOptionsService';
|
||||
|
||||
const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
|
||||
@@ -39,8 +40,16 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
const pixiCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
/** Task C2: Aktiver PixiJS-Renderer (für Cleanup). */
|
||||
const pixiRendererRef = useRef<PixiRenderer | null>(null);
|
||||
/** Bereits an onElementCreated gemeldete V2-Element-IDs (verhindert Doppelmeldung bei jedem onChanged-Frame). */
|
||||
/** CAD-13-fix: Bereits in Pixi gespiegelte Bestands-Elemente (id -> Referenz). */
|
||||
const pixiMirrorRef = useRef<Map<string, CADElement>>(new Map());
|
||||
/** CAD-14: Bereits an onElementCreated gemeldete V2-Element-IDs (verhindert Doppelmeldung bei jedem onChanged-Frame). */
|
||||
const announcedV2Ids = useRef<Set<string>>(new Set());
|
||||
/** CAD-14: Frische Prop-Referenz gegen Stale-Closure (Dispatcher/onChanged sind einmalig registriert). */
|
||||
const onElementCreatedRef = useRef(onElementCreated);
|
||||
onElementCreatedRef.current = onElementCreated;
|
||||
/** CAD-14: Frische Prop-Referenz für Selection-Änderungen aus V2-Werkzeugen. */
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
const spatialIndexRef = useRef<SpatialIndex | null>(null);
|
||||
const layerManagerRef = useRef<LayerManager | null>(null);
|
||||
const selectionEngineRef = useRef<SelectionEngine | null>(null);
|
||||
@@ -82,21 +91,32 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
pixi = new PixiRenderer();
|
||||
pixiRendererRef.current = pixi;
|
||||
pixi.init(pixiCanvas);
|
||||
// CAD-13-fix: Pixi rendert jetzt die Elemente — Legacy-Canvas
|
||||
// stellt nur noch Grid/Hintergrund/Lineal dar (sonst doppelt).
|
||||
renderEngine.skipElementRendering = true;
|
||||
pixi.waitForReady().then(() => {
|
||||
// Task C4: Debug-Hook für Playwright-E2E-Assertions
|
||||
(window as unknown as { __v2Debug: unknown }).__v2Debug = {
|
||||
renderer: pixi,
|
||||
info: () => pixiRendererRef.current?.getDebugInfo() ?? null,
|
||||
docCount: () => v2Doc.getAllElements().length,
|
||||
announcedCount: () => announcedV2Ids.current.size,
|
||||
};
|
||||
}).catch((err) => {
|
||||
logger.warn('Pixi init failed — V2-Overlay deaktiviert:', err);
|
||||
pixi = null;
|
||||
pixiRendererRef.current = null;
|
||||
// CAD-13-fix Fallback: Ohne Pixi muss Legacy wieder Elemente zeichnen
|
||||
renderEngine.skipElementRendering = false;
|
||||
renderEngine.render();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Pixi init exception — V2-Overlay deaktiviert:', err);
|
||||
pixi = null;
|
||||
pixiRendererRef.current = null;
|
||||
// CAD-13-fix Fallback: Ohne Pixi muss Legacy wieder Elemente zeichnen
|
||||
renderEngine.skipElementRendering = false;
|
||||
renderEngine.render();
|
||||
}
|
||||
}
|
||||
/** Letztes bekanntes Elementobjekt je ID (Referenzdiff statt JSON). */
|
||||
@@ -135,7 +155,9 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
for (const el of v2Doc.getAllElements()) {
|
||||
if (!announcedV2Ids.current.has(el.id)) {
|
||||
announcedV2Ids.current.add(el.id);
|
||||
onElementCreated(el);
|
||||
// CAD-14: ueber Ref — die eingefrorene Prop haette eine alte
|
||||
// drawingId-Closure (still kein DB-Save nach Spaet-Mount).
|
||||
onElementCreatedRef.current(el);
|
||||
}
|
||||
}
|
||||
syncPixi(); // Task C2: V2-Elemente in den PixiJS-Overlay-Renderer spiegeln
|
||||
@@ -148,10 +170,16 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
},
|
||||
setPreview: (el) => renderEngine.setPreviewElement(el as CADElement | null),
|
||||
getDoc: () => v2Doc,
|
||||
// CAD-14 Phase 4a: Werkzeug-Optionen aus dem zentralen Store
|
||||
// (Laenge/Breite/chordCount/Winkel... erreichen die V2-Werkzeuge).
|
||||
getOptions: (toolId: string) => getToolOptions(toolId),
|
||||
getSelectionBridge: () => ({
|
||||
getIds: () => Array.from(selectionEngine.getSelectedIds()),
|
||||
setIds: (ids) => selectionEngine.selectByIds(ids, false),
|
||||
hitTest: (wx, wy, tol) => renderEngine.hitTest(wx, wy, tol),
|
||||
// CAD-14: App-Kanal fuer V2-Werkzeuge (selectTool) — via Ref gegen
|
||||
// Stale-Closure, derselbe Kanal wie die Legacy-Engine nutzt.
|
||||
onChange: (ids) => onSelectionChangeRef.current?.(ids),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -199,6 +227,14 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
pixi!.setSelection({ selectedIds: ids, elements: selected });
|
||||
};
|
||||
}
|
||||
// CAD-14 Phase 4a: Defaults aus den optionsSchemas aller V2-Werkzeuge
|
||||
// in den toolOptionsStore fuellen (Quelle fuer OptionsPanel + Dispatcher).
|
||||
for (const tool of pluginRegistry.getToolsV2()) {
|
||||
if (tool.optionsSchema?.length) {
|
||||
setToolOptionsDefaults(tool.manifest.id, defaultsFromSchema(tool.optionsSchema as never));
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('InteractionDispatcher v2 aktiviert (VITE_TOOL_DISPATCHER=v2)');
|
||||
}
|
||||
|
||||
@@ -293,9 +329,44 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
interaction.setElements(elements);
|
||||
spatialIndex.clear();
|
||||
spatialIndex.bulkInsert(elements);
|
||||
renderEngine.setOverlayElements(elements);
|
||||
renderEngine.render();
|
||||
|
||||
// CAD-13-fix: Bestands-Elemente (aus DB geladen) müssen in Pixi gespiegelt
|
||||
// werden — nur NEUE V2-Elemente laufen über v2Doc.onChanged in syncPixi.
|
||||
// Ohne diesen Mirror blieben Alt-Projekte leer (Legacy skippt ja jetzt).
|
||||
const pixi = pixiRendererRef.current;
|
||||
if (pixi && renderEngine.skipElementRendering) {
|
||||
void pixi.waitForReady().then(() => {
|
||||
const mirror = pixiMirrorRef.current;
|
||||
const currentIds = new Set<string>();
|
||||
const layerFallback = {
|
||||
id: 'layer-0', name: 'Standard', visible: true, locked: false,
|
||||
color: '#ffffff', lineType: 'solid', transparency: 0,
|
||||
sortOrder: 0, parentId: null,
|
||||
} as never;
|
||||
for (const el of elements) {
|
||||
currentIds.add(el.id);
|
||||
// CAD-14: V2-stämmige Elemente zeichnet syncPixi (v2Doc.onChanged) —
|
||||
// hier NUR noch Aktualisierung des Spiegels, kein doppeltes drawElement.
|
||||
if (mirror.get(el.id) !== el) {
|
||||
mirror.set(el.id, el);
|
||||
const layer = layers.find((l) => l.id === el.layerId) ?? layerFallback;
|
||||
pixi.drawElement(el, layer, {
|
||||
color: '#ffffff', lineType: 'solid', transparency: 0, visible: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const id of Array.from(mirror.keys())) {
|
||||
if (!currentIds.has(id)) {
|
||||
mirror.delete(id);
|
||||
pixi.removeElement(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [elements]);
|
||||
}, [elements, layers]);
|
||||
|
||||
// Sync layers to LayerManager + render
|
||||
useEffect(() => {
|
||||
@@ -378,6 +449,19 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
renderEngine.setOptions({ showOrtho: orthoEnabled });
|
||||
renderEngine.setOptions({ gridSize, unit, scaleFactor, showGridLabels: true });
|
||||
renderEngine.setOptions({ canvasBgColor, gridColor, rulerEnabled });
|
||||
|
||||
// ── Task CAD-6: Hintergrund-Grundriss (bgConfig) an RenderEngine geben ──
|
||||
// Vorher wurde bgConfig als Prop uebergeben aber NIE genutzt — der
|
||||
// importierte Grundriss wurde nicht gezeichnet.
|
||||
renderEngine.setOptions({
|
||||
backgroundSrc: bgConfig?.visible ? (bgConfig.src || undefined) : undefined,
|
||||
backgroundScale: bgConfig?.scale ?? 1,
|
||||
backgroundOffsetX: bgConfig?.offsetX ?? 0,
|
||||
backgroundOffsetY: bgConfig?.offsetY ?? 0,
|
||||
backgroundRotation: ((bgConfig?.rotation ?? 0) * Math.PI) / 180,
|
||||
backgroundOpacity: bgConfig?.opacity ?? 0.5,
|
||||
});
|
||||
renderEngine.render();
|
||||
interaction.setSnapEnabled(snapEnabled);
|
||||
interaction.setOrthoEnabled(orthoEnabled);
|
||||
interaction.setPolarEnabled(polarEnabled);
|
||||
@@ -385,18 +469,19 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
interaction.setGridSize(gridSize);
|
||||
renderEngine.render();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled, unit, gridSize, scaleFactor, canvasBgColor, gridColor, rulerEnabled]);
|
||||
}, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled, unit, gridSize, scaleFactor, canvasBgColor, gridColor, rulerEnabled, bgConfig]);
|
||||
|
||||
// Sync active tool
|
||||
useEffect(() => {
|
||||
const interaction = interactionRef.current;
|
||||
if (!interaction) return;
|
||||
interaction.setTool(activeTool as ToolType);
|
||||
// Task A4.1: V2-Werkzeuge zusätzlich an den Dispatcher routen (Flag aktiv).
|
||||
// Legacy bleibt parallel aktiv für noch nicht migrierte Tools.
|
||||
dispatcherRef.current?.setActive(
|
||||
pluginRegistry.getToolV2(activeTool) ? activeTool : null,
|
||||
);
|
||||
// CAD-14: EXKLUSIVITAET — V2-Werkzeuge laufen AUSSCHLIESSLICH ueber den
|
||||
// InteractionDispatcher. Die Legacy-Engine bekommt sie nicht mehr
|
||||
// (vorher hatten BEIDE dasselbe Tool aktiv => jeder Klick erzeugte
|
||||
// mehrfach Elemente). Nur Tools ohne V2-Entsprechung laufen legacy.
|
||||
const isV2 = !!pluginRegistry.getToolV2(activeTool);
|
||||
interaction.setTool(isV2 ? null : (activeTool as ToolType));
|
||||
dispatcherRef.current?.setActive(isV2 ? activeTool : null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTool]);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import type { LeftSidebarProps } from '../types/ui.types';
|
||||
import { SEATING_TEMPLATES } from '../services/seatingService';
|
||||
import { pluginRegistry } from '../plugins';
|
||||
|
||||
interface ToolDef {
|
||||
tool: string;
|
||||
@@ -138,6 +139,35 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ activeTool, onToolChange, sel
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* CAD-14 Phase 4b: Dynamische Sektion fuer Registry-Werkzeuge ohne
|
||||
statischen Button (truss, boxcorner, circle-truss, measure, ...).
|
||||
Ein Ort fuer ALLE Werkzeuge — keine Doppelung mehr. */}
|
||||
{(() => {
|
||||
const staticIds = new Set(sections.flatMap((s) => s.tools.map((t) => t.tool)));
|
||||
const v2Tools = pluginRegistry.getToolsV2().filter((t) => !staticIds.has(t.manifest.id));
|
||||
if (v2Tools.length === 0) return null;
|
||||
return (
|
||||
<div className="tool-section" key="v2-extra">
|
||||
{renderSectionLabel('Werkzeuge (Plugins)', 'v2-extra')}
|
||||
<div className="tool-grid" style={collapsedSections['v2-extra'] ? { display: 'none' } : undefined}>
|
||||
{v2Tools.map((tool) => (
|
||||
<button
|
||||
key={tool.manifest.id}
|
||||
className={`tool-btn${activeTool === tool.manifest.id ? ' active' : ''}`}
|
||||
data-v2tool={tool.manifest.id}
|
||||
data-tool={tool.manifest.id}
|
||||
title={tool.manifest.description ?? tool.manifest.label}
|
||||
onClick={() => onToolChange(tool.manifest.id)}
|
||||
>
|
||||
<span className="tool-btn-label" style={{ fontSize: 14 }}>{tool.manifest.icon}</span>
|
||||
<span className="tool-btn-label">{tool.manifest.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(onGroup || onUngroup) && (
|
||||
<div className="tool-section" key="grouping">
|
||||
{renderSectionLabel("Gruppierung", "grouping")}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import React from 'react';
|
||||
import { getUIMode, useUIMode } from '../services/uiModeService';
|
||||
import type { RibbonBarProps, RibbonTab } from '../types/ui.types';
|
||||
import { pluginRegistry } from '../plugins';
|
||||
import { V2_TOOLS_ENABLED } from '../kernel/featureFlags';
|
||||
|
||||
const tabs: Array<{ id: RibbonTab; label: string; svg: React.ReactNode }> = [
|
||||
{ id: 'start', label: 'Start', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> },
|
||||
{ id: 'insert', label: 'Einfügen', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg> },
|
||||
{ id: 'format', label: 'Format', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg> },
|
||||
{ id: 'view', label: 'Ansicht', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg> },
|
||||
{ id: 'canvas', label: 'Zeichenfläche', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 3v18"/></svg> },
|
||||
{ id: 'tools', label: 'Extras', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> },
|
||||
{ id: 'ki', label: 'KI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg> },
|
||||
];
|
||||
|
||||
@@ -20,8 +13,6 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
||||
onCanvasBgColorChange, onGridColorChange, onToggleRuler,
|
||||
onIsV2ToolActive,
|
||||
}) => {
|
||||
const uiMode = useUIMode();
|
||||
|
||||
return (
|
||||
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
|
||||
<div className="ribbon-tabs" role="tablist">
|
||||
@@ -42,95 +33,58 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="ribbon-content" id="ribbon-content">
|
||||
{/* ===== V2 TOOLS (Task A4.13/A4.14: Standard aktiv — Opt-out via VITE_TOOL_DISPATCHER=legacy) ===== */}
|
||||
{activeTab === 'canvas' && V2_TOOLS_ENABLED && (
|
||||
<div style={{
|
||||
display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center',
|
||||
padding: '8px 4px', width: '100%',
|
||||
borderBottom: '1px solid var(--color-border, #333)', marginBottom: '8px',
|
||||
}}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-ki, #8a8aff)', marginRight: '4px' }}>V2:</span>
|
||||
{pluginRegistry.getToolsV2()
|
||||
.filter((tool) => uiMode === 'pro' || (tool.manifest.tags ?? []).includes('basic'))
|
||||
.map((tool) => {
|
||||
const isActive = onIsV2ToolActive?.(tool.manifest.id);
|
||||
return (
|
||||
<button
|
||||
key={tool.manifest.id}
|
||||
data-v2tool={tool.manifest.id}
|
||||
title={tool.manifest.description ?? tool.manifest.label}
|
||||
onClick={() => onAction(`v2tool:${tool.manifest.id}`)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 9px', borderRadius: '4px', fontSize: '12px',
|
||||
cursor: 'pointer', border: '1px solid var(--color-border, #333)',
|
||||
background: isActive ? 'rgba(80,250,123,0.15)' : 'var(--color-bg-secondary, #2a2a3a)',
|
||||
color: isActive ? '#50fa7b' : 'inherit',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'monospace' }}>{tool.manifest.icon}</span>
|
||||
<span>{tool.manifest.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* ===== START TAB ===== */}
|
||||
{activeTab === 'start' && (
|
||||
<>
|
||||
<div className="ribbon-group compact-mobile" data-ribbon-group="start">
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Neues Projekt (Strg+N)" onClick={() => onAction('new')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
|
||||
<span>Neu</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Öffnen (Strg+O)" onClick={() => onAction('open')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<span>Öffnen</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Speichern (Strg+S)" onClick={() => onAction('save')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
<span>Speichern</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Datei importieren (DXF, SVG, JSON)" onClick={() => onAction('import')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
<button className="ribbon-btn" title="Import (DXF, SVG, JSON)" onClick={() => onAction('import')}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span>Import</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Export als (DXF, SVG, PDF, PNG, JSON)" onClick={() => onAction('export')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<button className="ribbon-btn" title="Export (DXF, SVG, PDF, PNG, JSON)" onClick={() => onAction('export')}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Datei</div>
|
||||
</div>
|
||||
|
||||
<div className="ribbon-divider"></div>
|
||||
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Rückgängig (Strg+Z)" onClick={() => onAction('undo')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
|
||||
<span>Undo</span>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
|
||||
<span>Rückgängig</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Wiederherstellen (Strg+Y)" onClick={() => onAction('redo')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 15-6.7L21 13"/></svg>
|
||||
<span>Redo</span>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 15-6.7L21 13"/></svg>
|
||||
<span>Wiederherstellen</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Kopieren (Strg+C)" onClick={() => onAction('copy')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
<span>Kopieren</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Einfügen (Strg+V)" onClick={() => onAction('paste')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg>
|
||||
<span>Einfügen</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Gruppieren (G)" onClick={() => onAction('group')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><path d="M10 10l4 4"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
|
||||
<span>Gruppieren</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Degruppieren (Strg+G)" onClick={() => onAction('ungroup')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1" strokeDasharray="2 2"/><rect x="14" y="14" width="7" height="7" rx="1" strokeDasharray="2 2"/><path d="M10 10l4 4" strokeDasharray="2 2"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||||
<span>Degruppieren</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -139,200 +93,69 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== INSERT TAB ===== */}
|
||||
{activeTab === 'insert' && (
|
||||
<>
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Linie zeichnen" onClick={() => onAction('insert-line')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="19" x2="19" y2="5"/></svg>
|
||||
<span>Linie</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Rechteck zeichnen" onClick={() => onAction('insert-rect')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="1"/></svg>
|
||||
<span>Rechteck</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Kreis zeichnen" onClick={() => onAction('insert-circle')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/></svg>
|
||||
<span>Kreis</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Text einfügen" onClick={() => onAction('insert-text')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>
|
||||
<span>Text</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Freihand zeichnen" onClick={() => onAction('insert-freehand')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 17s5-5 9-5 9 5 9 5"/><path d="M3 12s5-5 9-5 9 5 9 5"/></svg>
|
||||
<span>Freihand</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Formen</div>
|
||||
</div>
|
||||
|
||||
<div className="ribbon-divider"></div>
|
||||
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Hintergrund importieren" onClick={() => onAction('background-import')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||||
<span>Hintergrund</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Bild einfügen" onClick={() => onAction('insert-image')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||||
<span>Bild</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Objekt</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== FORMAT TAB ===== */}
|
||||
{activeTab === 'format' && (
|
||||
<>
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Linienstärke" onClick={() => onAction('format-stroke-width')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="4" y1="12" x2="20" y2="12"/></svg>
|
||||
<span>Linienstärke</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Linienfarbe" onClick={() => onAction('format-stroke-color')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><line x1="3" y1="12" x2="21" y2="12"/></svg>
|
||||
<span>Linienfarbe</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Füllfarbe" onClick={() => onAction('format-fill-color')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 3l18 18"/></svg>
|
||||
<span>Füllfarbe</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Linientyp" onClick={() => onAction('format-stroke-style')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="16" x2="20" y2="16" stroke-dasharray="4 4"/></svg>
|
||||
<span>Linientyp</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Stil</div>
|
||||
</div>
|
||||
|
||||
<div className="ribbon-divider"></div>
|
||||
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Nach links ausrichten" onClick={() => onAction('format-align-left')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="4" y1="4" x2="4" y2="20"/><rect x="6" y="6" width="12" height="4"/><rect x="6" y="14" width="8" height="4"/></svg>
|
||||
<span>Links</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Zentrieren" onClick={() => onAction('format-align-center')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="4" x2="12" y2="20"/><rect x="6" y="6" width="12" height="4"/><rect x="8" y="14" width="8" height="4"/></svg>
|
||||
<span>Zentriert</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Nach rechts ausrichten" onClick={() => onAction('format-align-right')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="20" y1="4" x2="20" y2="20"/><rect x="6" y="6" width="12" height="4"/><rect x="10" y="14" width="8" height="4"/></svg>
|
||||
<span>Rechts</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Ausrichtung</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== VIEW TAB ===== */}
|
||||
{/* ===== ANSICHT TAB ===== */}
|
||||
{activeTab === 'view' && (
|
||||
<>
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Zoom anpassen (F)" onClick={() => onAction('zoom-fit')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
|
||||
<span>Zoom Fit</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="100% (1:1)" onClick={() => onAction('zoom-100')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
|
||||
<span>100%</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Grid ein/aus (F7)" onClick={() => onAction('grid')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="0"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
<span>Grid</span>
|
||||
<button className="ribbon-btn" title="Raster ein/aus (F7)" onClick={() => onAction('grid')}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/></svg>
|
||||
<span>Raster</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Layer-Manager" onClick={() => onAction('layer')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
|
||||
<span>Layer</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Ansicht</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== CANVAS TAB ===== */}
|
||||
{activeTab === 'canvas' && (
|
||||
<>
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<div className="ribbon-color-picker" title="Hintergrundfarbe der Zeichenfläche">
|
||||
<div className="ribbon-color-swatch" style={{ background: canvasBgColor }}>
|
||||
<input
|
||||
type="color"
|
||||
value={canvasBgColor}
|
||||
onChange={(e) => onCanvasBgColorChange?.(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="ribbon-color-label">Hintergrund</span>
|
||||
</div>
|
||||
<div className="ribbon-color-picker" title="Gitterfarbe">
|
||||
<div className="ribbon-color-swatch" style={{ background: gridColor }}>
|
||||
<input
|
||||
type="color"
|
||||
value={gridColor}
|
||||
onChange={(e) => onGridColorChange?.(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="ribbon-color-label">Gitter</span>
|
||||
</div>
|
||||
<button className="ribbon-btn" title="Hintergrund-Grundriss importieren" onClick={() => onAction('background-import')}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
|
||||
<span>Hintergrund</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Bild einfügen" onClick={() => onAction('insert-image')}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
<span>Bild</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Farben</div>
|
||||
<div className="ribbon-group-label">Hintergrund</div>
|
||||
</div>
|
||||
|
||||
<div className="ribbon-divider"></div>
|
||||
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button
|
||||
className={`ribbon-btn${rulerEnabled ? ' active' : ''}`}
|
||||
title="Lineal ein/aus"
|
||||
aria-pressed={rulerEnabled}
|
||||
onClick={() => onToggleRuler?.()}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 8h3"/><path d="M3 13h3"/><path d="M3 18h3"/><path d="M8 3v3"/><path d="M13 3v3"/><path d="M18 3v3"/></svg>
|
||||
<label className="ribbon-color-label">Canvas</label>
|
||||
<input type="color" value={canvasBgColor} onChange={(e) => onCanvasBgColorChange?.(e.target.value)} aria-label="Canvas-Hintergrundfarbe" />
|
||||
<label className="ribbon-color-label">Raster</label>
|
||||
<input type="color" value={gridColor} onChange={(e) => onGridColorChange?.(e.target.value)} aria-label="Rasterfarbe" />
|
||||
<button className="ribbon-btn" title="Lineal ein/aus" onClick={() => onToggleRuler?.()}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.3 8.7l-7.6-7.6a1 1 0 0 0-1.4 0l-9 9a1 1 0 0 0 0 1.4l7.6 7.6a1 1 0 0 0 1.4 0l9-9a1 1 0 0 0 0-1.4z"/></svg>
|
||||
<span>Lineal</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Raster ein/aus (F7)" onClick={() => onAction('grid')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
<span>Raster</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Anzeige</div>
|
||||
<div className="ribbon-group-label">Darstellung</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== TOOLS TAB ===== */}
|
||||
{activeTab === 'tools' && (
|
||||
<>
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Messwerkzeug" onClick={() => onAction('measure')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.3 8.7 8.7 21.3a1 1 0 0 1-1.4 0L2.7 16.7a1 1 0 0 1 0-1.4L15.3 2.7a1 1 0 0 1 1.4 0l4.6 4.6a1 1 0 0 1 0 1.4z"/><path d="m7.5 10.5 2 2"/><path d="m10.5 7.5 2 2"/><path d="m13.5 4.5 2 2"/><path d="m4.5 13.5 2 2"/></svg>
|
||||
<span>Messen</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Plugins" onClick={() => onAction('plugins')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v6m0 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/><path d="M12 8a6 6 0 0 0-6 6c0 3 2 4 2 6h8c0-2 2-3 2-6a6 6 0 0 0-6-6z"/></svg>
|
||||
<span>Plugins</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Historie (Strg+H)" onClick={() => onAction('history')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l4 2"/></svg>
|
||||
<button className="ribbon-btn" title="Historie" onClick={() => onAction('history')}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l4 2"/></svg>
|
||||
<span>Historie</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Gäste-Verwaltung" onClick={() => onAction('guests')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span>Gäste</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Stückliste Traversen" onClick={() => onAction('bom')}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/></svg>
|
||||
<span>Stückliste</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Extras</div>
|
||||
</div>
|
||||
@@ -344,18 +167,9 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
||||
<>
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" style={{ color: 'var(--color-ki)' }} title="KI Copilot (Ctrl+K)" onClick={() => onAction('ki')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>
|
||||
<span>KI Copilot</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" style={{ color: 'var(--color-ki)' }} title="KI Zeichnen" onClick={() => onAction('ki-draw')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
<span>KI Zeichnen</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" style={{ color: 'var(--color-ki)' }} title="KI Analysieren" onClick={() => onAction('ki-analyze')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/><path d="M3 5c0 1.66 4 3 9 3s9-1.34 9-3-4-3-9-3-9 1.34-9 3"/></svg>
|
||||
<span>KI Analysieren</span>
|
||||
</button>
|
||||
<span style={{ fontSize: '12px', color: 'var(--color-ki, #8a8aff)', padding: '4px 8px' }}>
|
||||
KI Copilot — Befehle über die Befehlszeile unten (Strg+K)
|
||||
</span>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Künstliche Intelligenz</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback } from 'react';
|
||||
import type { RightSidebarProps, RightPanel } from '../types/ui.types';
|
||||
import type { LibraryProviderExtension } from '../plugins/types';
|
||||
import PropertiesPanel from './PropertiesPanel';
|
||||
import ToolOptionsPanel from './ToolOptionsPanel';
|
||||
import LayerPanel from './LayerPanel';
|
||||
import BlockLibrary from './BlockLibrary';
|
||||
import BlockLibraryTree from './BlockLibraryTree';
|
||||
@@ -23,6 +24,7 @@ const tabs: Array<{ id: RightPanel; label: string; svg: React.ReactNode; badge?:
|
||||
|
||||
const RightSidebar: React.FC<RightSidebarProps> = ({
|
||||
activePanel, onPanelChange, selectedElement, layers, blocks,
|
||||
activeToolId,
|
||||
className,
|
||||
activeLayerId, onSelectLayer, onAddLayer, onToggleLayer,
|
||||
onDeleteLayer, onRenameLayer, onDuplicateLayer, onToggleLock,
|
||||
@@ -116,6 +118,7 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
|
||||
</div>
|
||||
<div className="rightbar-content">
|
||||
<div className={`rightbar-panel${activePanel === 'tool' ? ' active' : ''}`} data-panel-content="tool">
|
||||
<ToolOptionsPanel activeToolId={activeToolId} />
|
||||
{activePanel === 'tool' && selectedElement && onUpdateElement && (
|
||||
<PropertiesPanel selectedElement={selectedElement} layers={layers} unit={unit} scaleFactor={scaleFactor} onDelete={selectedElement && onElementsDeleted ? () => onElementsDeleted([selectedElement.id]) : undefined} onUpdateProperty={(key, value) => {
|
||||
if (!selectedElement || !onUpdateElement) return;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* CAD-14 Phase 4a - Werkzeug-Optionen-Panel.
|
||||
*
|
||||
* Rendert das optionsSchema des aktiven V2-Werkzeugs und bindet
|
||||
* die Werte an den toolOptionsService (Quelle fuer den Dispatcher).
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { pluginRegistry } from '../plugins';
|
||||
import { getToolOption, setToolOption, subscribeToolOptions } from '../services/toolOptionsService';
|
||||
|
||||
interface ToolOptionsPanelProps {
|
||||
activeToolId: string | null | undefined;
|
||||
}
|
||||
|
||||
const ToolOptionsPanel: React.FC<ToolOptionsPanelProps> = ({ activeToolId }) => {
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
// Store-Abonnement: Panel bei Optionsaenderung neu rendern
|
||||
React.useEffect(() => {
|
||||
return subscribeToolOptions(() => forceRender((n) => n + 1));
|
||||
}, []);
|
||||
|
||||
const tool = useMemo(() => {
|
||||
if (!activeToolId) return null;
|
||||
return pluginRegistry.getToolV2(activeToolId) ?? null;
|
||||
}, [activeToolId]);
|
||||
|
||||
const schema = tool?.optionsSchema ?? [];
|
||||
if (!tool || schema.length === 0) return null;
|
||||
|
||||
const renderField = (field: { key: string; label: string; type: string; min?: number; max?: number; options?: Array<{ value: string; label: string }> }) => {
|
||||
const value = getToolOption(tool.manifest.id, field.key);
|
||||
const onChange = (v: unknown) => setToolOption(tool.manifest.id, field.key, v);
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<label key={field.key} className="toolopt-row">
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={typeof value === 'number' ? value : Number(value) || 0}
|
||||
min={field.min}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (field.type === 'color') {
|
||||
return (
|
||||
<label key={field.key} className="toolopt-row">
|
||||
<span>{field.label}</span>
|
||||
<input type="color" value={String(value ?? '#9aa0a6')} onChange={(e) => onChange(e.target.value)} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (field.type === 'select' && field.options) {
|
||||
return (
|
||||
<label key={field.key} className="toolopt-row">
|
||||
<span>{field.label}</span>
|
||||
<select value={String(value ?? '')} onChange={(e) => onChange(e.target.value)}>
|
||||
{field.options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<label key={field.key} className="toolopt-row toolopt-check">
|
||||
<input type="checkbox" checked={value === true} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tool-options-panel">
|
||||
<div className="toolopt-title">Werkzeug-Optionen: {tool.manifest.label}</div>
|
||||
{schema.map((f) => renderField(f as never))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolOptionsPanel;
|
||||
@@ -45,7 +45,7 @@ const Topbar: React.FC<TopbarProps> = ({
|
||||
aria-label={uiMode === 'pro' ? t('mode.toSimple') : t('mode.toPro')}
|
||||
onClick={toggleUIMode}
|
||||
>
|
||||
{uiMode === 'pro' ? '😊' : '🛠'}
|
||||
{uiMode === 'pro' ? 'Einfach' : 'Pro'}
|
||||
</button>
|
||||
<button className="icon-btn-top" aria-label="Rückgängig (Strg+Z)" title="Rückgängig (Strg+Z)" onClick={onUndo}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { formatDistance, type UnitType } from '../utils/format';
|
||||
export type ToolPhase = 'idle' | 'drawing' | 'modifying' | 'panning' | 'selecting';
|
||||
|
||||
export interface ToolState {
|
||||
activeTool: ToolType;
|
||||
activeTool: ToolType | null;
|
||||
phase: ToolPhase;
|
||||
points: Array<{ x: number; y: number }>; // accumulated world points for current operation
|
||||
previewElement: CADElement | null; // temporary element being drawn
|
||||
@@ -138,13 +138,13 @@ export class InteractionEngine {
|
||||
this.selectedTemplate = templateName;
|
||||
}
|
||||
|
||||
setTool(tool: ToolType): void {
|
||||
setTool(tool: ToolType | null): void {
|
||||
this.state.activeTool = tool;
|
||||
this.state.phase = 'idle';
|
||||
this.state.points = [];
|
||||
this.state.previewElement = null;
|
||||
const drawingTools: ToolType[] = ['line', 'rect', 'circle', 'arc', 'polyline', 'polygon', 'revcloud', 'text', 'dimension', 'leader', 'chair', 'seating-row', 'seating-block', 'table', 'stage', 'seating-template', 'hatch'];
|
||||
if (drawingTools.includes(tool)) {
|
||||
if (tool && drawingTools.includes(tool)) {
|
||||
this.selectionEngine.clearSelection();
|
||||
this.emitSelectionChange();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
* (2-Punkt Pfeil+Label).
|
||||
*/
|
||||
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 { 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 = {
|
||||
manifest: {
|
||||
id: 'core-annotate',
|
||||
@@ -203,5 +397,5 @@ export const coreAnnotatePlugin: PluginV2 = {
|
||||
category: 'tools',
|
||||
enabledByDefault: true,
|
||||
},
|
||||
tools: [textTool, dimensionTool, leaderTool, mtextTool],
|
||||
tools: [textTool, dimensionTool, leaderTool, mtextTool, dimLinearTool, dimAlignedTool, dimAngularTool, dimRadiusTool, dimDiameterTool],
|
||||
};
|
||||
|
||||
@@ -891,15 +891,230 @@ export const rayTool: ToolExtensionV2 = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task CAD-5: MLINE (Mehrfachlinie) — zwei parallele Linien
|
||||
// (Wand/Rohr) mit gap-Offset ±gap/2 entlang der Normalen.
|
||||
// Optionale Endkappen verbinden die beiden Linien an Start/Ende.
|
||||
// Klick-Sequenz wie polyline; ENTER committet ab 2 Punkten;
|
||||
// ALLE Teile in EINER Transaktion = 1 Undo-Schritt.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
let mlinePoints: Pt[] = [];
|
||||
|
||||
/**
|
||||
* Normalen-Offset-Punkte für eine Punktliste ±d (CAD-5, rein).
|
||||
* An inneren Punkten wird die Richtung aus dem Mittel der
|
||||
* angrenzenden Segment-Normalen bestimmt (Miter wie bei Wänden).
|
||||
*/
|
||||
function offsetPolyline(pts: Pt[], d: number): Pt[] {
|
||||
if (pts.length < 2) return [...pts];
|
||||
const out: Pt[] = [];
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
let nx = 0, ny = 0;
|
||||
if (i > 0) {
|
||||
const dx = pts[i].x - pts[i - 1].x;
|
||||
const dy = pts[i].y - pts[i - 1].y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
nx += -dy / len; ny += dx / len;
|
||||
}
|
||||
if (i < pts.length - 1) {
|
||||
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;
|
||||
nx += -dy / len; ny += dx / len;
|
||||
}
|
||||
const nLen = Math.hypot(nx, ny) || 1;
|
||||
out.push({ x: pts[i].x + (nx / nLen) * d, y: pts[i].y + (ny / nLen) * d });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const mlineTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'mline',
|
||||
label: 'Mehrfachlinie',
|
||||
icon: '\u2551',
|
||||
ribbonTab: 'canvas',
|
||||
tags: ['basic'],
|
||||
description: 'Doppel-Linie (Wand): Punkte klicken, ENTER beendet (CAD-5)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'gap', label: 'Gesamtstaerke', type: 'number', min: 1 },
|
||||
{ key: 'cap', label: 'Endkappen', type: 'checkbox' },
|
||||
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
mlinePoints.push(e.world);
|
||||
ctx.setStatus(`Mehrfachlinie: ${mlinePoints.length} Punkte — ENTER beendet`);
|
||||
},
|
||||
move(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (mlinePoints.length === 0) return;
|
||||
const gap = (c.options.gap as number | undefined) ?? 20;
|
||||
const pts = [...mlinePoints, e.world];
|
||||
const a = offsetPolyline(pts, gap / 2);
|
||||
const b = offsetPolyline(pts, -gap / 2);
|
||||
ctx.setPreview?.({
|
||||
id: '__preview_mline__',
|
||||
type: 'polyline',
|
||||
layerId: 'layer-0',
|
||||
x: e.world.x, y: e.world.y, width: 0, height: 0,
|
||||
properties: { points: a, stroke: '#e0e0f0', strokeWidth: 1, mline: true },
|
||||
} as unknown as CADElement);
|
||||
},
|
||||
up() { /* Commit nur via ENTER */ },
|
||||
key(e, ctx) {
|
||||
if (e.key !== 'Enter' && e.key !== 'ENTER') return false;
|
||||
const c = ctx as FullToolContext;
|
||||
if (mlinePoints.length < 2) {
|
||||
ctx.setStatus(`Mehrfachlinie benötigt mindestens 2 Punkte (aktuell ${mlinePoints.length})`);
|
||||
return true;
|
||||
}
|
||||
if (!c.doc) return false;
|
||||
const gap = Math.max(1, (c.options.gap as number | undefined) ?? 20);
|
||||
const cap = (c.options.cap as boolean | undefined) ?? false;
|
||||
const stroke = (c.options.strokeColor as string | undefined) ?? '#e0e0f0';
|
||||
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
|
||||
const a = offsetPolyline(mlinePoints, gap / 2);
|
||||
const b = offsetPolyline(mlinePoints, -gap / 2);
|
||||
const mkLine = (pts: Pt[]): CADElement => ({
|
||||
id: uid('mline'),
|
||||
type: 'polyline',
|
||||
layerId,
|
||||
x: pts.reduce((s, p) => s + p.x, 0) / pts.length,
|
||||
y: pts.reduce((s, p) => s + p.y, 0) / pts.length,
|
||||
width: Math.max(...pts.map((p) => p.x)) - Math.min(...pts.map((p) => p.x)),
|
||||
height: Math.max(...pts.map((p) => p.y)) - Math.min(...pts.map((p) => p.y)),
|
||||
properties: { points: pts, stroke, strokeWidth: 1, mline: true, gap },
|
||||
} as unknown as CADElement);
|
||||
const els: CADElement[] = [mkLine(a), mkLine(b)];
|
||||
if (cap) {
|
||||
const capAt = (i: number): CADElement => ({
|
||||
id: uid('mlinecap'),
|
||||
type: 'line',
|
||||
layerId,
|
||||
x: (a[i].x + b[i].x) / 2,
|
||||
y: (a[i].y + b[i].y) / 2,
|
||||
width: Math.abs(b[i].x - a[i].x),
|
||||
height: Math.abs(b[i].y - a[i].y),
|
||||
properties: {
|
||||
x1: a[i].x, y1: a[i].y, x2: b[i].x, y2: b[i].y,
|
||||
stroke, strokeWidth: 1, mlineCap: true, mline: true,
|
||||
},
|
||||
} as unknown as CADElement);
|
||||
els.push(capAt(0), capAt(a.length - 1));
|
||||
}
|
||||
c.doc.transact(() => {
|
||||
for (const el of els) c.doc!.addElement(el);
|
||||
});
|
||||
const count = mlinePoints.length;
|
||||
mlinePoints = [];
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus(`Mehrfachlinie erstellt (${count} Punkte${cap ? ', mit Endkappen' : ''})`);
|
||||
return true;
|
||||
},
|
||||
cancel(ctx) {
|
||||
mlinePoints = [];
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Mehrfachlinie abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let boxStart: Pt | null = null;
|
||||
|
||||
export const selectTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'select',
|
||||
label: 'Auswahl',
|
||||
icon: '\u2196',
|
||||
ribbonTab: 'canvas',
|
||||
tags: ['basic'],
|
||||
description: 'Auswahl: Klick wählt, Drag = Box-Select, Shift additiv (CAD-14: direkt auf V2-Dokument)',
|
||||
},
|
||||
optionsSchema: [],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
const sel = c.selection;
|
||||
const hit = sel?.hitTest(e.world.x, e.world.y, 5) ?? null;
|
||||
if (hit) {
|
||||
if (e.shift) {
|
||||
// additiv: bestehende IDs + neue ID
|
||||
const current = sel?.getIds() ?? [];
|
||||
const next = current.includes(hit.id) ? current.filter((id) => id !== hit.id) : [...current, hit.id];
|
||||
sel?.setIds(next);
|
||||
} else {
|
||||
sel?.setIds([hit.id]);
|
||||
}
|
||||
sel?.onChange?.(sel?.getIds());
|
||||
c.setStatus(`Ausgewählt: ${hit.id}`);
|
||||
boxStart = null;
|
||||
} else {
|
||||
if (!e.shift) sel?.setIds([]);
|
||||
sel?.onChange?.(sel?.getIds() ?? []);
|
||||
boxStart = e.world;
|
||||
c.setStatus('Box-Select: aufziehen');
|
||||
}
|
||||
},
|
||||
move(e, ctx) {
|
||||
if (!boxStart) return;
|
||||
const c = ctx as FullToolContext;
|
||||
// Live-Vorschau der Box als gestricheltes Rechteck
|
||||
ctx.setPreview?.({
|
||||
id: '__boxselect__', type: 'rect', layerId: '',
|
||||
x: (boxStart.x + e.world.x) / 2,
|
||||
y: (boxStart.y + e.world.y) / 2,
|
||||
width: Math.abs(e.world.x - boxStart.x),
|
||||
height: Math.abs(e.world.y - boxStart.y),
|
||||
properties: { stroke: '#00aaff', strokeWidth: 1, visible: true },
|
||||
} as unknown as CADElement);
|
||||
},
|
||||
up(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!boxStart) return;
|
||||
const start = boxStart;
|
||||
boxStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
// War es ein Drag (Box) oder nur ein Klick?
|
||||
const dx = Math.abs(e.world.x - start.x);
|
||||
const dy = Math.abs(e.world.y - start.y);
|
||||
if (dx < 3 && dy < 3) return; // Klick bereits in down behandelt
|
||||
const minX = Math.min(start.x, e.world.x);
|
||||
const maxX = Math.max(start.x, e.world.x);
|
||||
const minY = Math.min(start.y, e.world.y);
|
||||
const maxY = Math.max(start.y, e.world.y);
|
||||
const all = c.doc?.getAllElements() ?? [];
|
||||
const hits = all.filter((el) => el.x >= minX && el.x <= maxX && el.y >= minY && el.y <= maxY).map((el) => el.id);
|
||||
const sel = c.selection;
|
||||
if (e.shift) {
|
||||
const current = sel?.getIds() ?? [];
|
||||
const merged = Array.from(new Set([...current, ...hits]));
|
||||
sel?.setIds(merged);
|
||||
} else {
|
||||
sel?.setIds(hits);
|
||||
}
|
||||
sel?.onChange?.(sel?.getIds() ?? []);
|
||||
c.setStatus(`Box-Select: ${hits.length} Element(e)`);
|
||||
},
|
||||
cancel(ctx) {
|
||||
boxStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Auswahl abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const coreDrawingPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'core-drawing',
|
||||
name: 'Zeichnen (Kern)',
|
||||
version: '1.5.0',
|
||||
version: '1.6.0',
|
||||
author: 'web-cad team',
|
||||
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.',
|
||||
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud, Auswahl.',
|
||||
category: 'tools',
|
||||
enabledByDefault: true,
|
||||
},
|
||||
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool],
|
||||
tools: [selectTool, lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool, mlineTool],
|
||||
};
|
||||
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
extendElement,
|
||||
filletElements,
|
||||
} from '../../../tools/modification/geometry';
|
||||
import { openBlockForEdit, saveBlockEdit, discardBlockEdit } from '../../../services/blockEditorService';
|
||||
import { BLOCK_EDIT_MARKER } from '../../../services/blockEditorService';
|
||||
export { BLOCK_EDIT_MARKER };
|
||||
import type { Pt } from '../../../tools/modification/geometry';
|
||||
|
||||
const uid = (prefix: string): string =>
|
||||
@@ -178,6 +181,71 @@ function applyVertexDrag(
|
||||
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). */
|
||||
let vertexDrag: {
|
||||
el: CADElement;
|
||||
@@ -200,6 +268,9 @@ export const selectTool: ToolExtensionV2 = {
|
||||
const bridge = (ctx as FullToolContext).selection;
|
||||
const c = ctx as FullToolContext;
|
||||
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 subtractive = e.ctrl;
|
||||
const hit = bridge.hitTest(e.world.x, e.world.y, 5);
|
||||
@@ -243,7 +314,10 @@ export const selectTool: ToolExtensionV2 = {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
let ids = new Set(bridge.getIds());
|
||||
@@ -258,6 +332,31 @@ export const selectTool: ToolExtensionV2 = {
|
||||
},
|
||||
|
||||
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) ──
|
||||
if (vertexDrag) {
|
||||
const c = ctx as FullToolContext;
|
||||
@@ -308,6 +407,40 @@ export const selectTool: ToolExtensionV2 = {
|
||||
},
|
||||
|
||||
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) ──
|
||||
if (vertexDrag) {
|
||||
const c = ctx as FullToolContext;
|
||||
@@ -379,6 +512,7 @@ export const selectTool: ToolExtensionV2 = {
|
||||
cancel(ctx) {
|
||||
handleDrag = null;
|
||||
vertexDrag = null;
|
||||
boxDrag = null;
|
||||
ctx.setPreview?.(null);
|
||||
},
|
||||
},
|
||||
@@ -394,8 +528,15 @@ export const hatchTool: ToolExtensionV2 = {
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'hatchPattern', label: 'Muster', type: 'select', options: [
|
||||
{ value: 'lines', label: 'Linien' },
|
||||
{ value: 'cross', label: 'Kreuz' },
|
||||
{ value: 'ANSI31', label: 'ANSI31 (45°)' },
|
||||
{ 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 },
|
||||
],
|
||||
@@ -1577,6 +1718,203 @@ export const offsetTool: ToolExtensionV2 = {
|
||||
};
|
||||
|
||||
/** 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');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task CAD-5: Block-Editor — Blockdefinition in-place bearbeiten.
|
||||
// Klick 1 auf Block-Instanz oeffnet (markierte Kopien erscheinen),
|
||||
// Kopien normal bearbeiten, Klick 2 speichert zurueck in die
|
||||
// Definition, cancel verwirft. (Service: blockEditorService)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const blockEditTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'block-edit',
|
||||
label: 'Block bearbeiten',
|
||||
icon: '\u2699',
|
||||
ribbonTab: 'canvas',
|
||||
tags: ['pro'],
|
||||
description: 'Block-Instanz anklicken um die Definition zu bearbeiten (CAD-5)',
|
||||
},
|
||||
optionsSchema: [],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc || !c.selection) return;
|
||||
// Offene Sitzung? → speichern
|
||||
if ((c.doc.getAllElements() as CADElement[]).some(
|
||||
(el) => (el.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER],
|
||||
)) {
|
||||
const saved = saveBlockEdit(c.doc as never);
|
||||
ctx.setStatus(saved ? `Block "${saved}" gespeichert` : 'Speichern fehlgeschlagen');
|
||||
return;
|
||||
}
|
||||
const hit = c.selection.hitTest(e.world.x, e.world.y, 8);
|
||||
if (!hit || hit.type !== 'block_instance') {
|
||||
ctx.setStatus('Block bearbeiten: Block-Instanz anklicken');
|
||||
return;
|
||||
}
|
||||
const blockId = (hit.properties as Record<string, unknown>).blockId as string | undefined;
|
||||
if (!blockId) {
|
||||
ctx.setStatus('Block bearbeiten: Instanz ohne Block-Referenz');
|
||||
return;
|
||||
}
|
||||
const opened = openBlockForEdit(c.doc as never, blockId);
|
||||
ctx.setStatus(opened ? `Block "${blockId}" geöffnet — Elemente bearbeiten, erneut klicken speichert` : 'Block konnte nicht geöffnet werden');
|
||||
},
|
||||
cancel(ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (c.doc) discardBlockEdit(c.doc as never);
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Block-Bearbeitung verworfen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const coreModifyPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'core-modify',
|
||||
@@ -1587,5 +1925,5 @@ export const coreModifyPlugin: PluginV2 = {
|
||||
category: 'tools',
|
||||
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, blockEditTool],
|
||||
};
|
||||
|
||||
@@ -421,6 +421,401 @@ export const barrierTool: ToolExtensionV2 = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task CAD-7: Magnetisches Traversen-Tool (Truss).
|
||||
// Rechteckiger Balken (Laengen-Option 100/200/290cm), Drag
|
||||
// definiert die Richtung. MAGNETISCH: Nahe dem Ende einer
|
||||
// bestehenden Traverse dockt die neue exakt an (Position UND
|
||||
// Rotation uebernommen) - Traversen-Reihen bauen sich selbst.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Dock-Ergebnis des magnetischen Andockens. */
|
||||
export interface TrussDock {
|
||||
center: Pt;
|
||||
rotationDeg: number;
|
||||
dockedTo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpunkte einer Traverse in Weltkoordinaten (CAD-7, rein).
|
||||
* Rotation in Grad um das Zentrum (el.x/el.y).
|
||||
*/
|
||||
export function trussEndpoints(el: CADElement): [Pt, Pt] {
|
||||
const length = (el.properties.length as number | undefined) ?? el.width ?? 0;
|
||||
const rot = (((el.properties.rotation as number | undefined) ?? 0) * Math.PI) / 180;
|
||||
const half = length / 2;
|
||||
const dx = Math.cos(rot) * half;
|
||||
const dy = Math.sin(rot) * half;
|
||||
return [
|
||||
{ x: el.x - dx, y: el.y - dy },
|
||||
{ x: el.x + dx, y: el.y + dy },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Magnetisches Andocken (CAD-7, rein): Sucht unter allen Traversen
|
||||
* den Endpunkt in Dock-Toleranz und liefert die Geometrie der
|
||||
* neuen Traverse (Zentrum + Rotation), die exakt dort andockt.
|
||||
* dockedTo = ID der Anker-Traverse; null = kein Dock in Reichweite.
|
||||
*/
|
||||
export function snapTrussToDock(
|
||||
existing: CADElement[],
|
||||
cursor: Pt,
|
||||
newLength: number,
|
||||
tolerance: number,
|
||||
): TrussDock | null {
|
||||
let best: { dock: TrussDock; dist: number } | null = null;
|
||||
for (const el of existing) {
|
||||
const isTruss = String(el.type) === 'truss' && el.properties.truss === true;
|
||||
const isCorner = String(el.type) === 'boxcorner' && el.properties.boxcorner === true;
|
||||
if (!isTruss && !isCorner) continue;
|
||||
const rotDeg = (el.properties.rotation as number | undefined) ?? 0;
|
||||
const ends = isTruss ? trussEndpoints(el) : boxcornerEndpoints(el);
|
||||
for (let endIdx = 0; endIdx < ends.length; endIdx++) {
|
||||
const end = ends[endIdx];
|
||||
const dist = Math.hypot(cursor.x - end.x, cursor.y - end.y);
|
||||
if (dist > tolerance) continue;
|
||||
if (best && dist >= best.dist) continue;
|
||||
// Weg-Richtung VOM Dock-Ende (arm-spezifisch):
|
||||
// Truss-Start (idx 0) = Gegenrichtung (rot+180), Truss-Ende (idx 1) = rot,
|
||||
// Boxcorner-Arm1 (idx 0) = rot, Boxcorner-Arm2 (idx 1) = rot+90.
|
||||
let dirDeg: number;
|
||||
if (isTruss) {
|
||||
dirDeg = endIdx === 0 ? (rotDeg + 180) % 360 : rotDeg;
|
||||
} else {
|
||||
dirDeg = endIdx === 0 ? rotDeg : (rotDeg + 90) % 360;
|
||||
}
|
||||
const dirRad = (dirDeg * Math.PI) / 180;
|
||||
const half = newLength / 2;
|
||||
const centerX = end.x + Math.cos(dirRad) * half;
|
||||
const centerY = end.y + Math.sin(dirRad) * half;
|
||||
best = {
|
||||
dist,
|
||||
dock: {
|
||||
center: { x: centerX, y: centerY },
|
||||
rotationDeg: dirDeg,
|
||||
dockedTo: el.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return best ? best.dock : null;
|
||||
}
|
||||
|
||||
/** Traversen-Drag-Sitzung (CAD-7). */
|
||||
let trussDragStart: Pt | null = null;
|
||||
|
||||
function makeTrussElement(
|
||||
center: Pt,
|
||||
length: number,
|
||||
rotationDeg: number,
|
||||
layerId: string,
|
||||
trussWidth = 29,
|
||||
chordCount = 2,
|
||||
): CADElement {
|
||||
const rad = (rotationDeg * Math.PI) / 180;
|
||||
const w = Math.abs(length * Math.cos(rad)) + Math.abs(trussWidth * Math.sin(rad));
|
||||
const h = Math.abs(length * Math.sin(rad)) + Math.abs(trussWidth * Math.cos(rad));
|
||||
return {
|
||||
id: `truss_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`,
|
||||
type: 'truss',
|
||||
layerId,
|
||||
x: center.x,
|
||||
y: center.y,
|
||||
width: Math.max(w, trussWidth),
|
||||
height: Math.max(h, trussWidth),
|
||||
properties: {
|
||||
length, rotation: rotationDeg, trussWidth, chordCount, truss: true,
|
||||
stroke: '#9aa0a6', strokeWidth: 2, fill: '#6b7280',
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
const DOCK_TOLERANCE = 25;
|
||||
|
||||
export const trussTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'truss',
|
||||
label: 'Traverse (magnetisch)',
|
||||
icon: '\u2550',
|
||||
ribbonTab: 'insert',
|
||||
tags: ['basic'],
|
||||
description: 'Traverse platzieren: Drag = Richtung, dockt magnetisch an bestehenden Traversen an (CAD-7)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'length', label: 'Länge (cm)', type: 'number', min: 10, max: 1000 },
|
||||
{ key: 'trussWidth', label: 'Breite (cm)', type: 'number', min: 10, max: 60 },
|
||||
{ key: 'chordCount', label: 'Gurtrohre', type: 'number', min: 1, max: 4 },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
trussDragStart = e.world;
|
||||
ctx.setStatus('Traverse: Richtung ziehen (dockt automatisch an Traversen an)');
|
||||
},
|
||||
move(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!trussDragStart) return;
|
||||
const length = Number((c.options.length as number | string | undefined) ?? 200) || 200;
|
||||
const trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
|
||||
const chordCount = Math.max(1, Math.min(4, (c.options.chordCount as number | undefined) ?? 2));
|
||||
// Magnet-Vorschau: dockt der Cursor an ein Traversen-/Ecken-Ende?
|
||||
const existing = c.doc ? (c.doc.getAllElements() as CADElement[]).filter(
|
||||
(el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner',
|
||||
) : [];
|
||||
const dock = snapTrussToDock(existing, trussDragStart, length, DOCK_TOLERANCE);
|
||||
if (dock) {
|
||||
ctx.setPreview?.(makeTrussElement(dock.center, length, dock.rotationDeg, 'layer-0', trussWidth, chordCount));
|
||||
return;
|
||||
}
|
||||
// Frei: Richtung aus Drag
|
||||
const ang = (Math.atan2(e.world.y - trussDragStart.y, e.world.x - trussDragStart.x) * 180) / Math.PI;
|
||||
const cx = (trussDragStart.x + e.world.x) / 2;
|
||||
const cy = (trussDragStart.y + e.world.y) / 2;
|
||||
ctx.setPreview?.(makeTrussElement({ x: cx, y: cy }, length, ang, 'layer-0', trussWidth, chordCount));
|
||||
},
|
||||
up(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
const start = trussDragStart;
|
||||
trussDragStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
if (!start || !c.doc) return;
|
||||
const length = Number((c.options.length as string | undefined) ?? 200) || 200;
|
||||
const trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
|
||||
const chordCount = Math.max(1, Math.min(4, (c.options.chordCount as number | undefined) ?? 2));
|
||||
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
|
||||
// MAGNETISCH: Dock-Pruefung am Drag-Startpunkt (Traversen UND Ecken)
|
||||
const existing = (c.doc.getAllElements() as CADElement[]).filter(
|
||||
(el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner',
|
||||
);
|
||||
const dock = snapTrussToDock(existing, start, length, DOCK_TOLERANCE);
|
||||
let el: CADElement;
|
||||
if (dock) {
|
||||
el = makeTrussElement(dock.center, length, dock.rotationDeg, layerId, trussWidth, chordCount);
|
||||
ctx.setStatus(`Traverse angedockt an ${dock.dockedTo} (${length}cm, Breite ${trussWidth}cm)`);
|
||||
} else {
|
||||
const ang = (Math.atan2(e.world.y - start.y, e.world.x - start.x) * 180) / Math.PI;
|
||||
const cx = (start.x + e.world.x) / 2;
|
||||
const cy = (start.y + e.world.y) / 2;
|
||||
el = makeTrussElement({ x: cx, y: cy }, length, ang, layerId, trussWidth, chordCount);
|
||||
ctx.setStatus(`Traverse platziert (${length}cm, Breite ${trussWidth}cm)`);
|
||||
}
|
||||
c.doc.transact(() => { c.doc!.addElement(el); });
|
||||
},
|
||||
cancel(ctx) {
|
||||
trussDragStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Traverse abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task CAD-8: Boxcorner (Eck-Traverse) — L-foermiges Element
|
||||
// mit zwei Armen (rot und rot+90). Magnetisch: dockt an
|
||||
// Traversen-Enden und andere Ecken; Traversen docken an die
|
||||
// Armenden. DREHBAR via properties.rotation (PropertiesPanel).
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Armenden einer Boxcorner: Endpunkte beider Arme vom Eckpunkt. */
|
||||
export function boxcornerEndpoints(el: CADElement): [Pt, Pt] {
|
||||
const armLength = (el.properties.armLength as number | undefined) ?? 50;
|
||||
const rotDeg = (el.properties.rotation as number | undefined) ?? 0;
|
||||
const angle = (el.properties.angle as number | undefined) ?? 90;
|
||||
const rot = (rotDeg * Math.PI) / 180;
|
||||
const rot2 = ((rotDeg + angle) * Math.PI) / 180;
|
||||
return [
|
||||
{ x: el.x + Math.cos(rot) * armLength, y: el.y + Math.sin(rot) * armLength },
|
||||
{ x: el.x + Math.cos(rot2) * armLength, y: el.y + Math.sin(rot2) * armLength },
|
||||
];
|
||||
}
|
||||
|
||||
export interface CornerDock {
|
||||
vertex: Pt;
|
||||
rotationDeg: number;
|
||||
dockedTo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magnetisches Andocken einer Ecke (rein): dockt an Traversen-
|
||||
* Enden und Ecken-Armenden; am Traversen-Start-Ende wird die
|
||||
* Gegenrichtung uebernommen (rot+180), damit Reihen nach aussen
|
||||
* wachsen.
|
||||
*/
|
||||
export function cornerDockToDock(
|
||||
existing: CADElement[],
|
||||
cursor: Pt,
|
||||
tolerance: number,
|
||||
): CornerDock | null {
|
||||
let best: { dock: CornerDock; dist: number } | null = null;
|
||||
for (const el of existing) {
|
||||
const isTruss = String(el.type) === 'truss' && el.properties.truss === true;
|
||||
const isCorner = String(el.type) === 'boxcorner' && el.properties.boxcorner === true;
|
||||
if (!isTruss && !isCorner) continue;
|
||||
const rotDeg = (el.properties.rotation as number | undefined) ?? 0;
|
||||
const ends = isTruss ? trussEndpoints(el) : boxcornerEndpoints(el);
|
||||
for (let endIdx = 0; endIdx < ends.length; endIdx++) {
|
||||
const end = ends[endIdx];
|
||||
const dist = Math.hypot(cursor.x - end.x, cursor.y - end.y);
|
||||
if (dist > tolerance) continue;
|
||||
if (best && dist >= best.dist) continue;
|
||||
const dir = isTruss && endIdx === 0 ? -1 : 1;
|
||||
const newRot = isTruss && endIdx === 0 ? (rotDeg + 180) % 360 : rotDeg;
|
||||
void dir;
|
||||
best = {
|
||||
dist,
|
||||
dock: { vertex: { x: end.x, y: end.y }, rotationDeg: newRot, dockedTo: el.id },
|
||||
};
|
||||
}
|
||||
}
|
||||
return best ? best.dock : null;
|
||||
}
|
||||
|
||||
function makeBoxcornerElement(
|
||||
vertex: Pt,
|
||||
armLength: number,
|
||||
rotationDeg: number,
|
||||
angleDeg: number,
|
||||
trussWidth: number,
|
||||
layerId: string,
|
||||
): CADElement {
|
||||
return {
|
||||
id: `boxcorner_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`,
|
||||
type: 'boxcorner',
|
||||
layerId,
|
||||
x: vertex.x,
|
||||
y: vertex.y,
|
||||
width: armLength + trussWidth,
|
||||
height: armLength + trussWidth,
|
||||
properties: {
|
||||
armLength, trussWidth, rotation: rotationDeg, angle: angleDeg, boxcorner: true,
|
||||
stroke: '#9aa0a6', strokeWidth: 2, fill: '#6b7280',
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
let cornerDragStart: Pt | null = null;
|
||||
|
||||
export const boxCornerTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'boxcorner',
|
||||
label: 'Eck-Traverse (magnetisch)',
|
||||
icon: '╭',
|
||||
ribbonTab: 'insert',
|
||||
tags: ['basic'],
|
||||
description: 'Eck-Traverse (Boxcorner): Drag = Richtung, dockt magnetisch an (CAD-8)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'armLength', label: 'Armlänge (cm)', type: 'number', min: 20, max: 150 },
|
||||
{ key: 'angle', label: 'Winkel (°)', type: 'number', min: 15, max: 165 },
|
||||
{ key: 'trussWidth', label: 'Breite (cm)', type: 'number', min: 10, max: 60 },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
cornerDragStart = e.world;
|
||||
ctx.setStatus('Eck-Traverse: Richtung ziehen (dockt automatisch an)');
|
||||
},
|
||||
move(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!cornerDragStart) return;
|
||||
const armLength = (c.options.armLength as number | undefined) ?? 50;
|
||||
const cornerAngle = (c.options.angle as number | undefined) ?? 90;
|
||||
const trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
|
||||
const existing = c.doc ? (c.doc.getAllElements() as CADElement[]).filter(
|
||||
(el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner',
|
||||
) : [];
|
||||
const dock = cornerDockToDock(existing, cornerDragStart, DOCK_TOLERANCE);
|
||||
if (dock) {
|
||||
ctx.setPreview?.(makeBoxcornerElement(dock.vertex, armLength, dock.rotationDeg, cornerAngle, trussWidth, 'layer-0'));
|
||||
return;
|
||||
}
|
||||
const ang = (Math.atan2(e.world.y - cornerDragStart.y, e.world.x - cornerDragStart.x) * 180) / Math.PI;
|
||||
ctx.setPreview?.(makeBoxcornerElement(cornerDragStart, armLength, ang, cornerAngle, trussWidth, 'layer-0'));
|
||||
},
|
||||
up(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
const start = cornerDragStart;
|
||||
cornerDragStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
if (!start || !c.doc) return;
|
||||
const armLength = (c.options.armLength as number | undefined) ?? 50;
|
||||
const cornerAngle = (c.options.angle as number | undefined) ?? 90;
|
||||
const trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
|
||||
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
|
||||
const existing = (c.doc.getAllElements() as CADElement[]).filter(
|
||||
(el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner',
|
||||
);
|
||||
const dock = cornerDockToDock(existing, start, DOCK_TOLERANCE);
|
||||
let el: CADElement;
|
||||
if (dock) {
|
||||
el = makeBoxcornerElement(dock.vertex, armLength, dock.rotationDeg, cornerAngle, trussWidth, layerId);
|
||||
ctx.setStatus(`Eck-Traverse angedockt an ${dock.dockedTo}`);
|
||||
} else {
|
||||
const ang = (Math.atan2(e.world.y - start.y, e.world.x - start.x) * 180) / Math.PI;
|
||||
el = makeBoxcornerElement(start, armLength, ang, cornerAngle, trussWidth, layerId);
|
||||
ctx.setStatus(`Eck-Traverse platziert (${armLength}cm Arme, Breite ${trussWidth}cm)`);
|
||||
}
|
||||
c.doc.transact(() => { c.doc!.addElement(el); });
|
||||
},
|
||||
cancel(ctx) {
|
||||
cornerDragStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Eck-Traverse abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Task CAD-11: Kreis-Traverse
|
||||
function makeCircleTrussElement(
|
||||
center: Pt,
|
||||
diameter: number,
|
||||
trussWidth: number,
|
||||
chordCount: number,
|
||||
layerId: string,
|
||||
): CADElement {
|
||||
return {
|
||||
id: `circletruss_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`,
|
||||
type: 'circletruss',
|
||||
layerId,
|
||||
x: center.x,
|
||||
y: center.y,
|
||||
width: diameter,
|
||||
height: diameter,
|
||||
properties: {
|
||||
diameter, trussWidth, chordCount, circletruss: true,
|
||||
stroke: '#9aa0a6', strokeWidth: 2, fill: 'none',
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
export const circleTrussTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'circle-truss',
|
||||
label: 'Kreis-Traverse',
|
||||
icon: '○',
|
||||
ribbonTab: 'insert',
|
||||
tags: ['basic'],
|
||||
description: 'Kreis-Traverse platzieren (CAD-11)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'diameter', label: 'Durchmesser (cm)', type: 'number', min: 50, max: 1500 },
|
||||
{ key: 'trussWidth', label: 'Breite (cm)', type: 'number', min: 10, max: 60 },
|
||||
{ key: 'chordCount', label: 'Gurtrohre', type: 'number', min: 1, max: 4 },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) return;
|
||||
const diameter = Math.max(50, (c.options.diameter as number | undefined) ?? 200);
|
||||
const trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
|
||||
const chordCount = Math.max(1, Math.min(4, (c.options.chordCount as number | undefined) ?? 2));
|
||||
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
|
||||
const el = makeCircleTrussElement(e.world, diameter, trussWidth, chordCount, layerId);
|
||||
c.doc.transact(() => { c.doc!.addElement(el); });
|
||||
ctx.setStatus('Kreis-Traverse platziert');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const eventToolsV2Plugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'event-tools',
|
||||
@@ -431,5 +826,5 @@ export const eventToolsV2Plugin: PluginV2 = {
|
||||
category: 'elements',
|
||||
enabledByDefault: true,
|
||||
},
|
||||
tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool],
|
||||
tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool, trussTool, boxCornerTool, circleTrussTool],
|
||||
};
|
||||
|
||||
@@ -157,10 +157,12 @@ export interface ToolContext {
|
||||
export interface ToolSelectionBridge {
|
||||
/** Aktuell selektierte Element-IDs. */
|
||||
getIds(): string[];
|
||||
/** Setzt die Selektion vollständig. */
|
||||
setIds(ids: string[]): void;
|
||||
/** Setzt die Selektion vollständig (additive = zur bestehenden hinzufügen). */
|
||||
setIds(ids: string[], additive?: boolean): void;
|
||||
/** Element-Treffer an Weltposition (oder null). */
|
||||
hitTest(worldX: number, worldY: number, tolerancePx: number): CADElement | null;
|
||||
/** CAD-14: Wird bei jeder Selektionsänderung durch V2-Werkzeuge aufgerufen. */
|
||||
onChange?(ids: string[]): void;
|
||||
}
|
||||
|
||||
/** Erweiterter Kontext für Tools mit Dokument-/Selektionszugriff (A3). */
|
||||
|
||||
@@ -156,8 +156,48 @@ export class PixiRenderer implements RenderAdapter {
|
||||
this.root.addChild(g);
|
||||
}
|
||||
|
||||
drawBackground(_img: HTMLImageElement | null, _cfg: BackgroundDrawConfig): void {
|
||||
// Sprite-/Texturintegration folgt in Task C3.
|
||||
private bgSprite: unknown | null = null;
|
||||
private bgSpriteSrc = '';
|
||||
|
||||
drawBackground(img: HTMLImageElement | null, cfg: BackgroundDrawConfig): void {
|
||||
// Task CAD-6: Hintergrund-Grundriss als Sprite unter den Elementen.
|
||||
if (!this.root || !img || !cfg.src) {
|
||||
// Ohne Bild: vorhandenes Sprite entfernen (Hintergrund aus)
|
||||
if (this.bgSprite) {
|
||||
(this.bgSprite as { destroy: () => void }).destroy();
|
||||
this.bgSprite = null;
|
||||
this.bgSpriteSrc = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Bild-Cache: Neuaufbau nur bei Quellwechsel
|
||||
if (this.bgSpriteSrc !== cfg.src) {
|
||||
if (this.bgSprite) (this.bgSprite as { destroy: () => void }).destroy();
|
||||
// Lazy-Pixi (Modul-Fehler-Isolation wie in init): Sprite dynamisch
|
||||
const PixiNS = (this as unknown as { pixiNS: unknown }).pixiNS as
|
||||
{ Sprite: new (src: string) => unknown } | undefined;
|
||||
if (!PixiNS) return;
|
||||
const sprite = new PixiNS.Sprite(cfg.src);
|
||||
(sprite as unknown as { alpha: number }).alpha = cfg.opacity ?? 0.5;
|
||||
this.bgSprite = sprite;
|
||||
this.bgSpriteSrc = cfg.src;
|
||||
this.root.addChildAt(sprite as never, 0); // ganz unten
|
||||
}
|
||||
// Transform aktualisieren (Legacy-Paritaet: Zentrum bei offset, scaled)
|
||||
const s = cfg.scale ?? 1;
|
||||
const sprite = this.bgSprite as unknown as {
|
||||
position: { set: (x: number, y: number) => void };
|
||||
width: number; height: number;
|
||||
anchor: { set: (x: number, y: number) => void };
|
||||
rotation: number; scale: { set: (x: number, y: number) => void };
|
||||
alpha: number;
|
||||
};
|
||||
sprite.anchor.set(0, 0);
|
||||
sprite.width = img.width * s;
|
||||
sprite.height = img.height * s;
|
||||
sprite.position.set(cfg.offsetX ?? 0, cfg.offsetY ?? 0);
|
||||
sprite.rotation = cfg.rotation ?? 0;
|
||||
sprite.alpha = cfg.opacity ?? 0.5;
|
||||
}
|
||||
|
||||
drawRuler(_cfg: RulerConfig): void {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
* - arc-Winkel in Grad, 0° = 3 Uhr, gegen den Uhrzeigersinn
|
||||
*/
|
||||
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';
|
||||
|
||||
/** 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,
|
||||
* 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[] {
|
||||
const props = (el.properties ?? {}) as Record<string, unknown>;
|
||||
const stroke = (props.stroke as string | undefined) ?? FALLBACK_STROKE;
|
||||
@@ -66,7 +147,7 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri
|
||||
|
||||
case 'rect': {
|
||||
// Legacy-BBox-Konvention: x/y ist das Zentrum des Rechtecks
|
||||
return [{
|
||||
const base: ShapePrimitive[] = [{
|
||||
kind: 'rect',
|
||||
x: el.x - el.width / 2,
|
||||
y: el.y - el.height / 2,
|
||||
@@ -76,11 +157,12 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri
|
||||
stroke,
|
||||
strokeWidth: sw,
|
||||
}];
|
||||
return [...base, ...hatchPrimitives(el, stroke)];
|
||||
}
|
||||
|
||||
case 'circle': {
|
||||
const radius = (props.radius as number | undefined) ?? el.width / 2;
|
||||
return [{
|
||||
const circBase: ShapePrimitive[] = [{
|
||||
kind: 'circle',
|
||||
center: { x: el.x, y: el.y },
|
||||
radius,
|
||||
@@ -88,6 +170,7 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri
|
||||
stroke,
|
||||
strokeWidth: sw,
|
||||
}];
|
||||
return [...circBase, ...hatchPrimitives(el, stroke)];
|
||||
}
|
||||
|
||||
case 'arc': {
|
||||
@@ -109,13 +192,14 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri
|
||||
? (props.points as unknown[]).map(asPoint)
|
||||
: [];
|
||||
if (pts.length < 2) return [];
|
||||
return [{
|
||||
const polyBase: ShapePrimitive[] = [{
|
||||
kind: 'polyline',
|
||||
points: pts,
|
||||
close: el.type === 'polygon',
|
||||
stroke,
|
||||
strokeWidth: sw,
|
||||
}];
|
||||
return [...polyBase, ...hatchPrimitives(el, stroke)];
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
@@ -133,25 +217,75 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri
|
||||
// ── Task C3: bisher im Pixi-Pfad fehlende Typen ──
|
||||
|
||||
case 'dimension': {
|
||||
// Legacy-Konvention: x1..y2 in properties, BBox-Zentrum als x/y,
|
||||
// Extension lines + Hauptlinie; Pfeilspitzen sind Detail (C3+)
|
||||
// Task CAD-1: komplette Bemassung mit Extension lines, Masslinie,
|
||||
// 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 y1 = (props.y1 as number | undefined) ?? el.y;
|
||||
const x2 = (props.x2 as number | undefined) ?? el.x + el.width / 2;
|
||||
const y2 = (props.y2 as number | undefined) ?? el.y;
|
||||
const color = (props.color as string | undefined) ?? '#aaaaaa';
|
||||
const dimColor = (props.dimColor as string | undefined) ?? color;
|
||||
const label = String(props.value ?? props.text ?? '');
|
||||
const subtype = (props.dimSubtype as string | undefined) ?? 'horizontal';
|
||||
|
||||
// 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[] = [
|
||||
{ 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) {
|
||||
out.push({
|
||||
kind: 'text',
|
||||
at: { x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 12 },
|
||||
text: label,
|
||||
fontSize: 10,
|
||||
color: dimColor,
|
||||
at: { x: (mx1 + mx2) / 2, y: (my1 + my2) / 2 - style.textHeight * 0.6 },
|
||||
text: label, fontSize: style.textHeight * 4, color: dimColor,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -265,6 +399,125 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri
|
||||
return [];
|
||||
}
|
||||
|
||||
case 'truss': {
|
||||
// Traverse (CAD-7/8/11): ROTIERTES Rechteck-Polygon + Gurtlinien
|
||||
// entlang der Richtung (Fachwerk-Profil), Breite = trussWidth,
|
||||
// chordCount = Anzahl Gurtrohre (CAD-11).
|
||||
const length = (props.length as number | undefined) ?? el.width;
|
||||
const tWidth = (props.trussWidth as number | undefined) ?? el.height ?? 29;
|
||||
const chordCount = Math.max(1, Math.min(4, (props.chordCount as number | undefined) ?? 2));
|
||||
const rotDeg = (props.rotation as number | undefined) ?? 0;
|
||||
const rad = (rotDeg * Math.PI) / 180;
|
||||
const ux = Math.cos(rad), uy = Math.sin(rad);
|
||||
const nx = -Math.sin(rad), ny = Math.cos(rad);
|
||||
const hl = length / 2, hw = tWidth / 2;
|
||||
const c = { x: el.x, y: el.y };
|
||||
const corner = (su: number, sv: number): { x: number; y: number } => ({
|
||||
x: c.x + ux * (hl * su) + nx * (hw * sv),
|
||||
y: c.y + uy * (hl * su) + ny * (hw * sv),
|
||||
});
|
||||
const pts = [corner(-1, -1), corner(1, -1), corner(1, 1), corner(-1, 1)];
|
||||
const strokeCol = (props.stroke as string | undefined) ?? '#9aa0a6';
|
||||
const out: ShapePrimitive[] = [
|
||||
{
|
||||
kind: 'polyline',
|
||||
points: pts,
|
||||
close: true,
|
||||
stroke: strokeCol,
|
||||
strokeWidth: 2,
|
||||
},
|
||||
];
|
||||
// Gurtrohre: chordCount Linien gleichmaessig ueber die Breite verteilt
|
||||
// chordCount=1: Mittellinie, chordCount=2: bei ±hw/2, chordCount=4: bei ±hw/2 und ±hw/6
|
||||
for (let ci = 0; ci < chordCount; ci++) {
|
||||
let sv: number;
|
||||
if (chordCount === 1) sv = 0;
|
||||
else if (chordCount === 2) sv = ci === 0 ? -0.5 : 0.5;
|
||||
else if (chordCount === 3) sv = ci === 0 ? -0.67 : ci === 1 ? 0 : 0.67;
|
||||
else sv = -0.75 + ci * 0.5; // chordCount=4: -0.75, -0.25, 0.25, 0.75
|
||||
const chordPt = (su: number): { x: number; y: number } => ({
|
||||
x: c.x + ux * (hl * su) + nx * (hw * sv),
|
||||
y: c.y + uy * (hl * su) + ny * (hw * sv),
|
||||
});
|
||||
const from = chordPt(-1);
|
||||
const to = chordPt(1);
|
||||
out.push({ kind: 'line', from, to, stroke: '#4b5563', strokeWidth: 1 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'boxcorner': {
|
||||
// Eck-Traverse (CAD-8/11): L-foermiges Polygon (6 Punkte) aus zwei
|
||||
// Armen (rot und rot+angle) um den Eckpunkt, Breite = trussWidth,
|
||||
// Winkel = angle (variabel, CAD-11, Default 90).
|
||||
const armLength = (props.armLength as number | undefined) ?? 50;
|
||||
const bWidth = (props.trussWidth as number | undefined) ?? 29;
|
||||
const rotDeg = (props.rotation as number | undefined) ?? 0;
|
||||
const angleDeg = (props.angle as number | undefined) ?? 90;
|
||||
const rad = (rotDeg * Math.PI) / 180;
|
||||
const rad2 = ((rotDeg + angleDeg) * Math.PI) / 180;
|
||||
const ux = Math.cos(rad), uy = Math.sin(rad);
|
||||
const nx = -Math.sin(rad), ny = Math.cos(rad);
|
||||
const vx = { x: el.x, y: el.y };
|
||||
const px = (a: number, b: number): { x: number; y: number } => ({
|
||||
x: vx.x + ux * a + nx * b,
|
||||
y: vx.y + uy * a + ny * b,
|
||||
});
|
||||
const ax2 = Math.cos(rad2), ay2 = Math.sin(rad2);
|
||||
const pxa = (a: number, b: number): { x: number; y: number } => ({
|
||||
x: vx.x + ax2 * a - ay2 * b,
|
||||
y: vx.y + ay2 * a + ax2 * b,
|
||||
});
|
||||
// L-Geometrie: Arm1 entlang +u (rot), Arm2 entlang rot+angle
|
||||
const pts = [
|
||||
px(0, 0),
|
||||
px(armLength, 0),
|
||||
px(armLength, bWidth),
|
||||
px(bWidth, bWidth),
|
||||
pxa(bWidth, 0),
|
||||
pxa(armLength, 0),
|
||||
pxa(0, 0),
|
||||
px(0, bWidth),
|
||||
];
|
||||
const strokeCol = (props.stroke as string | undefined) ?? '#9aa0a6';
|
||||
return [{
|
||||
kind: 'polyline',
|
||||
points: pts,
|
||||
close: true,
|
||||
stroke: strokeCol,
|
||||
strokeWidth: 2,
|
||||
}];
|
||||
}
|
||||
|
||||
case 'circletruss': {
|
||||
// Kreis-Traverse (CAD-11): Aussenkreis + Innenkreis +
|
||||
// chordCount Gurtrohr-Kreise gleichmaessig verteilt.
|
||||
const diameter = (props.diameter as number | undefined) ?? el.width;
|
||||
const tWidth = (props.trussWidth as number | undefined) ?? 29;
|
||||
const chordCount = Math.max(1, Math.min(4, (props.chordCount as number | undefined) ?? 2));
|
||||
const rOuter = diameter / 2;
|
||||
const rInner = rOuter - tWidth;
|
||||
const strokeCol = (props.stroke as string | undefined) ?? '#9aa0a6';
|
||||
const out: ShapePrimitive[] = [
|
||||
{ kind: 'circle', center: { x: el.x, y: el.y }, radius: rOuter, fill: null, stroke: strokeCol, strokeWidth: 2 },
|
||||
{ kind: 'circle', center: { x: el.x, y: el.y }, radius: rInner, fill: null, stroke: strokeCol, strokeWidth: 1 },
|
||||
];
|
||||
// Gurtrohr-Kreise: gleichmaessig zwischen Aussen- und Innenkreis
|
||||
const rMid = (rOuter + rInner) / 2;
|
||||
for (let ci = 0; ci < chordCount; ci++) {
|
||||
const ang = (2 * Math.PI * ci) / chordCount;
|
||||
out.push({
|
||||
kind: 'circle',
|
||||
center: { x: el.x + Math.cos(ang) * rMid, y: el.y + Math.sin(ang) * rMid },
|
||||
radius: tWidth * 0.15,
|
||||
fill: null,
|
||||
stroke: '#4b5563',
|
||||
strokeWidth: 1,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'barrier': {
|
||||
// Absperrung (Task E7): Kette aus Kreis-Paaren (properties.circles).
|
||||
const circles = Array.isArray(props.circles)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Task CAD-12a – Autosave-Service.
|
||||
*
|
||||
* Der Yjs-CRDT speichert bereits automatisch über WebSocket → LevelDB.
|
||||
* Dieser Service ergänzt: (1) periodischen Full-Save in die SQLite-Tabellen
|
||||
* (Redundanz), (2) sichtbaren Status (Gespeichert/Speichert.../Offline),
|
||||
* (3) Reconnect-Handling (CRDT synced nach).
|
||||
*/
|
||||
|
||||
export const AUTOSAVE_INTERVAL_MS = 30_000;
|
||||
|
||||
export interface AutosaveStatus {
|
||||
state: 'idle' | 'saved' | 'saving' | 'offline';
|
||||
lastSaved: Date | null;
|
||||
}
|
||||
|
||||
export interface AutosaveDeps {
|
||||
/** Ist der CRDT-WebSocket verbunden? */
|
||||
isConnected(): boolean;
|
||||
/** Gibt es ungespeicherte Änderungen (seit letztem Full-Save)? */
|
||||
hasUnsavedChanges(): boolean;
|
||||
/** Speichert den Zustand (Promise). */
|
||||
performFullSave(): Promise<void>;
|
||||
/** Status-Callback (UI-Update). */
|
||||
onStatusChange(status: AutosaveStatus): void;
|
||||
}
|
||||
|
||||
export interface AutosaveController {
|
||||
getStatus(): AutosaveStatus;
|
||||
/** Merkt eine Änderung (nächstes Interval speichert). */
|
||||
markChanges(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function createAutosaveController(deps: AutosaveDeps): AutosaveController {
|
||||
let state: AutosaveStatus['state'] = 'idle';
|
||||
let lastSaved: Date | null = null;
|
||||
let dirty = false;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
const setStatus = (s: AutosaveStatus['state']): void => {
|
||||
state = s;
|
||||
deps.onStatusChange({ state, lastSaved });
|
||||
};
|
||||
|
||||
const tick = (): void => {
|
||||
if (destroyed) return;
|
||||
if (!deps.isConnected()) { setStatus('offline'); return; }
|
||||
if (!dirty && !deps.hasUnsavedChanges()) { setStatus('saved'); return; }
|
||||
setStatus('saving');
|
||||
deps.performFullSave()
|
||||
.then(() => {
|
||||
lastSaved = new Date();
|
||||
dirty = false;
|
||||
setStatus('saved');
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus('offline');
|
||||
});
|
||||
};
|
||||
|
||||
// Init: Status sofort melden
|
||||
if (!deps.isConnected()) setStatus('offline');
|
||||
else setStatus('saved');
|
||||
|
||||
timer = setInterval(tick, AUTOSAVE_INTERVAL_MS);
|
||||
|
||||
return {
|
||||
getStatus: () => ({ state, lastSaved }),
|
||||
markChanges: () => { dirty = true; },
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Task CAD-5 – Block-Editor-Service.
|
||||
*
|
||||
* openBlockForEdit legt markierte KOPIEN der Block-Elemente ins
|
||||
* Dokument (Original unangetastet); saveBlockEdit schreibt die
|
||||
* bearbeiteten Kopien als neue Blockdefinition zurueck und
|
||||
* raeumt die Edit-Kopien auf; discardBlockEdit verwirft alles.
|
||||
* Marker: BLOCK_EDIT_MARKER (properties-Flag + __blockEdit-IDs).
|
||||
*/
|
||||
import type { CADElement, BlockDefinition } from '../types/cad.types';
|
||||
import type { CADDocument } from '../kernel/document/CADDocument';
|
||||
|
||||
/** Marker-Flag in properties der Edit-Kopien. */
|
||||
export const BLOCK_EDIT_MARKER = '__blockEdit';
|
||||
|
||||
/** Aktive Edit-Sitzung (Service-State). */
|
||||
let editSession: { blockId: string; copyIds: string[] } | null = null;
|
||||
|
||||
/**
|
||||
* Oeffnet einen Block zur Bearbeitung: Kopien aller Elemente mit
|
||||
* Edit-Marker werden ins Dokument gelegt (Original bleibt).
|
||||
* @returns true wenn geoeffnet, false bei unbekannter ID oder
|
||||
* bereits offener Sitzung.
|
||||
*/
|
||||
export function openBlockForEdit(doc: CADDocument, blockId: string): boolean {
|
||||
if (editSession) return false;
|
||||
const block = doc.getBlock(blockId);
|
||||
if (!block) return false;
|
||||
const copyIds: string[] = [];
|
||||
doc.transact(() => {
|
||||
for (const el of block.elements) {
|
||||
const copy: CADElement = {
|
||||
...el,
|
||||
id: `__blockEdit_${blockId}_${copyIds.length}`,
|
||||
properties: {
|
||||
...(el.properties as Record<string, unknown>),
|
||||
[BLOCK_EDIT_MARKER]: true,
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(copy);
|
||||
copyIds.push(copy.id);
|
||||
}
|
||||
});
|
||||
editSession = { blockId, copyIds };
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die Bearbeitung: die markierten Kopien werden als
|
||||
* neue Elementliste in die Blockdefinition geschrieben (Marker
|
||||
* entfernt); Edit-Kopien werden aus dem Dokument geloescht.
|
||||
* @returns blockId der gespeicherten Definition oder null.
|
||||
*/
|
||||
export function saveBlockEdit(doc: CADDocument): string | null {
|
||||
if (!editSession) return null;
|
||||
const { blockId, copyIds } = editSession;
|
||||
const elements: CADElement[] = [];
|
||||
for (const id of copyIds) {
|
||||
const el = doc.getElement(id);
|
||||
if (!el) continue;
|
||||
const { [BLOCK_EDIT_MARKER]: _marker, ...cleanProps } =
|
||||
el.properties as Record<string, unknown>;
|
||||
elements.push({ ...el, id: uid('blk'), properties: cleanProps } as unknown as CADElement);
|
||||
}
|
||||
const block = doc.getBlock(blockId);
|
||||
if (block) {
|
||||
doc.addBlock({ ...block, elements });
|
||||
}
|
||||
doc.deleteElements(copyIds);
|
||||
editSession = null;
|
||||
return blockId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwirft die Bearbeitung: Edit-Kopien werden geloescht, die
|
||||
* Blockdefinition bleibt unveraendert.
|
||||
* @returns true wenn verworfen, false ohne offene Sitzung.
|
||||
*/
|
||||
export function discardBlockEdit(doc: CADDocument): boolean {
|
||||
if (!editSession) return false;
|
||||
doc.deleteElements(editSession.copyIds);
|
||||
editSession = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Ist gerade eine Block-Bearbeitung offen? */
|
||||
export function isBlockEditOpen(): boolean {
|
||||
return editSession !== null;
|
||||
}
|
||||
|
||||
let uidCounter = 0;
|
||||
function uid(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}_${uidCounter++}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Task CAD-9 – Stueckliste (BOM) fuer Traversen-Systeme.
|
||||
*
|
||||
* buildBOM gruppiert Traversen + Eck-Traversen nach Typ + Mass
|
||||
* (Laenge x Breite), zaehlt Stueckzahlen und summiert die
|
||||
* Gesamt-Traversemeter (Boxcorner zaehlt BEIDE Arme).
|
||||
* exportBOMCsv: Semikolon-separierte CSV (Excel-DE-freundlich).
|
||||
*/
|
||||
import type { CADElement } from '../types/cad.types';
|
||||
|
||||
export interface BOMRow {
|
||||
pos: number;
|
||||
type: 'Traverse' | 'Eck-Traverse' | 'Kreis-Traverse';
|
||||
/** Laenge in cm (Traverse: length; Eck-Traverse: armLength; Kreis: Durchmesser). */
|
||||
length: number;
|
||||
/** Breite in cm (trussWidth). */
|
||||
width: number;
|
||||
count: number;
|
||||
/** Gesamtlaenge dieser Position in cm. */
|
||||
totalCm: number;
|
||||
/** Bezeichnung. */
|
||||
label: string;
|
||||
/** Gurtrohr-Anzahl (optional, CAD-11). */
|
||||
chordCount?: number;
|
||||
/** Eck-Winkel in Grad (optional, CAD-11). */
|
||||
angle?: number;
|
||||
}
|
||||
|
||||
export interface BOMResult {
|
||||
rows: BOMRow[];
|
||||
/** Gesamte Traversenlaenge in Metern (Ecken zaehlen beide Arme). */
|
||||
totalLengthM: number;
|
||||
}
|
||||
|
||||
interface GroupKey {
|
||||
type: 'Traverse' | 'Eck-Traverse' | 'Kreis-Traverse';
|
||||
length: number;
|
||||
width: number;
|
||||
chordCount?: number;
|
||||
angle?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Stueckliste aus allen Zeichnungselementen.
|
||||
* Nur truss- und boxcorner-Elemente fliessen ein; alles andere
|
||||
* wird still ignoriert.
|
||||
*/
|
||||
export function buildBOM(elements: CADElement[]): BOMResult {
|
||||
const groups = new Map<string, { key: GroupKey; count: number }>();
|
||||
|
||||
for (const el of elements) {
|
||||
if (String(el.type) === 'truss' && el.properties.truss === true) {
|
||||
const length = (el.properties.length as number | undefined) ?? el.width ?? 0;
|
||||
const width = (el.properties.trussWidth as number | undefined) ?? el.height ?? 29;
|
||||
const chordCount = (el.properties.chordCount as number | undefined) ?? 2;
|
||||
const key: GroupKey = { type: 'Traverse', length, width, chordCount };
|
||||
const id = `T|${length}|${width}|${chordCount}`;
|
||||
const g = groups.get(id) ?? { key, count: 0 };
|
||||
g.count++;
|
||||
groups.set(id, g);
|
||||
} else if (String(el.type) === 'boxcorner' && el.properties.boxcorner === true) {
|
||||
const length = (el.properties.armLength as number | undefined) ?? 50;
|
||||
const width = (el.properties.trussWidth as number | undefined) ?? 29;
|
||||
const angle = (el.properties.angle as number | undefined) ?? 90;
|
||||
const key: GroupKey = { type: 'Eck-Traverse', length, width, angle };
|
||||
const id = `C|${length}|${width}|${angle}`;
|
||||
const g = groups.get(id) ?? { key, count: 0 };
|
||||
g.count++;
|
||||
groups.set(id, g);
|
||||
} else if (String(el.type) === 'circletruss' && el.properties.circletruss === true) {
|
||||
const diameter = (el.properties.diameter as number | undefined) ?? el.width;
|
||||
const width = (el.properties.trussWidth as number | undefined) ?? 29;
|
||||
const chordCount = (el.properties.chordCount as number | undefined) ?? 2;
|
||||
const key: GroupKey = { type: 'Kreis-Traverse', length: diameter, width, chordCount };
|
||||
const id = `K|${diameter}|${width}|${chordCount}`;
|
||||
const g = groups.get(id) ?? { key, count: 0 };
|
||||
g.count++;
|
||||
groups.set(id, g);
|
||||
}
|
||||
}
|
||||
|
||||
// Sortierung: Traversen vor Ecken, innerhalb Typ nach Laenge absteigend
|
||||
const entries = Array.from(groups.values());
|
||||
entries.sort((a, b) => {
|
||||
if (a.key.type !== b.key.type) {
|
||||
return a.key.type === 'Traverse' ? -1 : 1;
|
||||
}
|
||||
return b.key.length - a.key.length;
|
||||
});
|
||||
|
||||
const rows: BOMRow[] = entries.map((g, i) => {
|
||||
// Ecken haben 2 Arme: Gesamtlaenge = count * length * 2
|
||||
// Kreis-Traversen: Umfang = Pi * Durchmesser
|
||||
const isCircle = g.key.type === 'Kreis-Traverse';
|
||||
const armFactor = g.key.type === 'Eck-Traverse' ? 2 : 1;
|
||||
const perPieceCm = isCircle ? Math.PI * g.key.length : g.key.length * armFactor;
|
||||
const totalCm = g.count * perPieceCm;
|
||||
const detailParts: string[] = [];
|
||||
if (g.key.chordCount !== undefined && g.key.chordCount > 0) detailParts.push(`${g.key.chordCount}-rohr`);
|
||||
if (g.key.angle !== undefined && g.key.angle !== 90) detailParts.push(`${g.key.angle}°`);
|
||||
const detailSuffix = detailParts.length > 0 ? ` (${detailParts.join(', ')})` : '';
|
||||
return {
|
||||
pos: i + 1,
|
||||
type: g.key.type,
|
||||
length: g.key.length,
|
||||
width: g.key.width,
|
||||
count: g.count,
|
||||
totalCm,
|
||||
label: `${g.key.type} ${isCircle ? '⌀' : ''}${g.key.length} × ${g.key.width}${detailSuffix}`,
|
||||
chordCount: g.key.chordCount,
|
||||
angle: g.key.angle,
|
||||
};
|
||||
});
|
||||
|
||||
const totalCmAll = rows.reduce((s, r) => s + r.totalCm, 0);
|
||||
return {
|
||||
rows,
|
||||
totalLengthM: totalCmAll / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportiert die Stueckliste als Semikolon-CSV (Excel-DE oeffnet
|
||||
* das direkt mehrspaltig). Spalten: Pos;Typ;Laenge_cm;Breite_cm;
|
||||
* Anzahl;Gesamt_cm;Bezeichnung + Summenzeile am Ende.
|
||||
*/
|
||||
export function exportBOMCsv(bom: BOMResult): string {
|
||||
const lines: string[] = ['Pos;Typ;Laenge_cm;Breite_cm;Anzahl;Gesamt_cm;Bezeichnung'];
|
||||
for (const r of bom.rows) {
|
||||
lines.push(
|
||||
`${r.pos};${r.type};${r.length};${r.width};${r.count};${r.totalCm};${r.label}`,
|
||||
);
|
||||
}
|
||||
if (bom.rows.length > 0) {
|
||||
lines.push(`;;;SUMME;;;${bom.totalLengthM.toFixed(2)} m`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Task CAD-12b – Globale Keyboard-Shortcuts fuer den Editor.
|
||||
*
|
||||
* ESC = aktives Tool abbrechen
|
||||
* Delete/Backspace = Auswahl loeschen
|
||||
* Strg+A = alles auswaehlen
|
||||
* Strg+D = Auswahl duplizieren
|
||||
* F7 = Grid toggle, F8 = Ortho toggle, F3 = Snap toggle
|
||||
*
|
||||
* Input-Felder (INPUT, TEXTAREA, contentEditable) werden ignoriert.
|
||||
*/
|
||||
|
||||
export interface ShortcutAction {
|
||||
escape: () => void;
|
||||
delete: () => void;
|
||||
selectAll: () => void;
|
||||
duplicate: () => void;
|
||||
toggleGrid: () => void;
|
||||
toggleOrtho: () => void;
|
||||
toggleSnap: () => void;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcutsController {
|
||||
handler: (e: KeyboardEvent) => void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function createKeyboardShortcuts(actions: ShortcutAction): KeyboardShortcutsController {
|
||||
const isInputElement = (target: EventTarget | null): boolean => {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
const tag = el.tagName?.toUpperCase() ?? '';
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable === true;
|
||||
};
|
||||
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
if (isInputElement(e.target)) return;
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
actions.escape();
|
||||
break;
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
e.preventDefault();
|
||||
actions.delete();
|
||||
break;
|
||||
case 'a':
|
||||
case 'A':
|
||||
if (ctrl) { e.preventDefault(); actions.selectAll(); }
|
||||
break;
|
||||
case 'd':
|
||||
case 'D':
|
||||
if (ctrl) { e.preventDefault(); actions.duplicate(); }
|
||||
break;
|
||||
case 'F7':
|
||||
e.preventDefault();
|
||||
actions.toggleGrid();
|
||||
break;
|
||||
case 'F8':
|
||||
e.preventDefault();
|
||||
actions.toggleOrtho();
|
||||
break;
|
||||
case 'F3':
|
||||
e.preventDefault();
|
||||
actions.toggleSnap();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handler,
|
||||
destroy: () => {
|
||||
// cleanup: caller removes listener
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* CAD-14 Phase 4a - toolOptionsService.
|
||||
*
|
||||
* Zentraler Store fuer Werkzeug-Optionen (Laenge, Breite, Gurtrohre,
|
||||
* Winkel, Durchmesser...). Der InteractionDispatcher liest darueber
|
||||
* getOptions; die UI (OptionsPanel) schreibt hier hinein. Defaults
|
||||
* stammen aus dem optionsSchema der V2-Werkzeuge.
|
||||
*/
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
/** Per-Tool gesetzte Werte (override Defaults). */
|
||||
const values = new Map<string, Record<string, unknown>>();
|
||||
/** Per-Tool Defaults (aus optionsSchema gefuellt von der Registry/UI). */
|
||||
const defaults = new Map<string, Record<string, unknown>>();
|
||||
/** Abonnenten (UI-Update bei Aenderung). */
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function notify(): void {
|
||||
for (const l of Array.from(listeners)) l();
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet sinnvolle Defaults aus einem optionsSchema ab:
|
||||
* number -> default ?? min (geclamped), color -> '#9aa0a6', sonst undefined.
|
||||
*/
|
||||
export function defaultsFromSchema(schema: Array<{ key: string; type: string; default?: unknown; min?: number }>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of schema) {
|
||||
if (f.default !== undefined) { out[f.key] = f.default; continue; }
|
||||
if (f.type === 'number') {
|
||||
out[f.key] = typeof f.min === 'number' ? Math.max(f.min, f.min === 0 ? 0 : Math.round(f.min)) : 0;
|
||||
} else if (f.type === 'color') {
|
||||
out[f.key] = '#9aa0a6';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Registriert Defaults fuer ein Werkzeug (aus optionsSchema). */
|
||||
export function setToolOptionsDefaults(toolId: string, defs: Record<string, unknown>): void {
|
||||
defaults.set(toolId, { ...(defaults.get(toolId) ?? {}), ...defs });
|
||||
}
|
||||
|
||||
export function getToolOption<T = unknown>(toolId: string, key: string, fallback?: T): T {
|
||||
const v = values.get(toolId)?.[key];
|
||||
if (v !== undefined) return v as T;
|
||||
const d = defaults.get(toolId)?.[key];
|
||||
if (d !== undefined) return d as T;
|
||||
return fallback as T;
|
||||
}
|
||||
|
||||
export function setToolOption(toolId: string, key: string, value: unknown): void {
|
||||
const cur = values.get(toolId) ?? {};
|
||||
cur[key] = value;
|
||||
values.set(toolId, cur);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function getToolOptions(toolId: string): Record<string, unknown> {
|
||||
return { ...(defaults.get(toolId) ?? {}), ...(values.get(toolId) ?? {}) };
|
||||
}
|
||||
|
||||
export function subscribeToolOptions(fn: Listener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
|
||||
export function resetToolOptions(): void {
|
||||
values.clear();
|
||||
defaults.clear();
|
||||
}
|
||||
+168
-5
@@ -335,7 +335,10 @@ a:hover { text-decoration: underline; }
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: var(--ribbon-h);
|
||||
height: auto;
|
||||
min-height: var(--ribbon-h);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
z-index: 25;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -370,20 +373,22 @@ a:hover { text-decoration: underline; }
|
||||
.ribbon-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
padding: 6px 8px;
|
||||
gap: var(--spacing-lg);
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ribbon-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 4px 0;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
.ribbon-group-btns { display: flex; align-items: center; gap: 2px; flex: 1; }
|
||||
.ribbon-group-btns { display: flex; align-items: center; gap: 2px; flex: 1; flex-wrap: wrap; justify-content: center; }
|
||||
.ribbon-group-label {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
@@ -2974,3 +2979,161 @@ body.drawer-open { overflow: hidden; }
|
||||
border-radius: 4px;
|
||||
}
|
||||
.layout-bar-print:hover { color: #4a90d9; background: rgba(74, 144, 217, 0.15); }
|
||||
|
||||
/* ─── Task CAD-9 – BOM-Dialog ─── */
|
||||
.bom-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.bom-dialog {
|
||||
background: #1e1e2e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
min-width: 640px;
|
||||
max-width: 800px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.bom-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.bom-dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.bom-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.bom-close:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.bom-empty {
|
||||
color: #888;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.bom-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.bom-table th,
|
||||
.bom-table td {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
.bom-table th {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #9aa0a6;
|
||||
font-weight: 600;
|
||||
}
|
||||
.bom-table td {
|
||||
color: #d0d0d0;
|
||||
}
|
||||
.bom-sum-row td {
|
||||
background: rgba(74, 144, 217, 0.1);
|
||||
}
|
||||
.bom-dialog-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.bom-btn-primary {
|
||||
background: #4a90d9;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.bom-btn-primary:hover {
|
||||
background: #5ba0e9;
|
||||
}
|
||||
.bom-btn-secondary {
|
||||
background: transparent;
|
||||
color: #9aa0a6;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.bom-btn-secondary:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ─── CAD-10: Zeichnen-Tab V2-Buttons ─── */
|
||||
.ribbon-draw-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 2px 0;
|
||||
width: 100%;
|
||||
}
|
||||
.ribbon-v2-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 52px;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text, #d0d0d0);
|
||||
}
|
||||
.ribbon-v2-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.ribbon-v2-btn.active {
|
||||
background: rgba(74, 144, 217, 0.15);
|
||||
border-color: rgba(74, 144, 217, 0.5);
|
||||
}
|
||||
.ribbon-v2-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.ribbon-v2-label {
|
||||
font-size: 9px;
|
||||
color: var(--color-text-secondary, #9aa0a6);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ribbon-v2-btn.active .ribbon-v2-label {
|
||||
color: #4a90d9;
|
||||
}
|
||||
|
||||
/* CAD-14 Phase 4a: Werkzeug-Optionen */
|
||||
.tool-options-panel { padding: 8px 4px; }
|
||||
.toolopt-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 8px; padding: 4px 0; font-size: 12px; color: var(--color-text, #d0d0d0);
|
||||
}
|
||||
.toolopt-row input[type=number], .toolopt-row select {
|
||||
width: 90px; padding: 3px 6px; background: rgba(255,255,255,0.06);
|
||||
color: #d0d0d0; border: 1px solid rgba(255,255,255,0.15); border-radius: 4px;
|
||||
}
|
||||
.toolopt-row input[type=color] { width: 36px; height: 24px; border: none; background: transparent; cursor: pointer; }
|
||||
|
||||
@@ -7,7 +7,7 @@ export type ElementType =
|
||||
| 'polyline' | 'text' | 'dimension' | 'block_instance' | 'chair'
|
||||
| 'seating-row' | 'seating-block' | 'table' | 'stage'
|
||||
| 'leader' | 'revcloud' | 'image'
|
||||
| 'curtain' | 'spotlight' | 'barrier';
|
||||
| 'curtain' | 'spotlight' | 'barrier' | 'truss' | 'boxcorner' | 'circletruss';
|
||||
|
||||
export type LineType = 'solid' | 'dashed' | 'dotted';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { UserCursor } from '../crdt';
|
||||
import type { UnitType } from '../utils/format';
|
||||
|
||||
export type ViewMode = '2d' | 'iso' | 'front' | 'top';
|
||||
export type RibbonTab = 'start' | 'insert' | 'format' | 'view' | 'canvas' | 'tools' | 'ki';
|
||||
export type RibbonTab = 'start' | 'view' | 'ki';
|
||||
export type RightPanel = 'tool' | 'layer' | 'library' | 'ki';
|
||||
export type Theme = 'light' | 'dark';
|
||||
export type DrawerTab = 'tool' | 'layer' | 'library' | 'ki';
|
||||
@@ -172,6 +172,8 @@ export interface RightSidebarProps {
|
||||
layers: CADLayer[];
|
||||
elements?: CADElement[];
|
||||
blocks: BlockDefinition[];
|
||||
/** CAD-14 Phase 4a: Aktives V2-Werkzeug (fuer Werkzeug-Optionen-Panel). */
|
||||
activeToolId?: string | null;
|
||||
activeLayerId?: string;
|
||||
onSelectLayer?: (id: string) => void;
|
||||
onAddLayer?: () => void;
|
||||
|
||||
@@ -59,9 +59,9 @@ describe('RibbonBar', () => {
|
||||
it('should call onTabChange when clicking a tab', () => {
|
||||
const onTabChange = vi.fn();
|
||||
render(<RibbonBar activeTab="start" onTabChange={onTabChange} onAction={() => {}} />);
|
||||
// Click the 'Format' tab (unique text)
|
||||
fireEvent.click(screen.getByText('Format'));
|
||||
expect(onTabChange).toHaveBeenCalledWith('format');
|
||||
// CAD-13-fix: Werkzeuge nur noch in Sidebar — Ribbon hat Start/Ansicht/KI
|
||||
fireEvent.click(screen.getByText('Ansicht'));
|
||||
expect(onTabChange).toHaveBeenCalledWith('view');
|
||||
});
|
||||
|
||||
it('should call onAction when clicking an action button', () => {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Task CAD-12a - Autosave-Service.
|
||||
*
|
||||
* Der Yjs-CRDT speichert bereits automatisch ueber WebSocket.
|
||||
* Dieser Service macht das SICHTBAR und ergaenzt:
|
||||
* - Save-Status-Text (Automatisch gespeichert / Offline / Speichert...)
|
||||
* - Periodischer Full-Save in SQLite (Redundanz)
|
||||
* - Reconnect-Handling (CRDT synced automatisch nach)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
createAutosaveController,
|
||||
type AutosaveStatus,
|
||||
AUTOSAVE_INTERVAL_MS,
|
||||
} from '../src/services/autosaveService';
|
||||
|
||||
describe('CAD-12a: autosaveService', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('AUTOSAVE_INTERVAL_MS ist 30 Sekunden', () => {
|
||||
expect(AUTOSAVE_INTERVAL_MS).toBe(30000);
|
||||
});
|
||||
|
||||
it('Controller: Status ist "idle" wenn kein CRDT verbunden', () => {
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => false,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: vi.fn(),
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
const status: AutosaveStatus = ctrl.getStatus();
|
||||
expect(status.state).toBe('offline');
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: Status ist "saved" wenn CRDT verbunden und keine ungespeicherten Aenderungen', () => {
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: vi.fn(),
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
expect(ctrl.getStatus().state).toBe('saved');
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: Status wechselt zu "saving" wenn Aenderungen vorhanden und Timer feuert', () => {
|
||||
const fullSave = vi.fn().mockResolvedValue(undefined);
|
||||
const onStatus = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => true,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: onStatus,
|
||||
});
|
||||
vi.advanceTimersByTime(31000);
|
||||
expect(fullSave).toHaveBeenCalledTimes(1);
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: fuehrt KEINEN Full-Save aus wenn keine Aenderungen', () => {
|
||||
const fullSave = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
vi.advanceTimersByTime(31000);
|
||||
expect(fullSave).not.toHaveBeenCalled();
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: fuehrt KEINEN Full-Save aus wenn CRDT offline', () => {
|
||||
const fullSave = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => false,
|
||||
hasUnsavedChanges: () => true,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
vi.advanceTimersByTime(31000);
|
||||
expect(fullSave).not.toHaveBeenCalled();
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: Status-Meldung an onStatusChange bei Aenderung', () => {
|
||||
const onStatus = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: vi.fn(),
|
||||
onStatusChange: onStatus,
|
||||
});
|
||||
expect(onStatus).toHaveBeenCalledWith({ state: 'saved', lastSaved: null });
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: markChanges triggert saving beim naechsten Timer-Tick', () => {
|
||||
const fullSave = vi.fn().mockResolvedValue(undefined);
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
ctrl.markChanges();
|
||||
vi.advanceTimersByTime(31000);
|
||||
expect(fullSave).toHaveBeenCalled();
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: destroy stoppt den Timer', () => {
|
||||
const fullSave = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => true,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
ctrl.destroy();
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(fullSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Task CAD-6 - Hintergrund-Grundrisse mit Maszstab rendern.
|
||||
*
|
||||
* bgConfig (App-State) wird an CanvasArea uebergeben aber dort
|
||||
* NIE an den RenderEngine weitergegeben — der Hintergrund wird
|
||||
* importiert aber nicht gezeichnet. Fix: CanvasArea-Effect
|
||||
* setzt bgConfig in renderEngine.setOptions (backgroundSrc,
|
||||
* scale, offset, rotation, opacity).
|
||||
* PixiRenderer.drawBackground: Sprite-Implementierung.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { PixiRenderer } from '../src/render/pixi/PixiRenderer';
|
||||
|
||||
// ─── PixiRenderer.drawBackground (Sprite-Implementierung) ─────
|
||||
|
||||
describe('CAD-6: PixiRenderer.drawBackground', () => {
|
||||
it('drawBackground mit img erzeugt ein Sprite im worldLayer (nicht mehr leer)', async () => {
|
||||
const pixi = new PixiRenderer();
|
||||
// Ohne init: drawBackground sollte still zurueckkehren (defensiv)
|
||||
expect(() => pixi.drawBackground(null, {})).not.toThrow();
|
||||
});
|
||||
|
||||
it('drawBackground mit cfg aber ohne img: kein Sprite (still)', () => {
|
||||
const pixi = new PixiRenderer();
|
||||
expect(() => pixi.drawBackground(null, { src: 'test', opacity: 0.5, offsetX: 0, offsetY: 0, scale: 1, rotation: 0 })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CanvasArea: bgConfig-Effect (setOptions-Verkabelung) ────
|
||||
|
||||
describe('CAD-6: bgConfig wird an RenderEngine weitergegeben', () => {
|
||||
it('CanvasArea-Quelle enthaelt bgConfig-Verarbeitung in setOptions', async () => {
|
||||
const src = await import('fs').then(m => m.readFileSync('src/components/CanvasArea.tsx', 'utf-8'));
|
||||
// bgConfig-Prop muss genutzt werden: backgroundSrc in setOptions
|
||||
expect(src).toContain('bgConfig');
|
||||
expect(src).toMatch(/backgroundSrc.*bgConfig|bgConfig.*backgroundSrc/s);
|
||||
});
|
||||
|
||||
it('CanvasArea-Quelle: alle bgConfig-Felder in setOptions (src/scale/offset/rotation/opacity)', async () => {
|
||||
const src = await import('fs').then(m => m.readFileSync('src/components/CanvasArea.tsx', 'utf-8'));
|
||||
expect(src).toContain('backgroundScale');
|
||||
expect(src).toContain('backgroundOffsetX');
|
||||
expect(src).toContain('backgroundOffsetY');
|
||||
expect(src).toContain('backgroundRotation');
|
||||
expect(src).toContain('backgroundOpacity');
|
||||
});
|
||||
|
||||
it('RenderEngine hat funktionierende drawBackground mit Transform (Legacy-Paritaet)', async () => {
|
||||
const src = await import('fs').then(m => m.readFileSync('src/canvas/RenderEngine.ts', 'utf-8'));
|
||||
expect(src).toContain('drawImage');
|
||||
expect(src).toContain('globalAlpha');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Task CAD-9 - Stueckliste (BOM) fuer Traversen.
|
||||
*
|
||||
* buildBOM: gruppiert Traversen + Boxcorner nach Typ + Mass
|
||||
* (Laenge x Breite), zaehlt Stueckzahlen, summiert Gesamt-meter.
|
||||
* exportBOMCsv: saubere CSV mit Kopfzeile fuer Excel-Import.
|
||||
* Dialog zeigt Tabelle + CSV-Download.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildBOM, exportBOMCsv, type BOMRow } from '../src/services/bomService';
|
||||
import type { CADElement } from '../src/types/cad.types';
|
||||
|
||||
function truss(id: string, x: number, y: number, length: number, rotation: number, width = 29): CADElement {
|
||||
return {
|
||||
id, type: 'truss', layerId: 'l', x, y, width: length, height: width,
|
||||
properties: { length, rotation, trussWidth: width, truss: true },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
function corner(id: string, x: number, y: number, armLength: number, rotation: number, width = 29): CADElement {
|
||||
return {
|
||||
id, type: 'boxcorner', layerId: 'l', x, y, width: 80, height: 80,
|
||||
properties: { armLength, rotation, trussWidth: width, boxcorner: true },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
function chair(id: string): CADElement {
|
||||
return {
|
||||
id, type: 'chair', layerId: 'l', x: 0, y: 0, width: 45, height: 45,
|
||||
properties: {},
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
// ─── buildBOM: Gruppierung ──────────────────────────────────
|
||||
|
||||
describe('CAD-9: buildBOM (Gruppierung + Zaehlung)', () => {
|
||||
it('zwei identische Traversen (200x29) ergeben EINE Position mit Anzahl 2', () => {
|
||||
const els = [
|
||||
truss('t1', 0, 0, 200, 0),
|
||||
truss('t2', 100, 100, 200, 90),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
const rows = bom.rows.filter((r) => r.type === 'Traverse');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].count).toBe(2);
|
||||
});
|
||||
|
||||
it('unterschiedliche Laengen ergetrennte Positionen', () => {
|
||||
const els = [
|
||||
truss('t1', 0, 0, 200, 0),
|
||||
truss('t2', 0, 0, 100, 0),
|
||||
truss('t3', 0, 0, 300, 0),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
const trussRows = bom.rows.filter((r) => r.type === 'Traverse');
|
||||
expect(trussRows).toHaveLength(3);
|
||||
const counts = trussRows.map((r) => r.count);
|
||||
expect(counts).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it('unterschiedliche Breiten = getrennte Positionen (100x29 vs 100x40)', () => {
|
||||
const els = [
|
||||
truss('t1', 0, 0, 100, 0, 29),
|
||||
truss('t2', 0, 0, 100, 0, 40),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
expect(bom.rows.filter((r) => r.type === 'Traverse')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Boxcorner als eigener Typ mit armLength als Laenge', () => {
|
||||
const els = [
|
||||
corner('c1', 0, 0, 50, 0),
|
||||
corner('c2', 100, 100, 50, 90),
|
||||
corner('c3', 200, 200, 75, 0),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
const cornerRows = bom.rows.filter((r) => r.type === 'Eck-Traverse');
|
||||
expect(cornerRows).toHaveLength(2); // 50cm x2 + 75cm x1
|
||||
const r50 = cornerRows.find((r) => r.length === 50)!;
|
||||
expect(r50.count).toBe(2);
|
||||
});
|
||||
|
||||
it('Nicht-Traversen (chair etc.) werden ignoriert', () => {
|
||||
const els = [chair('s1'), truss('t1', 0, 0, 200, 0)];
|
||||
const bom = buildBOM(els);
|
||||
expect(bom.rows.every((r) => r.type !== 'chair')).toBe(true);
|
||||
});
|
||||
|
||||
it('leere Zeichnung: leere Stueckliste ohne Crash', () => {
|
||||
const bom = buildBOM([]);
|
||||
expect(bom.rows).toHaveLength(0);
|
||||
expect(bom.totalLengthM).toBe(0);
|
||||
});
|
||||
|
||||
it('Gesamtlaenge: 200+100+300cm Traversen + 2x50cm Ecken = 7.00m', () => {
|
||||
const els = [
|
||||
truss('t1', 0, 0, 200, 0),
|
||||
truss('t2', 0, 0, 100, 0),
|
||||
truss('t3', 0, 0, 300, 0),
|
||||
corner('c1', 0, 0, 50, 0),
|
||||
corner('c2', 0, 0, 50, 90),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
// Traversen 6.00m + 2 Ecken je 2 Arme = 8.00m
|
||||
expect(bom.totalLengthM).toBeCloseTo(8.0, 2);
|
||||
});
|
||||
|
||||
it('Boxcorner zaehlt BEIDE Arme in die Gesamtlaenge (50er-Ecke = 1.00m)', () => {
|
||||
const els = [corner('c1', 0, 0, 50, 0)];
|
||||
const bom = buildBOM(els);
|
||||
expect(bom.totalLengthM).toBeCloseTo(1.0, 2);
|
||||
});
|
||||
|
||||
it('Positionen sind sortiert: Traversen vor Ecken, dann nach Laenge absteigend', () => {
|
||||
const els = [
|
||||
corner('c1', 0, 0, 50, 0),
|
||||
truss('t1', 0, 0, 100, 0),
|
||||
truss('t2', 0, 0, 300, 0),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
const types = bom.rows.map((r) => r.type);
|
||||
// Traversen zuerst (nach Laenge absteigend), dann Ecken
|
||||
expect(types.indexOf('Traverse')).toBeLessThan(types.indexOf('Eck-Traverse'));
|
||||
const trussLens = bom.rows.filter((r) => r.type === 'Traverse').map((r) => r.length);
|
||||
expect(trussLens[0]).toBeGreaterThanOrEqual(trussLens[trussLens.length - 1]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── exportBOMCsv ─────────────────────────────────────────
|
||||
|
||||
describe('CAD-9: exportBOMCsv', () => {
|
||||
it('CSV hat Kopfzeile und Zeilen pro Position mit Semikolon/ Komma', () => {
|
||||
const els = [truss('t1', 0, 0, 200, 0), truss('t2', 0, 0, 200, 90)];
|
||||
const bom = buildBOM(els);
|
||||
const csv = exportBOMCsv(bom);
|
||||
const lines = csv.split('\n');
|
||||
expect(lines[0]).toContain('Pos');
|
||||
expect(lines[0]).toContain('Typ');
|
||||
expect(lines[0]).toContain('Anzahl');
|
||||
expect(lines).toHaveLength(2 + 1); // Header + 1 Position + ggf. Summenzeile
|
||||
});
|
||||
|
||||
it('CSV enthaelt Laenge und Anzahl der Position', () => {
|
||||
const els = [truss('t1', 0, 0, 200, 0), truss('t2', 0, 0, 200, 0)];
|
||||
const csv = exportBOMCsv(buildBOM(els));
|
||||
expect(csv).toContain('200');
|
||||
expect(csv).toContain('2');
|
||||
});
|
||||
|
||||
it('CSV: Semikolon-Separatoren (Excel-DE-freundlich) oder Komma + konsistent', () => {
|
||||
const csv = exportBOMCsv(buildBOM([truss('t1', 0, 0, 200, 0)]));
|
||||
// Konsistenz: Jede Datenzeile hat dieselbe Anzahl Separatoren
|
||||
const lines = csv.trim().split('\n');
|
||||
const sepCounts = lines.map((l) => (l.match(/;/g) || []).length);
|
||||
expect(new Set(sepCounts).size).toBe(1);
|
||||
});
|
||||
|
||||
it('leere Stueckliste: nur Kopfzeile', () => {
|
||||
const csv = exportBOMCsv(buildBOM([]));
|
||||
expect(csv.trim().split('\n')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -113,14 +113,16 @@ describe('elementToPrimitives', () => {
|
||||
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', {
|
||||
x1: 0, y1: 0, x2: 100, y2: 0, value: '250 cm',
|
||||
}, { x: 50, y: 0 }));
|
||||
expect(prims.length).toBe(2);
|
||||
expect(prims[0].kind).toBe('line');
|
||||
const txt = prims[1];
|
||||
if (txt.kind !== 'text') throw new Error('erwartete text');
|
||||
// CAD-1: 2 Extension lines + 1 Masslinie + 2 Pfeilspitzen + 1 Text = 6
|
||||
expect(prims.length).toBe(6);
|
||||
expect(prims.filter((p) => p.kind === 'line').length).toBe(3);
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Task CAD-12b - Globale Keyboard-Shortcuts fuer den Editor.
|
||||
*
|
||||
* ESC = aktives Tool abbrechen, Delete/Entf = Auswahl loeschen,
|
||||
* Strg+A = alles auswaehlen, Strg+D = Auswahl duplizieren,
|
||||
* F7 = Grid toggle, F8 = Ortho toggle, F3 = Snap toggle.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createKeyboardShortcuts, type ShortcutAction } from '../src/services/keyboardShortcuts';
|
||||
describe('CAD-12b: keyboardShortcuts', () => {
|
||||
let actions: Record<string, ReturnType<typeof vi.fn>>;
|
||||
let handler: (e: KeyboardEvent) => void;
|
||||
let cleanup: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
actions = {
|
||||
escape: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
selectAll: vi.fn(),
|
||||
duplicate: vi.fn(),
|
||||
toggleGrid: vi.fn(),
|
||||
toggleOrtho: vi.fn(),
|
||||
toggleSnap: vi.fn(),
|
||||
};
|
||||
const ctrl = createKeyboardShortcuts(actions as unknown as ShortcutAction);
|
||||
handler = ctrl.handler;
|
||||
cleanup = ctrl.destroy;
|
||||
});
|
||||
|
||||
afterEach(() => { cleanup(); });
|
||||
|
||||
const fire = (key: string, opts: { ctrl?: boolean; shift?: boolean } = {}) => {
|
||||
handler({ key, ctrlKey: opts.ctrl ?? false, shiftKey: opts.shift ?? false, preventDefault: vi.fn() } as unknown as KeyboardEvent);
|
||||
};
|
||||
|
||||
it('ESC ruft escape auf (Tool abbrechen)', () => {
|
||||
fire('Escape');
|
||||
expect(actions.escape).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Delete und Entf rufen delete auf (Auswahl loeschen)', () => {
|
||||
fire('Delete');
|
||||
expect(actions.delete).toHaveBeenCalledTimes(1);
|
||||
fire('Backspace');
|
||||
expect(actions.delete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Strg+A ruft selectAll auf', () => {
|
||||
fire('a', { ctrl: true });
|
||||
expect(actions.selectAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Strg+D ruft duplicate auf', () => {
|
||||
fire('d', { ctrl: true });
|
||||
expect(actions.duplicate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('F7 ruft toggleGrid auf', () => {
|
||||
fire('F7');
|
||||
expect(actions.toggleGrid).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('F8 ruft toggleOrtho auf', () => {
|
||||
fire('F8');
|
||||
expect(actions.toggleOrtho).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('F3 ruft toggleSnap auf', () => {
|
||||
fire('F3');
|
||||
expect(actions.toggleSnap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Buchstaben ohne Strg loesen KEINE Aktion aus', () => {
|
||||
fire('a');
|
||||
fire('d');
|
||||
fire('x');
|
||||
expect(actions.selectAll).not.toHaveBeenCalled();
|
||||
expect(actions.duplicate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Input-Feld-Fokus wird ignoriert (kein Shortcut wenn in Input/Textarea)', () => {
|
||||
// Simuliere: Event-Target ist ein Input-Element
|
||||
const inputEvent = {
|
||||
key: 'a',
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
target: { tagName: 'INPUT', isContentEditable: false },
|
||||
} as unknown as KeyboardEvent & { target: { tagName: string } };
|
||||
handler(inputEvent);
|
||||
expect(actions.selectAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Task CAD-5 - MLINE (Mehrfachlinie) + Block-Editor.
|
||||
*
|
||||
* MLINE: Zwei parallele Linien (Wand/Rohr) mit gap-Offset ±gap/2
|
||||
* entlang der Normalen, optionale Endkappen, ENTER-Commit ab
|
||||
* 2 Punkten, ALLES in EINER Transaktion.
|
||||
* Block-Editor: openBlockForEdit (markierte Kopien), saveBlockEdit
|
||||
* (zurueckschreiben + aufraeumen), discard, blockEditTool-Flow.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mlineTool } from '../src/plugins/builtin/core-drawing';
|
||||
import { blockEditTool, BLOCK_EDIT_MARKER } from '../src/plugins/builtin/core-modify';
|
||||
import {
|
||||
openBlockForEdit,
|
||||
saveBlockEdit,
|
||||
discardBlockEdit,
|
||||
} from '../src/services/blockEditorService';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import type { CADElement, Pt, BlockDefinition } 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;
|
||||
}
|
||||
|
||||
function key(k: string): { key: string } {
|
||||
return { key: k } as never;
|
||||
|
||||
}
|
||||
|
||||
const BLOCK: BlockDefinition = {
|
||||
id: 'blk-1', name: 'Muster', description: '', category: 'test',
|
||||
elements: [
|
||||
{ id: 'e1', type: 'rect', layerId: 'l', x: 50, y: 25, width: 100, height: 50, properties: {} },
|
||||
{ id: 'e2', type: 'line', layerId: 'l', x: 50, y: 50, width: 100, height: 0, properties: { x1: 0, y1: 50, x2: 100, y2: 50 } },
|
||||
],
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
// Service-State-Leak verhindern: offene Edit-Sitzungen immer verwerfen
|
||||
// (Dummy-Doc: deleteElements ignoriert fehlende IDs still)
|
||||
const { ydoc: y2 } = createInMemoryDoc();
|
||||
discardBlockEdit(new CADDocument(y2));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mlineTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
blockEditTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
// ─── MLINE ────────────────────────────────────────────────────
|
||||
|
||||
describe('CAD-5: mlineTool', () => {
|
||||
it('horizontale Mittellinie: 2 parallele Linien mit gap/2 Abstand', () => {
|
||||
const { ctx, doc } = mkCtx({ gap: 20 });
|
||||
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
mlineTool.handlers.down!(pe(100, 0), ctx);
|
||||
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||
const lines = doc.getAllElements().filter((e) => e.type === 'polyline');
|
||||
expect(lines).toHaveLength(2);
|
||||
const ys = lines.map((l) => (l.properties.points as Pt[])[0].y).sort();
|
||||
expect(ys[0]).toBeCloseTo(-10, 5);
|
||||
expect(ys[1]).toBeCloseTo(10, 5);
|
||||
});
|
||||
|
||||
it('gap-Option steuert den Abstand (gap=10 → ±5)', () => {
|
||||
const { ctx, doc } = mkCtx({ gap: 10 });
|
||||
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
mlineTool.handlers.down!(pe(50, 0), ctx);
|
||||
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||
const lines = doc.getAllElements().filter((e) => e.type === 'polyline');
|
||||
const ys = lines.map((l) => (l.properties.points as Pt[])[0].y).sort();
|
||||
expect(ys[0]).toBeCloseTo(-5, 5);
|
||||
expect(ys[1]).toBeCloseTo(5, 5);
|
||||
});
|
||||
|
||||
it('Endkappen-Option: 2 zusaetzliche Linien an Start und Ende', () => {
|
||||
const { ctx, doc } = mkCtx({ gap: 20, cap: true });
|
||||
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
mlineTool.handlers.down!(pe(100, 0), ctx);
|
||||
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||
const caps = doc.getAllElements().filter((e) => e.properties.mlineCap);
|
||||
expect(caps).toHaveLength(2);
|
||||
// Startkappe verbindet beide Linien (y=±10) bei x=0 — Zuordnung a/b
|
||||
// ist Vorzeichenkonvention, daher reihenfolge-agnostisch pruefen:
|
||||
const startCap = caps.find((c) => (c.properties.x1 as number) === 0);
|
||||
expect(startCap).toBeDefined();
|
||||
const ys = [startCap!.properties.y1, startCap!.properties.y2].sort((p, q) => (p as number) - (q as number)) as number[];
|
||||
expect(ys[0]).toBeCloseTo(-10, 5);
|
||||
expect(ys[1]).toBeCloseTo(10, 5);
|
||||
});
|
||||
|
||||
it('ohne cap: nur 2 Linien, keine Kappen', () => {
|
||||
const { ctx, doc } = mkCtx({ gap: 20 });
|
||||
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
mlineTool.handlers.down!(pe(100, 0), ctx);
|
||||
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.properties.mlineCap)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('EINE Transaktion: 1 Undo entfernt alle MLINE-Teile', () => {
|
||||
const { ctx, doc } = mkCtx({ gap: 20, cap: true });
|
||||
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
mlineTool.handlers.down!(pe(100, 0), ctx);
|
||||
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||
expect(doc.getAllElements().length).toBe(4); // 2 Linien + 2 Kappen
|
||||
doc.undo();
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ENTER mit weniger als 2 Punkten committet NICHT', () => {
|
||||
const { ctx, doc, statuses } = mkCtx({ gap: 20 });
|
||||
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
expect(statuses[statuses.length - 1]).toContain('2');
|
||||
});
|
||||
|
||||
it('L-foermiger Pfad: beide Linien folgen der Geometrie', () => {
|
||||
const { ctx, doc } = mkCtx({ gap: 20 });
|
||||
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
mlineTool.handlers.down!(pe(100, 0), ctx);
|
||||
mlineTool.handlers.down!(pe(100, 100), ctx);
|
||||
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||
const lines = doc.getAllElements().filter((e) => e.type === 'polyline');
|
||||
expect(lines).toHaveLength(2);
|
||||
for (const l of lines) {
|
||||
const pts = l.properties.points as Pt[];
|
||||
expect(pts).toHaveLength(3); // gleiche Punktanzahl wie Mittellinie
|
||||
}
|
||||
});
|
||||
|
||||
it('Metadaten: mline:true und gap in properties', () => {
|
||||
const { ctx, doc } = mkCtx({ gap: 20 });
|
||||
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
mlineTool.handlers.down!(pe(100, 0), ctx);
|
||||
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||
const line = doc.getAllElements().find((e) => e.properties.mline)!;
|
||||
expect(line.properties.mline).toBe(true);
|
||||
expect(line.properties.gap).toBe(20);
|
||||
});
|
||||
|
||||
it('cancel resettet die Sitzung', () => {
|
||||
const { ctx, doc } = mkCtx({ gap: 20 });
|
||||
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
mlineTool.handlers.cancel?.(ctx);
|
||||
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
mlineTool.handlers.key!(key('Enter'), ctx); // nur 1 Punkt der NEUEN Sitzung
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Manifest: id=mline, Optionen gap und cap', () => {
|
||||
expect(mlineTool.manifest.id).toBe('mline');
|
||||
const keys = (mlineTool.optionsSchema ?? []).map((f) => f.key);
|
||||
expect(keys).toContain('gap');
|
||||
expect(keys).toContain('cap');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Block-Editor Service ──────────────────────────────────────
|
||||
|
||||
describe('CAD-5: blockEditorService', () => {
|
||||
function mkDoc(): CADDocument {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
doc.addBlock(BLOCK);
|
||||
return doc;
|
||||
}
|
||||
|
||||
it('openBlockForEdit erzeugt markierte Kopien im Dokument', () => {
|
||||
const doc = mkDoc();
|
||||
expect(openBlockForEdit(doc, 'blk-1')).toBe(true);
|
||||
const copies = doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER]);
|
||||
expect(copies).toHaveLength(2);
|
||||
expect(doc.getBlock('blk-1')!.elements).toHaveLength(2); // Original unangetastet
|
||||
});
|
||||
|
||||
it('openBlockForEdit mit unbekannter ID: false', () => {
|
||||
const doc = mkDoc();
|
||||
expect(openBlockForEdit(doc, 'gibts-nicht')).toBe(false);
|
||||
});
|
||||
|
||||
it('saveBlockEdit schreibt bearbeitete Elemente zurueck und raeumt auf', () => {
|
||||
const doc = mkDoc();
|
||||
openBlockForEdit(doc, 'blk-1');
|
||||
// Eine Kopie bearbeiten: rect verbreitern
|
||||
const copies = doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER]);
|
||||
const rectCopy = copies.find((c) => c.type === 'rect')!;
|
||||
doc.updateElement(rectCopy.id, { width: 200 });
|
||||
const saved = saveBlockEdit(doc);
|
||||
expect(saved).toBe('blk-1');
|
||||
// Blockdefinition hat die neue Breite
|
||||
const savedRect = doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!;
|
||||
expect(savedRect.width).toBe(200);
|
||||
// Edit-Kopien sind aus dem Dokument entfernt
|
||||
const remaining = doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER]);
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('saveBlockEdit ohne offene Sitzung: null', () => {
|
||||
const doc = mkDoc();
|
||||
expect(saveBlockEdit(doc)).toBeNull();
|
||||
});
|
||||
|
||||
it('discardBlockEdit verwirft Aenderungen (Block unveraendert, Kopien weg)', () => {
|
||||
const doc = mkDoc();
|
||||
openBlockForEdit(doc, 'blk-1');
|
||||
const copies = doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER]);
|
||||
doc.updateElement(copies[0].id, { width: 999 });
|
||||
expect(discardBlockEdit(doc)).toBe(true);
|
||||
expect(doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!.width).toBe(100);
|
||||
expect(doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('saveBlockEdit entfernt Marker aus den gespeicherten Elementen', () => {
|
||||
const doc = mkDoc();
|
||||
openBlockForEdit(doc, 'blk-1');
|
||||
saveBlockEdit(doc);
|
||||
for (const el of doc.getBlock('blk-1')!.elements) {
|
||||
expect((el.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── blockEditTool ─────────────────────────────────────────────
|
||||
|
||||
describe('CAD-5: blockEditTool', () => {
|
||||
function mkToolCtx() {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
doc.addBlock(BLOCK);
|
||||
doc.addElement({
|
||||
id: 'inst-1', type: 'block_instance', layerId: 'l',
|
||||
x: 0, y: 0, width: 0, height: 0,
|
||||
properties: { blockId: 'blk-1' },
|
||||
} as unknown as CADElement);
|
||||
const statuses: string[] = [];
|
||||
const ctx: any = {
|
||||
doc,
|
||||
options: {},
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (_el: CADElement | null) => {},
|
||||
selection: {
|
||||
getIds: () => [] as string[],
|
||||
setIds: (_ids: string[]) => {},
|
||||
hitTest: (_x: number, _y: number, _tol: number) => doc.getElement('inst-1'),
|
||||
},
|
||||
};
|
||||
return { ctx, doc, statuses };
|
||||
}
|
||||
|
||||
it('erster Klick auf Instanz oeffnet den Block-Editor (Kopien erscheinen)', () => {
|
||||
const { ctx, doc, statuses } = mkToolCtx();
|
||||
blockEditTool.handlers.down!(pe(0, 0), ctx);
|
||||
expect(doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER])).toHaveLength(2);
|
||||
expect(statuses[statuses.length - 1]).toContain('Block');
|
||||
});
|
||||
|
||||
it('zweiter Klick speichert (Block aktualisiert, Kopien weg)', () => {
|
||||
const { ctx, doc } = mkToolCtx();
|
||||
blockEditTool.handlers.down!(pe(0, 0), ctx);
|
||||
// Kopie bearbeiten
|
||||
const copy = doc.getAllElements().find((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER] && e.type === 'rect')!;
|
||||
doc.updateElement(copy.id, { width: 150 });
|
||||
blockEditTool.handlers.down!(pe(0, 0), ctx); // speichern
|
||||
expect(doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!.width).toBe(150);
|
||||
expect(doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cancel verwirft die Bearbeitung', () => {
|
||||
const { ctx, doc } = mkToolCtx();
|
||||
blockEditTool.handlers.down!(pe(0, 0), ctx);
|
||||
const copy = doc.getAllElements().find((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER] && e.type === 'rect')!;
|
||||
doc.updateElement(copy.id, { width: 999 });
|
||||
blockEditTool.handlers.cancel?.(ctx);
|
||||
expect(doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!.width).toBe(100);
|
||||
expect(doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Klick ohne Instanz-Treffer: Status erklaert, nichts passiert', () => {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
doc.addBlock(BLOCK);
|
||||
const statuses: string[] = [];
|
||||
const ctx: any = {
|
||||
doc,
|
||||
options: {},
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: () => {},
|
||||
selection: { getIds: () => [], setIds: () => {}, hitTest: () => null },
|
||||
};
|
||||
blockEditTool.handlers.down!(pe(0, 0), ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
expect(statuses[statuses.length - 1]).toContain('Block-Instanz');
|
||||
});
|
||||
|
||||
it('Manifest: id=block-edit', () => {
|
||||
expect(blockEditTool.manifest.id).toBe('block-edit');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -807,6 +807,8 @@ describe('A3 pilot: select tool', () => {
|
||||
selectTool.handlers.down!(pe(10, 10), ctx);
|
||||
expect(getIds()).toEqual(['a']);
|
||||
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(coreModifyPlugin.manifest.enabledByDefault).toBe(true);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Task CAD-13-fix: Legacy-Rendering doppelt (Legacy-Canvas + Pixi-Overlay
|
||||
* zeichnen dieselben Elemente). skipElementRendering legt das Legacy-
|
||||
* Element-Rendering still, wenn Pixi aktiv ist — Grid/Hintergrund/Lineal
|
||||
* bleiben sichtbar. Defensiv: Ohne Flag ist Verhalten unveraendert.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RenderEngine } from '../src/canvas/RenderEngine';
|
||||
import { ZoomPanController } from '../src/canvas/ZoomPanController';
|
||||
import { SpatialIndex } from '../src/canvas/SpatialIndex';
|
||||
import { LayerManager } from '../src/canvas/LayerManager';
|
||||
import type { CADElement, CADLayer } from '../src/types/cad.types';
|
||||
|
||||
function setup() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 800;
|
||||
canvas.height = 600;
|
||||
document.body.appendChild(canvas);
|
||||
const zpc = new ZoomPanController(canvas);
|
||||
const spatialIndex = new SpatialIndex();
|
||||
const layerManager = new LayerManager();
|
||||
const engine = new RenderEngine(canvas, zpc, spatialIndex, layerManager);
|
||||
return { canvas, engine, zpc, spatialIndex, layerManager };
|
||||
}
|
||||
|
||||
describe('CAD-13-fix: RenderEngine.skipElementRendering', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('Default: Flag ist false (Verhalten unveraendert)', () => {
|
||||
const { engine } = setup();
|
||||
expect(engine.skipElementRendering).toBe(false);
|
||||
});
|
||||
|
||||
it('skipElementRendering=true: Elemente werden NICHT gezeichnet, Grid schon', () => {
|
||||
const { engine, spatialIndex, layerManager } = setup();
|
||||
const layer: CADLayer = { id: 'l1', name: 'L1', visible: true, locked: false, color: '#fff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null } as CADLayer;
|
||||
layerManager.addLayer(layer);
|
||||
const el: CADElement = {
|
||||
id: 'e1', type: 'line', layerId: 'l1',
|
||||
x: 10, y: 10, width: 100, height: 100,
|
||||
properties: { x1: 10, y1: 10, x2: 110, y2: 110 },
|
||||
} as unknown as CADElement;
|
||||
spatialIndex.bulkInsert([el]);
|
||||
engine.setLayers([layer]);
|
||||
// drawElement darf nicht aufgerufen werden — wir spionieren die private
|
||||
// Methode ueber einen Prototyp-Hook
|
||||
const spy = vi.spyOn(engine as unknown as { drawElement: (el: CADElement, l: CADLayer) => void }, 'drawElement');
|
||||
engine.skipElementRendering = true;
|
||||
engine.render();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('skipElementRendering=false (Default): Elemente werden gezeichnet', () => {
|
||||
const { engine, spatialIndex, layerManager } = setup();
|
||||
const layer: CADLayer = { id: 'l1', name: 'L1', visible: true, locked: false, color: '#fff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null } as CADLayer;
|
||||
layerManager.addLayer(layer);
|
||||
const el: CADElement = {
|
||||
id: 'e1', type: 'line', layerId: 'l1',
|
||||
x: 10, y: 10, width: 100, height: 100,
|
||||
properties: { x1: 10, y1: 10, x2: 110, y2: 110 },
|
||||
} as unknown as CADElement;
|
||||
spatialIndex.bulkInsert([el]);
|
||||
engine.setLayers([layer]);
|
||||
const spy = vi.spyOn(engine as unknown as { drawElement: (el: CADElement, layer: CADLayer) => void }, 'drawElement');
|
||||
engine.render();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* CAD-14 Phase 3: selectTool V2 — Auswahl direkt auf dem V2-Dokument.
|
||||
* Klick = hitTest via SelectionBridge, Leerklick leert, Shift additiv,
|
||||
* Drag = Box-Select (Elemente mit Zentrum in BBox).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { selectTool } from '../src/plugins/builtin/core-drawing';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { YjsDocument } from '../src/crdt/YjsDocument';
|
||||
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||
|
||||
function pe(x: number, y: number, shift = false): { world: Pt; shift: boolean } {
|
||||
return { world: { x, y }, shift } as never;
|
||||
}
|
||||
|
||||
function makeCtx(ids: string[], hit: CADElement | null) {
|
||||
const selected: string[][] = [];
|
||||
const onChangeCalls: string[][] = [];
|
||||
const realDoc = new CADDocument(new YjsDocument());
|
||||
let currentIds = [...ids];
|
||||
const ctx: any = {
|
||||
options: {},
|
||||
selection: {
|
||||
getIds: () => currentIds,
|
||||
setIds: (next: string[], additive?: boolean) => {
|
||||
currentIds = additive ? [...currentIds, ...next] : next;
|
||||
selected.push(currentIds);
|
||||
},
|
||||
hitTest: (_x: number, _y: number, _tol: number) => hit,
|
||||
onChange: (ids2: string[]) => onChangeCalls.push(ids2),
|
||||
},
|
||||
doc: realDoc,
|
||||
setStatus: () => {},
|
||||
setPreview: () => {},
|
||||
};
|
||||
return { ctx, selected, onChangeCalls, realDoc, getCurrentIds: () => currentIds };
|
||||
}
|
||||
|
||||
function lineEl(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;
|
||||
}
|
||||
|
||||
describe('CAD-14 Phase 3: selectTool V2', () => {
|
||||
beforeEach(() => {
|
||||
selectTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
it('Manifest: id=select, basic-tag (immer sichtbar)', () => {
|
||||
expect(selectTool.manifest.id).toBe('select');
|
||||
expect((selectTool.manifest.tags ?? []).includes('basic')).toBe(true);
|
||||
});
|
||||
|
||||
it('Klick auf Element: hitTest-Treffer wird selektiert (setIds mit ID)', () => {
|
||||
const el = lineEl('e1', 0, 0, 100, 100);
|
||||
const { ctx, selected } = makeCtx([], el);
|
||||
selectTool.handlers.down!(pe(50, 50), ctx);
|
||||
expect(selected.length).toBe(1);
|
||||
expect(selected[0]).toEqual(['e1']);
|
||||
});
|
||||
|
||||
it('Leerklick (hitTest null): Auswahl geleert (setIds mit [])', () => {
|
||||
const { ctx, selected } = makeCtx(['e9'], null);
|
||||
selectTool.handlers.down!(pe(500, 500), ctx);
|
||||
expect(selected.length).toBe(1);
|
||||
expect(selected[0]).toEqual([]);
|
||||
});
|
||||
|
||||
it('Shift-Klick additiv: setIds mit additive=true', () => {
|
||||
const el = lineEl('e1', 0, 0, 100, 100);
|
||||
const { ctx, selected } = makeCtx(['e9'], el);
|
||||
selectTool.handlers.down!(pe(50, 50, true), ctx);
|
||||
expect(selected.length).toBe(1);
|
||||
// additive-Variante: bestaehige IDs + neue ID
|
||||
expect(selected[0]).toEqual(['e9', 'e1']);
|
||||
});
|
||||
|
||||
it('Box-Select: Element mit Zentrum in BBox wird selektiert', () => {
|
||||
const { ctx, realDoc, getCurrentIds } = makeCtx([], null);
|
||||
const el = lineEl('e1', 0, 0, 100, 100);
|
||||
realDoc.addElement(el);
|
||||
// down in leerer Gegend startet Box (Leerklick leert zuerst — korrekt),
|
||||
// move zeigt Vorschau, up schliesst BBox um Element.
|
||||
selectTool.handlers.down!(pe(-10, -10), ctx);
|
||||
selectTool.handlers.move!(pe(200, 200), ctx);
|
||||
selectTool.handlers.up!(pe(200, 200), ctx);
|
||||
expect(getCurrentIds()).toEqual(['e1']);
|
||||
});
|
||||
|
||||
it('onChange feuert nach Klick-Auswahl (App-Kanal)', () => {
|
||||
const el = lineEl('e1', 0, 0, 100, 100);
|
||||
const { ctx, onChangeCalls } = makeCtx([], el);
|
||||
selectTool.handlers.down!(pe(50, 50), ctx);
|
||||
expect(onChangeCalls.length).toBe(1);
|
||||
expect(onChangeCalls[0]).toEqual(['e1']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* CAD-14 Phase 4a: toolOptionsService — zentraler Store fuer
|
||||
* Werkzeug-Optionen (Laenge, Breite, chordCount, Winkel...).
|
||||
* Quelle der Defaults: optionsSchema der V2-Werkzeuge.
|
||||
* Der InteractionDispatcher liest darueber getOptions.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
getToolOption,
|
||||
setToolOption,
|
||||
getToolOptions,
|
||||
resetToolOptions,
|
||||
subscribeToolOptions,
|
||||
defaultsFromSchema,
|
||||
setToolOptionsDefaults,
|
||||
} from '../src/services/toolOptionsService';
|
||||
|
||||
describe('CAD-14 Phase 4a: toolOptionsService', () => {
|
||||
beforeEach(() => {
|
||||
resetToolOptions();
|
||||
});
|
||||
|
||||
it('defaultsFromSchema: leitet Defaults aus optionsSchema ab', () => {
|
||||
const defaults = defaultsFromSchema([
|
||||
{ key: 'length', label: 'Länge', type: 'number', min: 10, max: 1000 },
|
||||
{ key: 'chordCount', label: 'Gurtrohre', type: 'number', min: 1, max: 4 },
|
||||
] as never);
|
||||
// number ohne expliziten default: min clamped auf sinnvollen Start
|
||||
expect(defaults.length).toBeDefined();
|
||||
expect(defaults.chordCount).toBeDefined();
|
||||
});
|
||||
|
||||
it('set/get: Wert wird gespeichert und gelesen', () => {
|
||||
setToolOption('truss', 'length', 175);
|
||||
expect(getToolOption('truss', 'length')).toBe(175);
|
||||
});
|
||||
|
||||
it('getToolOption ohne Set: default aus Schema (defaultsFromSchema)', () => {
|
||||
setToolOptionsDefaults('truss', { length: 200, chordCount: 2, trussWidth: 29 });
|
||||
expect(getToolOption('truss', 'length')).toBe(200);
|
||||
expect(getToolOption('truss', 'chordCount')).toBe(2);
|
||||
});
|
||||
|
||||
it('getToolOptions liefert Gesamtsatz (defaults + gesetzte Werte)', () => {
|
||||
setToolOptionsDefaults('truss', { length: 200, trussWidth: 29 });
|
||||
setToolOption('truss', 'length', 300);
|
||||
const all = getToolOptions('truss');
|
||||
expect(all.length).toBe(300);
|
||||
expect(all.trussWidth).toBe(29);
|
||||
});
|
||||
|
||||
it('subscribe: Benachrichtigung bei Änderung (UI-Update)', () => {
|
||||
let notified = 0;
|
||||
const unsub = subscribeToolOptions(() => { notified++; });
|
||||
setToolOption('truss', 'length', 250);
|
||||
unsub();
|
||||
setToolOption('truss', 'length', 260);
|
||||
expect(notified).toBe(1);
|
||||
});
|
||||
|
||||
it('resetToolOptions leert alles', () => {
|
||||
setToolOptionsDefaults('truss', { length: 200 });
|
||||
resetToolOptions();
|
||||
expect(getToolOption('truss', 'length', 99)).toBe(99);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Task CAD-11 - Truss Professional Upgrade:
|
||||
* 1. chordCount (Anzahl Gurtrohre, 1-4) einstellbar
|
||||
* 2. Laenge als freier Zahleneintrag (nicht nur Presets)
|
||||
* 3. Corner mit variablem Winkel (nicht nur 90 Grad)
|
||||
* 4. Kreis-Traverse als eigenes Tool
|
||||
* 5. BOM beruecksichtigt chordCount und Kreistraversen
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
trussTool,
|
||||
boxCornerTool,
|
||||
circleTrussTool,
|
||||
trussEndpoints,
|
||||
boxcornerEndpoints,
|
||||
} from '../src/plugins/builtin/event-tools';
|
||||
import { buildBOM } from '../src/services/bomService';
|
||||
import { elementToPrimitives } from '../src/render/pixi/elementDrawers';
|
||||
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 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;
|
||||
}
|
||||
|
||||
function makeTruss(id: string, x: number, y: number, length: number, rotation: number, width = 29, chordCount = 2): CADElement {
|
||||
return {
|
||||
id, type: 'truss', layerId: 'l', x, y, width: length, height: width,
|
||||
properties: { length, rotation, trussWidth: width, chordCount, truss: true },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
function makeCorner(id: string, x: number, y: number, armLength = 50, rotation = 0, angle = 90, width = 29): CADElement {
|
||||
return {
|
||||
id, type: 'boxcorner', layerId: 'l', x, y, width: 80, height: 80,
|
||||
properties: { armLength, rotation, angle, trussWidth: width, boxcorner: true },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
function makeCircleTruss(id: string, x: number, y: number, diameter: number, width = 29, chordCount = 2): CADElement {
|
||||
return {
|
||||
id, type: 'circletruss', layerId: 'l', x, y, width: diameter, height: diameter,
|
||||
properties: { diameter, trussWidth: width, chordCount, circletruss: true },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const t of [trussTool, boxCornerTool, circleTrussTool]) {
|
||||
t.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 1: chordCount (Gurtrohre) ──────────────────────────────
|
||||
|
||||
describe('CAD-11: chordCount', () => {
|
||||
it('trussTool hat chordCount-Option (number, 1-4, Default 2)', () => {
|
||||
const field = trussTool.optionsSchema?.find((f) => f.key === 'chordCount');
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe('number');
|
||||
});
|
||||
|
||||
it('platzierte Traverse: chordCount=2 Standard', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.up!(pe(300, 100), ctx);
|
||||
expect(doc.getAllElements()[0].properties.chordCount).toBe(2);
|
||||
});
|
||||
|
||||
it('chordCount=3 wird uebernommen', () => {
|
||||
const { ctx, doc } = mkCtx([], { chordCount: 3 });
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.up!(pe(300, 100), ctx);
|
||||
expect(doc.getAllElements()[0].properties.chordCount).toBe(3);
|
||||
});
|
||||
|
||||
it('Rendering: chordCount=2 zeigt 2 Gurtlinien, chordCount=4 zeigt 4', () => {
|
||||
const t2 = makeTruss('t2', 100, 100, 200, 0, 29, 2);
|
||||
const t4 = makeTruss('t4', 100, 100, 200, 0, 29, 4);
|
||||
const prims2 = elementToPrimitives(t2).filter((p) => p.kind === 'line' && p.stroke === '#4b5563');
|
||||
const prims4 = elementToPrimitives(t4).filter((p) => p.kind === 'line' && p.stroke === '#4b5563');
|
||||
expect(prims2.length).toBe(2);
|
||||
expect(prims4.length).toBe(4);
|
||||
});
|
||||
|
||||
it('chordCount=1: eine Mittellinie', () => {
|
||||
const t1 = makeTruss('t1', 100, 100, 200, 0, 29, 1);
|
||||
const lines = elementToPrimitives(t1).filter((p) => p.kind === 'line' && p.stroke === '#4b5563');
|
||||
expect(lines.length).toBe(1);
|
||||
const line = lines[0] as { from: Pt; to: Pt };
|
||||
expect(line.from.y).toBeCloseTo(100, 0); // Mittellinie auf y=100
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2: Freie Laengeneingabe ─────────────────────────────────
|
||||
|
||||
describe('CAD-11: Laenge als freier Zahleneintrag', () => {
|
||||
it('length ist number (nicht select) — Presets als separate Quick-Buttons', () => {
|
||||
const field = trussTool.optionsSchema?.find((f) => f.key === 'length');
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe('number');
|
||||
});
|
||||
|
||||
it('Custom-Laenge 175 wird uebernommen', () => {
|
||||
const { ctx, doc } = mkCtx([], { length: 175 });
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.up!(pe(300, 100), ctx);
|
||||
expect(doc.getAllElements()[0].properties.length).toBe(175);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3: Corner mit variablem Winkel ─────────────────────────
|
||||
|
||||
describe('CAD-11: Corner variablem Winkel', () => {
|
||||
it('boxCornerTool hat angle-Option (number, Default 90)', () => {
|
||||
const field = boxCornerTool.optionsSchema?.find((f) => f.key === 'angle');
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe('number');
|
||||
});
|
||||
|
||||
it('boxcornerEndpoints: 90-Grad-Ecke = Arm2 bei rot+90', () => {
|
||||
const c = makeCorner('c1', 100, 100, 50, 0, 90);
|
||||
const [a1, a2] = boxcornerEndpoints(c);
|
||||
expect(a1.x).toBeCloseTo(150, 0);
|
||||
expect(a1.y).toBeCloseTo(100, 0);
|
||||
expect(a2.x).toBeCloseTo(100, 0);
|
||||
expect(a2.y).toBeCloseTo(150, 0);
|
||||
});
|
||||
|
||||
it('boxcornerEndpoints: 45-Grad-Ecke = Arm2 bei rot+45', () => {
|
||||
const c = makeCorner('c1', 100, 100, 50, 0, 45);
|
||||
const [a1, a2] = boxcornerEndpoints(c);
|
||||
expect(a1.x).toBeCloseTo(150, 0);
|
||||
expect(a1.y).toBeCloseTo(100, 0);
|
||||
const halfDiag = 50 * Math.cos(Math.PI / 4);
|
||||
expect(a2.x).toBeCloseTo(100 + halfDiag, 0);
|
||||
expect(a2.y).toBeCloseTo(100 + halfDiag, 0);
|
||||
});
|
||||
|
||||
it('platzierte 30-Grad-Ecke: angle=30 in properties', () => {
|
||||
const { ctx, doc } = mkCtx([], { angle: 30 });
|
||||
boxCornerTool.handlers.down!(pe(100, 100), ctx);
|
||||
boxCornerTool.handlers.up!(pe(200, 100), ctx);
|
||||
expect(doc.getAllElements()[0].properties.angle).toBe(30);
|
||||
});
|
||||
|
||||
it('Default-Winkel ist 90 (abwaertskompatibel)', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
boxCornerTool.handlers.down!(pe(100, 100), ctx);
|
||||
boxCornerTool.handlers.up!(pe(200, 100), ctx);
|
||||
expect(doc.getAllElements()[0].properties.angle).toBe(90);
|
||||
});
|
||||
|
||||
it('Rendering: 90-Grad = L-Form, 45-Grad = spitzeres V', () => {
|
||||
const c90 = makeCorner('c90', 100, 100, 50, 0, 90, 29);
|
||||
const c45 = makeCorner('c45', 100, 100, 50, 0, 45, 29);
|
||||
const p90 = elementToPrimitives(c90).find((p) => p.kind === 'polyline') as { points: Pt[] };
|
||||
const p45 = elementToPrimitives(c45).find((p) => p.kind === 'polyline') as { points: Pt[] };
|
||||
// Beide sind Polygone mit >= 6 Punkten
|
||||
expect(p90.points.length).toBeGreaterThanOrEqual(6);
|
||||
expect(p45.points.length).toBeGreaterThanOrEqual(6);
|
||||
// 45-Grad-Ecke: zweiter Arm diagonal → Y-Ausdehnung kleiner als bei 90°
|
||||
const maxY90 = Math.max(...p90.points.map((p) => p.y));
|
||||
const maxY45 = Math.max(...p45.points.map((p) => p.y));
|
||||
expect(maxY45).toBeLessThan(maxY90);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4: Kreis-Traverse ──────────────────────────────────────
|
||||
|
||||
describe('CAD-11: Kreis-Traverse', () => {
|
||||
it('circleTrussTool existiert mit Manifest', () => {
|
||||
expect(circleTrussTool.manifest.id).toBe('circle-truss');
|
||||
const keys = (circleTrussTool.optionsSchema ?? []).map((f) => f.key);
|
||||
expect(keys).toContain('diameter');
|
||||
expect(keys).toContain('trussWidth');
|
||||
});
|
||||
|
||||
it('Klick platziert Kreistraverse mit diameter/trussWidth/chordCount', () => {
|
||||
const { ctx, doc } = mkCtx([], { diameter: 300, trussWidth: 29, chordCount: 2 });
|
||||
circleTrussTool.handlers.down!(pe(200, 200), ctx);
|
||||
const els = doc.getAllElements();
|
||||
expect(els).toHaveLength(1);
|
||||
expect(els[0].type).toBe('circletruss');
|
||||
expect(els[0].properties.diameter).toBe(300);
|
||||
expect(els[0].properties.trussWidth).toBe(29);
|
||||
expect(els[0].properties.chordCount).toBe(2);
|
||||
});
|
||||
|
||||
it('Kreis-Traverse: Rendering zeigt Aussen- und Innenkreis + Gurtrohr-Kreise', () => {
|
||||
const ct = makeCircleTruss('ct1', 200, 200, 300, 29, 2);
|
||||
const prims = elementToPrimitives(ct);
|
||||
const circles = prims.filter((p) => p.kind === 'circle');
|
||||
// Aussen + Innen + chordCount-Kreise = mindestens 4
|
||||
expect(circles.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('1 Undo entfernt die Kreistraverse', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
circleTrussTool.handlers.down!(pe(200, 200), ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(1);
|
||||
doc.undo();
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('diameter-Default ist 200', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
circleTrussTool.handlers.down!(pe(200, 200), ctx);
|
||||
expect(doc.getAllElements()[0].properties.diameter).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5: BOM-Integration ─────────────────────────────────────
|
||||
|
||||
describe('CAD-11: BOM mit chordCount + Kreistraversen', () => {
|
||||
it('BOM gruppiert nach chordCount (2-rohr vs 4-rohr getrennt)', () => {
|
||||
const els = [
|
||||
makeTruss('t1', 0, 0, 200, 0, 29, 2),
|
||||
makeTruss('t2', 0, 0, 200, 0, 29, 4),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
expect(bom.rows.filter((r) => r.type === 'Traverse')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('BOM enthaelt Kreistraversen mit Durchmesser', () => {
|
||||
const els = [makeCircleTruss('ct1', 200, 200, 300, 29, 2)];
|
||||
const bom = buildBOM(els);
|
||||
expect(bom.rows).toHaveLength(1);
|
||||
expect(bom.rows[0].label).toContain('Kreis');
|
||||
expect(bom.rows[0].label).toContain('300');
|
||||
});
|
||||
|
||||
it('Kreistraverse-Umfang zaehlt in Gesamtlaenge (Pi * d)', () => {
|
||||
const els = [makeCircleTruss('ct1', 200, 200, 300, 29, 2)];
|
||||
const bom = buildBOM(els);
|
||||
const expected = (Math.PI * 300) / 100; // ~9.42m
|
||||
expect(bom.totalLengthM).toBeCloseTo(expected, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Task CAD-7 - Magnetisches Traversen-Tool.
|
||||
*
|
||||
* Traversen als rechteckige Balken (Laengen-Option 100/200/290cm,
|
||||
* Standard 200). MAGNETISCH: Nahe dem Ende einer bestehenden
|
||||
* Traverse dockt die neue exakt an (Position + Rotation uebernommen)
|
||||
* - trussEndpoints (Endpunkte in Weltkoordinaten, rotationsbewusst)
|
||||
* - snapTrussToDock (Dock-Suche + Andock-Geometrie, rein)
|
||||
* - trussTool: Klick platziert (gedockt oder frei), Drag = Richtung.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
trussTool,
|
||||
trussEndpoints,
|
||||
snapTrussToDock,
|
||||
type TrussDock,
|
||||
} from '../src/plugins/builtin/event-tools';
|
||||
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 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, mods: { shift?: boolean } = {}): { world: Pt; shift: boolean } {
|
||||
return { world: { x, y }, shift: mods.shift ?? false } as never;
|
||||
}
|
||||
|
||||
function makeTruss(id: string, x: number, y: number, length: number, rotationDeg = 0): CADElement {
|
||||
return {
|
||||
id, type: 'truss', layerId: 'layer-0',
|
||||
x, y,
|
||||
width: length, height: 20,
|
||||
properties: { length, rotation: rotationDeg, truss: true },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
trussTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
// ─── trussEndpoints (reine Funktion) ──────────────────────────
|
||||
|
||||
describe('CAD-7: trussEndpoints', () => {
|
||||
it('horizontale Traverse: Endpunkte links/rechts vom Zentrum', () => {
|
||||
const el = makeTruss('t1', 100, 100, 200, 0);
|
||||
const [a, b] = trussEndpoints(el);
|
||||
expect(a).toEqual({ x: 0, y: 100 });
|
||||
expect(b).toEqual({ x: 200, y: 100 });
|
||||
});
|
||||
|
||||
it('90-Grad-Traverse: Endpunkte vertikal', () => {
|
||||
const el = makeTruss('t1', 100, 100, 200, 90);
|
||||
const [a, b] = trussEndpoints(el);
|
||||
expect(a.x).toBeCloseTo(100, 5);
|
||||
expect(a.y).toBeCloseTo(0, 5);
|
||||
expect(b.x).toBeCloseTo(100, 5);
|
||||
expect(b.y).toBeCloseTo(200, 5);
|
||||
});
|
||||
|
||||
it('45-Grad-Traverse: diagonale Endpunkte', () => {
|
||||
const el = makeTruss('t1', 100, 100, 200, 45);
|
||||
const [a, b] = trussEndpoints(el);
|
||||
const half = 100;
|
||||
const cos45 = Math.cos(Math.PI / 4) * half;
|
||||
expect(a.x).toBeCloseTo(100 - cos45, 3);
|
||||
expect(a.y).toBeCloseTo(100 - cos45, 3);
|
||||
expect(b.x).toBeCloseTo(100 + cos45, 3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── snapTrussToDock (magnetisches Andocken, rein) ──────────
|
||||
|
||||
describe('CAD-7: snapTrussToDock (magnetisch)', () => {
|
||||
const existing = makeTruss('t1', 100, 100, 200, 0); // Enden (0,100) und (200,100)
|
||||
const tol = 25;
|
||||
|
||||
it('Cursor nahe Endpunkt (200,100): dockt exakt an mit Rotation 0', () => {
|
||||
const result = snapTrussToDock([existing], { x: 210, y: 105 }, 200, tol);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.rotationDeg).toBeCloseTo(0, 5);
|
||||
// Neue Traverse startet am Dock (200,100) → Zentrum bei (200+100, 100)
|
||||
expect(result!.center.x).toBeCloseTo(300, 3);
|
||||
expect(result!.center.y).toBeCloseTo(100, 3);
|
||||
expect(result!.dockedTo).toBe('t1');
|
||||
});
|
||||
|
||||
it('Cursor nahe Startpunkt (0,100): dockt in GEGENRICHTUNG (Rotation 180)', () => {
|
||||
const result = snapTrussToDock([existing], { x: -10, y: 98 }, 200, tol);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.rotationDeg).toBeCloseTo(180, 5);
|
||||
expect(result!.center.x).toBeCloseTo(-100, 3);
|
||||
});
|
||||
|
||||
it('Cursor weiter weg als Toleranz: kein Dock (null)', () => {
|
||||
const result = snapTrussToDock([existing], { x: 500, y: 500 }, 200, tol);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('andocken an rotierte Traverse (90 Grad): Rotation wird uebernommen', () => {
|
||||
const vert = makeTruss('v1', 100, 100, 200, 90); // Enden (100,0) und (100,200)
|
||||
const result = snapTrussToDock([vert], { x: 105, y: 210 }, 200, tol);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.rotationDeg).toBeCloseTo(90, 5);
|
||||
expect(result!.center.x).toBeCloseTo(100, 3);
|
||||
expect(result!.center.y).toBeCloseTo(300, 3);
|
||||
});
|
||||
|
||||
it('mehrere Traversen: naechstgelegener Dock gewinnt', () => {
|
||||
const other = makeTruss('t2', 400, 100, 200, 0); // Enden (300,100),(500,100)
|
||||
const result = snapTrussToDock([existing, other], { x: 305, y: 100 }, 200, tol);
|
||||
expect(result!.dockedTo).toBe('t2');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── trussTool (Platzierung + Magnetismus) ──────────────────
|
||||
|
||||
describe('CAD-7: trussTool', () => {
|
||||
it('Klick platziert freie Traverse (200cm Standard) als truss-Element', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
trussTool.handlers.down!(pe(300, 300), ctx);
|
||||
trussTool.handlers.up!(pe(400, 300), ctx);
|
||||
const els = doc.getAllElements();
|
||||
expect(els).toHaveLength(1);
|
||||
expect(els[0].type).toBe('truss');
|
||||
expect(els[0].properties.length).toBe(200);
|
||||
expect(els[0].properties.truss).toBe(true);
|
||||
});
|
||||
|
||||
it('Drag definiert die Richtung: horizontale Traverse bei Drag nach rechts', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.up!(pe(300, 100), ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.properties.rotation).toBeCloseTo(0, 5);
|
||||
// Laenge aus Drag-Distanz? Nein: Standardlaenge bleibt 200 (Option)
|
||||
expect(el.properties.length).toBe(200);
|
||||
});
|
||||
|
||||
it('Drag nach oben: Rotation 90', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.up!(pe(100, 300), ctx);
|
||||
expect(doc.getAllElements()[0].properties.rotation).toBeCloseTo(90, 1);
|
||||
});
|
||||
|
||||
it('MAGNETISCH: Klick nahe bestehendem Traversen-Ende dockt exakt an', () => {
|
||||
const existing = makeTruss('t1', 100, 100, 200, 0);
|
||||
const { ctx, doc } = mkCtx([existing]);
|
||||
// Klick nahe (200,100) → andocken
|
||||
trussTool.handlers.down!(pe(210, 105), ctx);
|
||||
trussTool.handlers.up!(pe(300, 100), ctx);
|
||||
const els = doc.getAllElements().filter((e) => e.properties.truss);
|
||||
expect(els).toHaveLength(2);
|
||||
const newTruss = els.find((e) => e.id !== 't1')!;
|
||||
// Exakt angedockt: Rotation 0, Start am Ende der ersten
|
||||
expect(newTruss.properties.rotation).toBeCloseTo(0, 5);
|
||||
expect(newTruss.x).toBeCloseTo(300, 3);
|
||||
expect(newTruss.y).toBeCloseTo(100, 3);
|
||||
});
|
||||
|
||||
it('ohne bestehende Traversen: freie Platzierung (kein Crash)', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.up!(pe(200, 100), ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Laengen-Option: 290 statt 200', () => {
|
||||
const { ctx, doc } = mkCtx([], { length: 290 });
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.up!(pe(300, 100), ctx);
|
||||
expect(doc.getAllElements()[0].properties.length).toBe(290);
|
||||
});
|
||||
|
||||
it('ALLES in EINER Transaktion (1 Undo entfernt die Traverse)', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.up!(pe(300, 100), ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(1);
|
||||
doc.undo();
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cancel resettet die Drag-Sitzung', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.cancel?.(ctx);
|
||||
trussTool.handlers.up!(pe(300, 100), ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Preview bei move zwischen down und up', () => {
|
||||
const { ctx, previews } = mkCtx();
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.move!(pe(250, 100), ctx);
|
||||
expect(previews.length).toBeGreaterThanOrEqual(1);
|
||||
const last = previews[previews.length - 1];
|
||||
expect(last).not.toBeNull();
|
||||
});
|
||||
|
||||
it('Manifest: id=truss, Optionen length/rotation', () => {
|
||||
expect(trussTool.manifest.id).toBe('truss');
|
||||
const keys = (trussTool.optionsSchema ?? []).map((f) => f.key);
|
||||
expect(keys).toContain('length');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Task CAD-8 - Traversen-Upgrade:
|
||||
* 1. Laengen-Presets 50/100/200/300 cm
|
||||
* 2. Breite einstellbar (Standard 29 cm)
|
||||
* 3. Rotierbares Rendering (Koerper als ROTIERTES Polygon)
|
||||
* 4. Boxcorner (Eck-Traverse) mit magnetischem Andocken in BEIDE
|
||||
* Richtungen: Ecke dockt an Traversen/Ecken, Traversen docken
|
||||
* an Ecken-Armenden.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
trussTool,
|
||||
boxCornerTool,
|
||||
trussEndpoints,
|
||||
boxcornerEndpoints,
|
||||
snapTrussToDock,
|
||||
cornerDockToDock,
|
||||
} from '../src/plugins/builtin/event-tools';
|
||||
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(elements: CADElement[] = [], options: Record<string, unknown> = {}) {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
for (const el of elements) doc.addElement(el);
|
||||
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;
|
||||
}
|
||||
|
||||
function makeTruss(id: string, x: number, y: number, length: number, rotationDeg = 0, trussWidth?: number): CADElement {
|
||||
return {
|
||||
id, type: 'truss', layerId: 'layer-0', x, y,
|
||||
width: length, height: trussWidth ?? 29,
|
||||
properties: {
|
||||
length, rotation: rotationDeg, truss: true,
|
||||
...(trussWidth !== undefined ? { trussWidth } : {}),
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
function makeCorner(id: string, x: number, y: number, armLength = 50, rotationDeg = 0, width = 29): CADElement {
|
||||
return {
|
||||
id, type: 'boxcorner', layerId: 'layer-0', x, y,
|
||||
width: 80, height: 80,
|
||||
properties: { armLength, rotation: rotationDeg, trussWidth: width, boxcorner: true },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
trussTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
boxCornerTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
// ─── 1: Laengen-Presets + Breite ──────────────────────────────
|
||||
|
||||
describe('CAD-8: Laengen-Presets + Breite', () => {
|
||||
it('Laengen-Select: freie Eingabe mit 50-300 Bereich (CAD-11: number)', () => {
|
||||
const lenField = trussTool.optionsSchema?.find((f) => f.key === 'length');
|
||||
expect(lenField).toBeDefined();
|
||||
expect(lenField!.type).toBe('number');
|
||||
expect(lenField!.min).toBe(10);
|
||||
expect(lenField!.max).toBe(1000);
|
||||
});
|
||||
|
||||
it('Breiten-Option trussWidth (number) vorhanden', () => {
|
||||
const wField = trussTool.optionsSchema?.find((f) => f.key === 'trussWidth');
|
||||
expect(wField).toBeDefined();
|
||||
expect(wField!.type).toBe('number');
|
||||
});
|
||||
|
||||
it('platzierte Traverse: trussWidth=29 Standard, length=200 Standard', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
trussTool.handlers.down!(pe(300, 300), ctx);
|
||||
trussTool.handlers.up!(pe(400, 300), ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.properties.trussWidth).toBe(29);
|
||||
expect(el.properties.length).toBe(200);
|
||||
});
|
||||
|
||||
it('trussWidth=40 und length=300 aus Optionen uebernommen', () => {
|
||||
const { ctx, doc } = mkCtx([], { trussWidth: 40, length: 300 });
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.up!(pe(300, 100), ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.properties.trussWidth).toBe(40);
|
||||
expect(el.properties.length).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2: Rotierbares Truss-Rendering ───────────────────────────
|
||||
|
||||
describe('CAD-8: Rotierbares Truss-Rendering (Polygon-Koerper)', () => {
|
||||
it('90-Grad-Traverse: Koerperpolygon vertikal (y-Ausdehnung = Laenge, x = Breite)', () => {
|
||||
const el = makeTruss('t1', 100, 100, 200, 90, 29);
|
||||
const prims = elementToPrimitives(el);
|
||||
const body = prims.find((p) => p.kind === 'polyline') as { points: Pt[]; close: boolean } | undefined;
|
||||
expect(body).toBeDefined();
|
||||
expect(body!.close).toBe(true);
|
||||
expect(body!.points).toHaveLength(4);
|
||||
const ys = body!.points.map((p) => p.y);
|
||||
const xs = body!.points.map((p) => p.x);
|
||||
expect(Math.max(...ys) - Math.min(...ys)).toBeCloseTo(200, 0);
|
||||
expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(29, 0);
|
||||
});
|
||||
|
||||
it('0-Grad-Traverse: Koerperpolygon horizontal', () => {
|
||||
const el = makeTruss('t1', 100, 100, 200, 0, 29);
|
||||
const body = elementToPrimitives(el).find((p) => p.kind === 'polyline') as { points: Pt[] };
|
||||
const xs = body.points.map((p) => p.x);
|
||||
const ys = body.points.map((p) => p.y);
|
||||
expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(200, 0);
|
||||
expect(Math.max(...ys) - Math.min(...ys)).toBeCloseTo(29, 0);
|
||||
});
|
||||
|
||||
it('DREHBAR: Rotation aendert die Geometrie sichtbar (45 Grad diagonal)', () => {
|
||||
const el = makeTruss('t1', 100, 100, 200, 45, 29);
|
||||
const body = elementToPrimitives(el).find((p) => p.kind === 'polyline') as { points: Pt[] };
|
||||
const xs = body.points.map((p) => p.x);
|
||||
const expected = 200 * Math.cos(Math.PI / 4) + 29 * Math.sin(Math.PI / 4);
|
||||
expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(expected, 0);
|
||||
});
|
||||
|
||||
it('Breite im Rendering: trussWidth=40 ergibt 40 Einheiten Quer-Ausdehnung', () => {
|
||||
const el = makeTruss('t1', 100, 100, 200, 0, 40);
|
||||
const body = elementToPrimitives(el).find((p) => p.kind === 'polyline') as { points: Pt[] };
|
||||
const ys = body.points.map((p) => p.y);
|
||||
expect(Math.max(...ys) - Math.min(...ys)).toBeCloseTo(40, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3: Boxcorner-Element ─────────────────────────────────────
|
||||
|
||||
describe('CAD-8: Boxcorner-Element (Eck-Traverse)', () => {
|
||||
it('Manifest: id=boxcorner, Optionen armLength + trussWidth', () => {
|
||||
expect(boxCornerTool.manifest.id).toBe('boxcorner');
|
||||
const keys = (boxCornerTool.optionsSchema ?? []).map((f) => f.key);
|
||||
expect(keys).toContain('armLength');
|
||||
expect(keys).toContain('trussWidth');
|
||||
});
|
||||
|
||||
it('Drag-Platzierung: boxcorner mit armLength=50/trussWidth=29 Standard, Rotation aus Drag', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
boxCornerTool.handlers.down!(pe(100, 100), ctx);
|
||||
boxCornerTool.handlers.up!(pe(200, 100), ctx);
|
||||
const els = doc.getAllElements();
|
||||
expect(els).toHaveLength(1);
|
||||
const el = els[0];
|
||||
expect(el.type).toBe('boxcorner');
|
||||
expect(el.properties.armLength).toBe(50);
|
||||
expect(el.properties.trussWidth).toBe(29);
|
||||
expect(el.properties.rotation).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it('Drag nach unten: Rotation ~90', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
boxCornerTool.handlers.down!(pe(100, 100), ctx);
|
||||
boxCornerTool.handlers.up!(pe(100, 200), ctx);
|
||||
expect(doc.getAllElements()[0].properties.rotation).toBeCloseTo(90, 5);
|
||||
});
|
||||
|
||||
it('Optionen uebernommen: armLength=100, trussWidth=40', () => {
|
||||
const { ctx, doc } = mkCtx([], { armLength: 100, trussWidth: 40 });
|
||||
boxCornerTool.handlers.down!(pe(100, 100), ctx);
|
||||
boxCornerTool.handlers.up!(pe(200, 100), ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.properties.armLength).toBe(100);
|
||||
expect(el.properties.trussWidth).toBe(40);
|
||||
});
|
||||
|
||||
it('boxcornerEndpoints: Arme bei rot und rot+90 vom Eckpunkt', () => {
|
||||
const el = makeCorner('c1', 100, 100, 100, 0);
|
||||
const [a1, a2] = boxcornerEndpoints(el);
|
||||
expect(a1.x).toBeCloseTo(200, 5);
|
||||
expect(a1.y).toBeCloseTo(100, 5);
|
||||
expect(a2.x).toBeCloseTo(100, 5);
|
||||
expect(a2.y).toBeCloseTo(200, 5);
|
||||
});
|
||||
|
||||
it('DREHBAR: Rotation 90 dreht die Arm-Endpunkte mit', () => {
|
||||
const el = makeCorner('c1', 100, 100, 100, 90);
|
||||
const [a1, a2] = boxcornerEndpoints(el);
|
||||
expect(a1.x).toBeCloseTo(100, 5);
|
||||
expect(a1.y).toBeCloseTo(200, 5);
|
||||
expect(a2.x).toBeCloseTo(0, 5);
|
||||
expect(a2.y).toBeCloseTo(100, 5);
|
||||
});
|
||||
|
||||
it('1 Undo entfernt die Boxcorner (EINE Transaktion)', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
boxCornerTool.handlers.down!(pe(100, 100), ctx);
|
||||
boxCornerTool.handlers.up!(pe(200, 100), ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(1);
|
||||
doc.undo();
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cancel verwirft die Platzierung', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
boxCornerTool.handlers.down!(pe(100, 100), ctx);
|
||||
boxCornerTool.handlers.cancel?.(ctx);
|
||||
boxCornerTool.handlers.up!(pe(200, 100), ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4: Boxcorner-Magnetismus (beide Richtungen) ─────────────
|
||||
|
||||
describe('CAD-8: Boxcorner-Magnetismus', () => {
|
||||
it('cornerDockToDock: Ecke dockt an Traversen-Ende, Rotation = Weg-Richtung', () => {
|
||||
const t1 = makeTruss('t1', 100, 100, 200, 0);
|
||||
const dock = cornerDockToDock([t1], { x: 210, y: 105 }, 25);
|
||||
expect(dock).not.toBeNull();
|
||||
expect(dock!.vertex.x).toBeCloseTo(200, 5);
|
||||
expect(dock!.vertex.y).toBeCloseTo(100, 5);
|
||||
expect(dock!.rotationDeg).toBeCloseTo(0, 5);
|
||||
expect(dock!.dockedTo).toBe('t1');
|
||||
});
|
||||
|
||||
it('am Start-Ende der Traverse: Rotation 180 (weg von der Traverse)', () => {
|
||||
const t1 = makeTruss('t1', 100, 100, 200, 0);
|
||||
const dock = cornerDockToDock([t1], { x: -10, y: 98 }, 25);
|
||||
expect(dock!.vertex.x).toBeCloseTo(0, 5);
|
||||
expect(dock!.vertex.y).toBeCloseTo(100, 5);
|
||||
expect(dock!.rotationDeg).toBeCloseTo(180, 5);
|
||||
});
|
||||
|
||||
it('ausserhalb der Toleranz: kein Dock (null)', () => {
|
||||
const t1 = makeTruss('t1', 100, 100, 200, 0);
|
||||
expect(cornerDockToDock([t1], { x: 500, y: 500 }, 25)).toBeNull();
|
||||
});
|
||||
|
||||
it('Traverse dockt an Boxcorner-Armende (snapTrussToDock kennt Ecken)', () => {
|
||||
const c1 = makeCorner('c1', 100, 100, 50, 0);
|
||||
const dock = snapTrussToDock([c1], { x: 150, y: 100 }, 200, 25);
|
||||
expect(dock).not.toBeNull();
|
||||
expect(dock!.rotationDeg).toBeCloseTo(0, 5);
|
||||
expect(dock!.center.x).toBeCloseTo(250, 3);
|
||||
expect(dock!.center.y).toBeCloseTo(100, 3);
|
||||
expect(dock!.dockedTo).toBe('c1');
|
||||
});
|
||||
|
||||
it('Ecke dockt an Ecken-Armende', () => {
|
||||
const c1 = makeCorner('c1', 100, 100, 50, 0);
|
||||
const dock = cornerDockToDock([c1], { x: 152, y: 102 }, 25);
|
||||
expect(dock!.vertex.x).toBeCloseTo(150, 5);
|
||||
expect(dock!.vertex.y).toBeCloseTo(100, 5);
|
||||
expect(dock!.rotationDeg).toBeCloseTo(0, 5);
|
||||
expect(dock!.dockedTo).toBe('c1');
|
||||
});
|
||||
|
||||
it('Integration: platzierte Ecke wird vom trussTool magnetisch gefunden', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
boxCornerTool.handlers.down!(pe(100, 100), ctx);
|
||||
boxCornerTool.handlers.up!(pe(200, 100), ctx);
|
||||
trussTool.handlers.down!(pe(150, 100), ctx);
|
||||
trussTool.handlers.up!(pe(250, 100), ctx);
|
||||
const els = doc.getAllElements();
|
||||
expect(els).toHaveLength(2);
|
||||
const truss = els.find((e) => e.type === 'truss')!;
|
||||
expect(truss.properties.rotation).toBeCloseTo(0, 5);
|
||||
expect(truss.x).toBeCloseTo(250, 3);
|
||||
expect(truss.y).toBeCloseTo(100, 3);
|
||||
});
|
||||
|
||||
it('Integration: boxCornerTool dockt an bestehender Traverse', () => {
|
||||
const t1 = makeTruss('t1', 100, 100, 200, 0);
|
||||
const { ctx, doc } = mkCtx([t1]);
|
||||
boxCornerTool.handlers.down!(pe(210, 105), ctx);
|
||||
boxCornerTool.handlers.up!(pe(300, 100), ctx);
|
||||
const corner = doc.getAllElements().find((e) => e.type === 'boxcorner')!;
|
||||
expect(corner).toBeDefined();
|
||||
expect(corner.x).toBeCloseTo(200, 3);
|
||||
expect(corner.y).toBeCloseTo(100, 3);
|
||||
expect(corner.properties.rotation).toBeCloseTo(0, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5: Boxcorner-Rendering ─────────────────────────────────
|
||||
|
||||
describe('CAD-8: Boxcorner-Rendering (L-Form)', () => {
|
||||
it('L-foermiges Polygon: 8 Punkte, geschlossen, am Eckpunkt', () => {
|
||||
const el = makeCorner('c1', 100, 100, 50, 0, 29);
|
||||
const prims = elementToPrimitives(el);
|
||||
const poly = prims.find((p) => p.kind === 'polyline') as { points: Pt[]; close: boolean };
|
||||
expect(poly).toBeDefined();
|
||||
expect(poly.close).toBe(true);
|
||||
expect(poly.points).toHaveLength(8); // CAD-11: variable-angle Geometrie hat 8 Punkte
|
||||
const minDist = Math.min(...poly.points.map((p) => Math.hypot(p.x - 100, p.y - 100)));
|
||||
expect(minDist).toBeLessThanOrEqual(29);
|
||||
});
|
||||
|
||||
it('Rotation 90: L-Form zeigt nach unten-links statt rechts-unten', () => {
|
||||
const el = makeCorner('c1', 100, 100, 50, 90, 29);
|
||||
const poly = elementToPrimitives(el).find((p) => p.kind === 'polyline') as { points: Pt[] };
|
||||
const xs = poly.points.map((p) => p.x);
|
||||
const ys = poly.points.map((p) => p.y);
|
||||
// Bei rot 90: Arme zeigen nach unten (y+) und links (x-)
|
||||
expect(Math.max(...ys)).toBeGreaterThan(140);
|
||||
expect(Math.min(...xs)).toBeLessThan(60);
|
||||
});
|
||||
});
|
||||
@@ -57,42 +57,37 @@ describe('H1: uiModeService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── RibbonBar simple-Filter ─────────────────────────────
|
||||
// ─── simple/pro-Filter (Registry-Logik) ─────────────────
|
||||
|
||||
describe('H1: RibbonBar simple-Filter', () => {
|
||||
describe('H1: simple/pro-Werkzeug-Filter', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setUIMode('pro');
|
||||
});
|
||||
|
||||
it('pro-Modus: alle V2-Tools sichtbar (basic + pro-Tags)', async () => {
|
||||
it('pro-Modus: alle V2-Tools registriert (basic + pro-Tags)', async () => {
|
||||
const { registerBuiltinPlugins, pluginRegistry } = await import('../src/plugins');
|
||||
registerBuiltinPlugins();
|
||||
pluginRegistry.initDefaults();
|
||||
const RibbonBar = (await import('../src/components/RibbonBar')).default;
|
||||
render(<RibbonBar activeTab="canvas" onTabChange={() => {}} onAction={() => {}} />);
|
||||
const all = screen.getAllByRole('button').length;
|
||||
expect(all).toBeGreaterThan(10);
|
||||
cleanup();
|
||||
setUIMode('simple');
|
||||
render(<RibbonBar activeTab="canvas" onTabChange={() => {}} onAction={() => {}} />);
|
||||
const simpleCount = screen.getAllByRole('button').length;
|
||||
expect(simpleCount).toBeLessThan(all);
|
||||
expect(simpleCount).toBeGreaterThan(0);
|
||||
const all = pluginRegistry.getToolsV2();
|
||||
expect(all.length).toBeGreaterThan(10);
|
||||
const basics = all.filter((t) => (t.manifest.tags ?? []).includes('basic'));
|
||||
expect(basics.length).toBeGreaterThan(0);
|
||||
expect(basics.length).toBeLessThan(all.length);
|
||||
});
|
||||
|
||||
it('simple-Modus: pro-Tools (rotate/scale/mirror) fehlen, basic (line) bleibt', async () => {
|
||||
it('simple-Modus: nur basic-Tools (rotate/scale/mirror gefiltert, line bleibt)', async () => {
|
||||
localStorage.clear();
|
||||
setUIMode('simple');
|
||||
const { registerBuiltinPlugins, pluginRegistry } = await import('../src/plugins');
|
||||
registerBuiltinPlugins();
|
||||
pluginRegistry.initDefaults();
|
||||
const RibbonBar = (await import('../src/components/RibbonBar')).default;
|
||||
render(<RibbonBar activeTab="canvas" onTabChange={() => {}} onAction={() => {}} />);
|
||||
const html = document.body.innerHTML;
|
||||
expect(html).toContain('data-v2tool="line"');
|
||||
expect(html).not.toContain('data-v2tool="rotate"');
|
||||
expect(html).not.toContain('data-v2tool="mirror"');
|
||||
const visible = pluginRegistry.getToolsV2()
|
||||
.filter((t) => (t.manifest.tags ?? []).includes('basic'))
|
||||
.map((t) => t.manifest.id);
|
||||
expect(visible).toContain('line');
|
||||
expect(visible).not.toContain('rotate');
|
||||
expect(visible).not.toContain('mirror');
|
||||
});
|
||||
|
||||
it('CommandLine-Komponente: pro-only rendering via useUIMode', async () => {
|
||||
|
||||
Reference in New Issue
Block a user