Compare commits
23 Commits
f7fedec0fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 00e967531a | |||
| 94050fb371 | |||
| 170fd54b2c | |||
| 30d8cbfd00 | |||
| c244571dd6 | |||
| 2b5905506f | |||
| d5acf76d0b | |||
| 2a05257ab5 | |||
| cd5d4326db | |||
| 1d129042d4 | |||
| 180aa9b03a | |||
| f9c67a7d78 | |||
| 02ed6a5ff0 | |||
| 4e6edb51ed | |||
| 685757303f | |||
| 5aca4569ba | |||
| 0e4f9fe160 | |||
| af9f53c61f | |||
| f3a8b9e346 | |||
| 480368a5da | |||
| c4a76fb74e | |||
| 933dfcbac2 | |||
| 51d9c2f208 |
+12
-3
@@ -42,9 +42,9 @@
|
|||||||
**Offene Aufgaben:**
|
**Offene Aufgaben:**
|
||||||
|
|
||||||
### MEILENSTEIN 1: Produktive Basis (CAD-12 — NÄCHSTER TASK)
|
### MEILENSTEIN 1: Produktive Basis (CAD-12 — NÄCHSTER TASK)
|
||||||
- [ ] CAD-12a: Autosave (CRDT-Sync alle 30s)
|
- [x] CAD-12a: Autosave (CRDT-Sync alle 30s) *(2026-09-16; Log CAD-12)*
|
||||||
- [ ] CAD-12b: Keyboard-Shortcuts (ESC, Delete, Strg+A/D, F7/F8/F3)
|
- [x] CAD-12b: Keyboard-Shortcuts (ESC, Delete, Strg+A/D, F7/F8/F3) *(2026-09-16; Log CAD-12)*
|
||||||
- [ ] CAD-12c: Visueller UI-Audit (Playwright-Screenshots, Vision-Modell)
|
- [x] CAD-12c: Visueller UI-Audit (Playwright-Screenshots, Vision-Modell) *(2026-09-16; Log CAD-12c; Rest: Ribbon-Kompaktlayout)*
|
||||||
|
|
||||||
### MEILENSTEIN 2: Alltagstauglichkeit (CAD-13)
|
### MEILENSTEIN 2: Alltagstauglichkeit (CAD-13)
|
||||||
- [ ] CAD-13a: OTRACK in Interaktions-Engine integrieren
|
- [ ] CAD-13a: OTRACK in Interaktions-Engine integrieren
|
||||||
@@ -718,6 +718,15 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
|||||||
|
|
||||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-09-18 | AUDIT | KOMPLETTER PROGRAMM-AUDIT (Nutzer: "komplett fehlerfrei bekommen"): (1) BASELINE GRÜN: FE tsc 0, FE 903/903 Tests, FE Build 0, BE tsc 0, BE 286/286 Tests; (2) DB-INTEGRITÄT (Prod-SQLite): 0 Orphan-Elemente, 0 Orphan-Layer, 0 Duplikat-IDs; 34 Elemente mit leerem properties_json identifiziert = ALLE alte rect-Testrelikte vom 01.-04.07. (vor dem 2.0-Umbau, harmlos); Prod-Bestand: 568 Elemente, 18 Projekte, 20 Drawings, 7 Nutzer; (3) LIVE-API-SMOKE: 9 Kern-Endpunkte (login, auth/me, projects, project, drawings, elements, layers, blocks, project-folders) alle 200 OK; Login-Token-Feld war session.token (nicht token) — Smoke korrigiert; (4) E2E-WORKFLOW mit Fehler-Monitoring: 9/9 Schritte OK (Login, Projekt, Linie-Drag, Rechteck-Drag, V2-Select, Undo, Delete, F7+ZoomFit, Screenshot), Auswahl verifiziert via __v2GetSelection; FINDINGS + FIXES: Registry-Doppel-Registrierung (main.tsx init VOR Render als Single-Source, App-Effect idempotent), select-Warning beim Start (gleiche Timing-Ursache, mitbehandelt), DELETE-REQ-FAIL — CDP-Diagnose bewies: REQ — 204 — ERR_ABORTED NACH erfolgreicher Antwort = kosmetisches Chromium-Telemetrie-Artefakt bei 204 No Content, kein Funktionsfehler (Löschung verifiziert); als Follow-up: DELETE idempotent (404-tolerant) + App->v2Doc Reconcile gegen Geister-Elemente; (5) LIVE-VERIFIZIERUNG der Workflow-Daten: CAD14-Testprojekt enthält exakt die erwarteten Elemente (Linie, Traverse mit chordCount=3, Rechteck) | Frontend: 903 Tests, tsc 0, Build 0; Backend: 286 Tests, tsc 0; DB sauber; API 200er; E2E 9/9 ohne JS-Fehler | mehrere Commits | AUDIT ABGESCHLOSSEN: alle Findings klassifiziert und behoben bzw. als Artefakt entlarvt |
|
||||||
|
| 2026-09-18 | CAD-14 Phase 4c | LEGACY-ENGINE GELÖSCHT (1173 Zeilen physisch, nicht abgeschaltet): Vorher wurden ALLE Legacy-Funktionen in den Dispatcher übertragen: (1) Snap-Nutzung aus der bestehenden SnapEngine als Dispatcher-Deps (setSnapPoints/setActiveSnapPoint erhalten), (2) Ortho-Clamp gegen lastSnappedDown (Dispatcher verwaltet die Referenz selbst, Legacy-Konvention dx-vs-dy), (3) natives Pan bei Mittel-/Rechtsklick-Drag (ZoomPan.pan), (4) Wheel-Zoom am Cursor, (5) Cursor-Meldung ohne aktives Tool (Statusleiste), requestRender-Dep; ToolState/ToolPhase nach ui.types verschoben (App/ui.types entkoppelt von interaction), dispatcher.test.ts auf neues Pan-Verhalten angepasst (Rechtsklick = natives Pan statt cancel, ESC bleibt cancel). ERGEBNIS: src/interaction/index.ts GELÖSCHT — EINE Engine im Code, dispatcher-nativ; dispatcher.test.ts 6/6, dispatcherNative 4/4; BEWEIS-TEST GRÜN nach Deploy: Zeichnen=1, DB=1, Auswahl=1, Optionen=3 (chordCount=3 ankommen) | Frontend: 903 Tests, tsc 0, Build 0, E2E-Beweis GRÜN | c244571 | PHASE 4c COMPLETE: NUR NOCH EINE ENGINE |
|
||||||
|
| 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-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-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-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 |
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
+85
-3
@@ -6,7 +6,7 @@ import type {
|
|||||||
import type { CADElement, CADLayer, BlockDefinition, ImageElement } from './types/cad.types';
|
import type { CADElement, CADLayer, BlockDefinition, ImageElement } from './types/cad.types';
|
||||||
import { BlockService } from './services/blockService';
|
import { BlockService } from './services/blockService';
|
||||||
import { SeatingService } from './services/seatingService';
|
import { SeatingService } from './services/seatingService';
|
||||||
import type { ToolState } from './interaction';
|
import type { ToolState } from './types/ui.types';
|
||||||
import { GroupManager, type ElementGroup } from './tools/modification/GroupTool';
|
import { GroupManager, type ElementGroup } from './tools/modification/GroupTool';
|
||||||
import Topbar from './components/Topbar';
|
import Topbar from './components/Topbar';
|
||||||
import RibbonBar from './components/RibbonBar';
|
import RibbonBar from './components/RibbonBar';
|
||||||
@@ -18,6 +18,10 @@ import StatusBar from './components/StatusBar';
|
|||||||
import LayoutBar from './components/LayoutBar';
|
import LayoutBar from './components/LayoutBar';
|
||||||
import MobileDrawers from './components/MobileDrawers';
|
import MobileDrawers from './components/MobileDrawers';
|
||||||
import BackgroundImport from './components/BackgroundImport';
|
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 HistoryPanel from './components/HistoryPanel';
|
||||||
import V2HistoryPanel from './components/V2HistoryPanel';
|
import V2HistoryPanel from './components/V2HistoryPanel';
|
||||||
import GuestPanel from './components/GuestPanel';
|
import GuestPanel from './components/GuestPanel';
|
||||||
@@ -146,8 +150,8 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pluginRegistry.setContext(createPluginContext());
|
pluginRegistry.setContext(createPluginContext());
|
||||||
registerBuiltinPlugins();
|
// CAD-14 Audit-Fix: main.tsx initialisiert die Registry bereits vor dem
|
||||||
pluginRegistry.initDefaults();
|
// Render — hier nur Context nachziehen, kein Doppel-Register (Warnungen).
|
||||||
}, [createPluginContext]);
|
}, [createPluginContext]);
|
||||||
|
|
||||||
// Status bar – only count self as online when actually connected
|
// Status bar – only count self as online when actually connected
|
||||||
@@ -178,8 +182,78 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
|
|
||||||
// History panel
|
// History panel
|
||||||
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
||||||
|
const [bomDialogOpen, setBomDialogOpen] = useState(false);
|
||||||
const [guestPanelOpen, setGuestPanelOpen] = 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)
|
// Export format selector (cross-browser replacement for nwsave)
|
||||||
const [exportFormatOpen, setExportFormatOpen] = useState(false);
|
const [exportFormatOpen, setExportFormatOpen] = useState(false);
|
||||||
|
|
||||||
@@ -1164,6 +1238,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
|
|
||||||
// ─── Background ───
|
// ─── Background ───
|
||||||
if (action === 'background-import') { setBgImportOpen(true); return; }
|
if (action === 'background-import') { setBgImportOpen(true); return; }
|
||||||
|
if (action === 'bom') { setBomDialogOpen(true); return; }
|
||||||
|
|
||||||
// ─── KI actions ───
|
// ─── KI actions ───
|
||||||
if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; }
|
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
|
<RightSidebar
|
||||||
activePanel={activeRightPanel}
|
activePanel={activeRightPanel}
|
||||||
onPanelChange={setActiveRightPanel}
|
onPanelChange={setActiveRightPanel}
|
||||||
|
activeToolId={activeTool}
|
||||||
selectedElement={selectedElement}
|
selectedElement={selectedElement}
|
||||||
layers={layers}
|
layers={layers}
|
||||||
blocks={blocks}
|
blocks={blocks}
|
||||||
@@ -1749,6 +1825,12 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
onApply={handleBgApply}
|
onApply={handleBgApply}
|
||||||
backgroundService={bgServiceRef.current}
|
backgroundService={bgServiceRef.current}
|
||||||
/>
|
/>
|
||||||
|
{bomDialogOpen && getActiveDocument() && (
|
||||||
|
<BOMDialog
|
||||||
|
elements={getActiveDocument()!.getAllElements()}
|
||||||
|
onClose={() => setBomDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{historyPanelOpen && (V2_TOOLS_ENABLED ? (
|
{historyPanelOpen && (V2_TOOLS_ENABLED ? (
|
||||||
<V2HistoryPanel
|
<V2HistoryPanel
|
||||||
onClose={() => setHistoryPanelOpen(false)}
|
onClose={() => setHistoryPanelOpen(false)}
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ export class RenderEngine {
|
|||||||
/** src des zuletzt erfolgreich geladenen Hintergrundbilds. */
|
/** src des zuletzt erfolgreich geladenen Hintergrundbilds. */
|
||||||
private bgSrcLoaded = '';
|
private bgSrcLoaded = '';
|
||||||
private dpr = 1;
|
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(
|
constructor(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
@@ -161,8 +169,17 @@ export class RenderEngine {
|
|||||||
if (this.options.showGrid) this.drawGrid(w, h);
|
if (this.options.showGrid) this.drawGrid(w, h);
|
||||||
if (this.options.backgroundSrc) this.drawBackground();
|
if (this.options.backgroundSrc) this.drawBackground();
|
||||||
this.drawRuler(w, h);
|
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 viewport = this.zoomPan.getViewport();
|
||||||
const visibleElements = this.spatialIndex.search({
|
const visibleElements = skipElements
|
||||||
|
? []
|
||||||
|
: this.spatialIndex.search({
|
||||||
minX: viewport.minX, minY: viewport.minY,
|
minX: viewport.minX, minY: viewport.minY,
|
||||||
maxX: viewport.maxX, maxY: viewport.maxY,
|
maxX: viewport.maxX, maxY: viewport.maxY,
|
||||||
});
|
});
|
||||||
@@ -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
|
// Draw preview element with dashed style
|
||||||
if (this.previewElement) {
|
if (this.previewElement) {
|
||||||
this.ctx.save();
|
this.ctx.save();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ToolType } from '../types/cad.types';
|
|||||||
import type { UserCursor } from '../crdt';
|
import type { UserCursor } from '../crdt';
|
||||||
import { RenderEngine } from '../canvas/RenderEngine';
|
import { RenderEngine } from '../canvas/RenderEngine';
|
||||||
import { ZoomPanController } from '../canvas/ZoomPanController';
|
import { ZoomPanController } from '../canvas/ZoomPanController';
|
||||||
import { InteractionEngine } from '../interaction';
|
|
||||||
import { SnapEngine } from '../canvas/SnapEngine';
|
import { SnapEngine } from '../canvas/SnapEngine';
|
||||||
import { SelectionEngine } from '../canvas/SelectionEngine';
|
import { SelectionEngine } from '../canvas/SelectionEngine';
|
||||||
import { SpatialIndex } from '../canvas/SpatialIndex';
|
import { SpatialIndex } from '../canvas/SpatialIndex';
|
||||||
@@ -20,6 +20,7 @@ import { setActiveDocument, getActiveDocument } from '../kernel/document/documen
|
|||||||
import { V2_TOOLS_ENABLED, USE_PIXI_RENDERER } from '../kernel/featureFlags';
|
import { V2_TOOLS_ENABLED, USE_PIXI_RENDERER } from '../kernel/featureFlags';
|
||||||
import { PixiRenderer } from '../render/pixi/PixiRenderer';
|
import { PixiRenderer } from '../render/pixi/PixiRenderer';
|
||||||
import { pluginRegistry } from '../plugins';
|
import { pluginRegistry } from '../plugins';
|
||||||
|
import { getToolOptions, setToolOptionsDefaults, defaultsFromSchema } from '../services/toolOptionsService';
|
||||||
|
|
||||||
const CanvasArea: React.FC<CanvasAreaProps> = ({
|
const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||||
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
|
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
|
||||||
@@ -33,14 +34,30 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const zoomPanRef = useRef<ZoomPanController | null>(null);
|
const zoomPanRef = useRef<ZoomPanController | null>(null);
|
||||||
const renderEngineRef = useRef<RenderEngine | null>(null);
|
const renderEngineRef = useRef<RenderEngine | null>(null);
|
||||||
const interactionRef = useRef<InteractionEngine | null>(null);
|
|
||||||
const dispatcherRef = useRef<InteractionDispatcher | null>(null);
|
const dispatcherRef = useRef<InteractionDispatcher | null>(null);
|
||||||
/** Task C2: PixiJS-Canvas-Ref für V2-Elemente-Overlay. */
|
/** Task C2: PixiJS-Canvas-Ref für V2-Elemente-Overlay. */
|
||||||
const pixiCanvasRef = useRef<HTMLCanvasElement>(null);
|
const pixiCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
/** Task C2: Aktiver PixiJS-Renderer (für Cleanup). */
|
/** Task C2: Aktiver PixiJS-Renderer (für Cleanup). */
|
||||||
const pixiRendererRef = useRef<PixiRenderer | null>(null);
|
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());
|
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;
|
||||||
|
/** CAD-14 Phase 4c: Frische Cursor-Meldung (Dispatcher-nativ, kein Legacy-Callback). */
|
||||||
|
const onCursorMovedRef = useRef(onCursorMoved);
|
||||||
|
onCursorMovedRef.current = onCursorMoved;
|
||||||
|
/** CAD-14 Phase 4c: Snap/Ortho-Zustände stale-sicher für den Dispatcher. */
|
||||||
|
const snapEnabledRef = useRef(snapEnabled);
|
||||||
|
snapEnabledRef.current = snapEnabled;
|
||||||
|
const orthoRef = useRef(orthoEnabled);
|
||||||
|
orthoRef.current = orthoEnabled;
|
||||||
const spatialIndexRef = useRef<SpatialIndex | null>(null);
|
const spatialIndexRef = useRef<SpatialIndex | null>(null);
|
||||||
const layerManagerRef = useRef<LayerManager | null>(null);
|
const layerManagerRef = useRef<LayerManager | null>(null);
|
||||||
const selectionEngineRef = useRef<SelectionEngine | null>(null);
|
const selectionEngineRef = useRef<SelectionEngine | null>(null);
|
||||||
@@ -55,9 +72,6 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
const renderEngine = new RenderEngine(canvas, zoomPan, spatialIndex, layerManager);
|
const renderEngine = new RenderEngine(canvas, zoomPan, spatialIndex, layerManager);
|
||||||
const snapEngine = new SnapEngine();
|
const snapEngine = new SnapEngine();
|
||||||
const selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager);
|
const selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager);
|
||||||
const interaction = new InteractionEngine(
|
|
||||||
canvas, zoomPan, renderEngine, snapEngine, selectionEngine, spatialIndex, layerManager,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Task A2/A4.14: V2-Pfad ist seit Verifikation STANDARD — Opt-out via
|
// ── Task A2/A4.14: V2-Pfad ist seit Verifikation STANDARD — Opt-out via
|
||||||
// VITE_TOOL_DISPATCHER=legacy. Siehe kernel/featureFlags.ts.
|
// VITE_TOOL_DISPATCHER=legacy. Siehe kernel/featureFlags.ts.
|
||||||
@@ -82,21 +96,32 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
pixi = new PixiRenderer();
|
pixi = new PixiRenderer();
|
||||||
pixiRendererRef.current = pixi;
|
pixiRendererRef.current = pixi;
|
||||||
pixi.init(pixiCanvas);
|
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(() => {
|
pixi.waitForReady().then(() => {
|
||||||
// Task C4: Debug-Hook für Playwright-E2E-Assertions
|
// Task C4: Debug-Hook für Playwright-E2E-Assertions
|
||||||
(window as unknown as { __v2Debug: unknown }).__v2Debug = {
|
(window as unknown as { __v2Debug: unknown }).__v2Debug = {
|
||||||
renderer: pixi,
|
renderer: pixi,
|
||||||
info: () => pixiRendererRef.current?.getDebugInfo() ?? null,
|
info: () => pixiRendererRef.current?.getDebugInfo() ?? null,
|
||||||
|
docCount: () => v2Doc.getAllElements().length,
|
||||||
|
announcedCount: () => announcedV2Ids.current.size,
|
||||||
};
|
};
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
logger.warn('Pixi init failed — V2-Overlay deaktiviert:', err);
|
logger.warn('Pixi init failed — V2-Overlay deaktiviert:', err);
|
||||||
pixi = null;
|
pixi = null;
|
||||||
pixiRendererRef.current = null;
|
pixiRendererRef.current = null;
|
||||||
|
// CAD-13-fix Fallback: Ohne Pixi muss Legacy wieder Elemente zeichnen
|
||||||
|
renderEngine.skipElementRendering = false;
|
||||||
|
renderEngine.render();
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Pixi init exception — V2-Overlay deaktiviert:', err);
|
logger.warn('Pixi init exception — V2-Overlay deaktiviert:', err);
|
||||||
pixi = null;
|
pixi = null;
|
||||||
pixiRendererRef.current = 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). */
|
/** Letztes bekanntes Elementobjekt je ID (Referenzdiff statt JSON). */
|
||||||
@@ -135,7 +160,9 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
for (const el of v2Doc.getAllElements()) {
|
for (const el of v2Doc.getAllElements()) {
|
||||||
if (!announcedV2Ids.current.has(el.id)) {
|
if (!announcedV2Ids.current.has(el.id)) {
|
||||||
announcedV2Ids.current.add(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
|
syncPixi(); // Task C2: V2-Elemente in den PixiJS-Overlay-Renderer spiegeln
|
||||||
@@ -143,15 +170,45 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
|
|
||||||
dispatcherRef.current = new InteractionDispatcher(canvas, {
|
dispatcherRef.current = new InteractionDispatcher(canvas, {
|
||||||
zoomPan,
|
zoomPan,
|
||||||
|
// CAD-14 Phase 4c: Snap aus der bestehenden SnapEngine (grid-snaps),
|
||||||
|
// Ortho via Ref (stale-sicher).
|
||||||
|
snap: (x: number, y: number) => {
|
||||||
|
if (!snapEnabledRef.current) return { x, y };
|
||||||
|
const result = snapEngine.snap(x, y);
|
||||||
|
if (result.point) {
|
||||||
|
renderEngine.setSnapPoints(result.preview);
|
||||||
|
renderEngine.setActiveSnapPoint(result.point);
|
||||||
|
return { x: result.point.x, y: result.point.y };
|
||||||
|
}
|
||||||
|
renderEngine.setSnapPoints([]);
|
||||||
|
renderEngine.setActiveSnapPoint(null);
|
||||||
|
return { x, y };
|
||||||
|
},
|
||||||
|
onCursorMoved: (wx: number, wy: number) => onCursorMovedRef.current?.(wx, wy),
|
||||||
|
// CAD-14 Phase 4c: Ortho-Clamp gegen den letzten gesnappten
|
||||||
|
// Down-Punkt (Legacy-Konvention) — Referenz verwaltet der Dispatcher.
|
||||||
|
ortho: (x: number, y: number, last: { x: number; y: number } | null) => {
|
||||||
|
if (!orthoRef.current || !last) return { x, y };
|
||||||
|
const dx = Math.abs(x - last.x);
|
||||||
|
const dy = Math.abs(y - last.y);
|
||||||
|
return dx >= dy ? { x, y: last.y } : { x: last.x, y };
|
||||||
|
},
|
||||||
|
requestRender: () => renderEngine.render(),
|
||||||
setStatus: (msg) => {
|
setStatus: (msg) => {
|
||||||
/* StatusBar-Bridge folgt in A4 */
|
/* StatusBar-Bridge folgt in A4 */
|
||||||
},
|
},
|
||||||
setPreview: (el) => renderEngine.setPreviewElement(el as CADElement | null),
|
setPreview: (el) => renderEngine.setPreviewElement(el as CADElement | null),
|
||||||
getDoc: () => v2Doc,
|
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: () => ({
|
getSelectionBridge: () => ({
|
||||||
getIds: () => Array.from(selectionEngine.getSelectedIds()),
|
getIds: () => Array.from(selectionEngine.getSelectedIds()),
|
||||||
setIds: (ids) => selectionEngine.selectByIds(ids, false),
|
setIds: (ids) => selectionEngine.selectByIds(ids, false),
|
||||||
hitTest: (wx, wy, tol) => renderEngine.hitTest(wx, wy, tol),
|
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,31 +256,24 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
pixi!.setSelection({ selectedIds: ids, elements: selected });
|
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)');
|
logger.info('InteractionDispatcher v2 aktiviert (VITE_TOOL_DISPATCHER=v2)');
|
||||||
}
|
}
|
||||||
|
|
||||||
zoomPanRef.current = zoomPan;
|
zoomPanRef.current = zoomPan;
|
||||||
renderEngineRef.current = renderEngine;
|
renderEngineRef.current = renderEngine;
|
||||||
interactionRef.current = interaction;
|
|
||||||
spatialIndexRef.current = spatialIndex;
|
spatialIndexRef.current = spatialIndex;
|
||||||
layerManagerRef.current = layerManager;
|
layerManagerRef.current = layerManager;
|
||||||
selectionEngineRef.current = selectionEngine;
|
selectionEngineRef.current = selectionEngine;
|
||||||
|
|
||||||
interaction.setCallbacks({
|
|
||||||
onElementCreated,
|
|
||||||
onElementsDeleted,
|
|
||||||
onElementsModified,
|
|
||||||
onCursorMoved,
|
|
||||||
onToolStateChanged,
|
|
||||||
onTextEdit,
|
|
||||||
onCommandTrigger,
|
|
||||||
onSelectionChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
interaction.attach();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
interaction.detach();
|
|
||||||
dispatcherRef.current?.destroy();
|
dispatcherRef.current?.destroy();
|
||||||
dispatcherRef.current = null;
|
dispatcherRef.current = null;
|
||||||
pixiRendererRef.current?.destroy(); // Task C2: WebGL-Kontext freigeben
|
pixiRendererRef.current?.destroy(); // Task C2: WebGL-Kontext freigeben
|
||||||
@@ -231,7 +281,6 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
setActiveDocument(null);
|
setActiveDocument(null);
|
||||||
zoomPanRef.current = null;
|
zoomPanRef.current = null;
|
||||||
renderEngineRef.current = null;
|
renderEngineRef.current = null;
|
||||||
interactionRef.current = null;
|
|
||||||
spatialIndexRef.current = null;
|
spatialIndexRef.current = null;
|
||||||
layerManagerRef.current = null;
|
layerManagerRef.current = null;
|
||||||
selectionEngineRef.current = null;
|
selectionEngineRef.current = null;
|
||||||
@@ -239,23 +288,6 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Update callbacks when they change (avoids stale closures)
|
|
||||||
useEffect(() => {
|
|
||||||
const interaction = interactionRef.current;
|
|
||||||
if (!interaction) return;
|
|
||||||
interaction.setCallbacks({
|
|
||||||
onElementCreated,
|
|
||||||
onElementsDeleted,
|
|
||||||
onElementsModified,
|
|
||||||
onCursorMoved,
|
|
||||||
onToolStateChanged,
|
|
||||||
onTextEdit,
|
|
||||||
onCommandTrigger,
|
|
||||||
onSelectionChange,
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, onTextEdit, onCommandTrigger, onSelectionChange]);
|
|
||||||
|
|
||||||
// Resize canvas to fill container
|
// Resize canvas to fill container
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -284,18 +316,63 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sync elements to interaction engine + spatial index + render
|
// Sync elements to spatial index + render (CAD-14: keine Legacy-Engine)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interaction = interactionRef.current;
|
|
||||||
const spatialIndex = spatialIndexRef.current;
|
const spatialIndex = spatialIndexRef.current;
|
||||||
const renderEngine = renderEngineRef.current;
|
const renderEngine = renderEngineRef.current;
|
||||||
if (!interaction || !spatialIndex || !renderEngine) return;
|
if (!spatialIndex || !renderEngine) return;
|
||||||
interaction.setElements(elements);
|
|
||||||
spatialIndex.clear();
|
spatialIndex.clear();
|
||||||
spatialIndex.bulkInsert(elements);
|
spatialIndex.bulkInsert(elements);
|
||||||
|
renderEngine.setOverlayElements(elements);
|
||||||
renderEngine.render();
|
renderEngine.render();
|
||||||
|
|
||||||
|
// CAD-14 Audit-Fix: App-Löschungen in das V2-Dokument zurueckspiegeln —
|
||||||
|
// sonst bleiben in App geloeschte Elemente als Geister in v2Doc/Pixi
|
||||||
|
// haengen und kamen beim naechsten onChanged zurueck.
|
||||||
|
const activeDoc = getActiveDocument();
|
||||||
|
if (activeDoc) {
|
||||||
|
const appIds = new Set(elements.map((e) => e.id));
|
||||||
|
const ghostIds = activeDoc.getAllElements().filter((e) => !appIds.has(e.id)).map((e) => e.id);
|
||||||
|
if (ghostIds.length > 0) {
|
||||||
|
activeDoc.deleteElements(ghostIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [elements]);
|
}, [elements, layers]);
|
||||||
|
|
||||||
// Sync layers to LayerManager + render
|
// Sync layers to LayerManager + render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -320,21 +397,12 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [blocks]);
|
}, [blocks]);
|
||||||
|
|
||||||
// Sync groups to RenderEngine and InteractionEngine
|
// Sync groups to RenderEngine (CAD-14: keine Legacy-Engine mehr)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const renderEngine = renderEngineRef.current;
|
const renderEngine = renderEngineRef.current;
|
||||||
const interaction = interactionRef.current;
|
|
||||||
if (!renderEngine) return;
|
if (!renderEngine) return;
|
||||||
renderEngine.setGroups(groups ?? []);
|
renderEngine.setGroups(groups ?? []);
|
||||||
renderEngine.render();
|
renderEngine.render();
|
||||||
// InteractionEngine uses GroupManager from App — we pass it via a simple adapter
|
|
||||||
if (interaction && groups) {
|
|
||||||
const gm = new GroupManager();
|
|
||||||
gm.fromJSON(groups);
|
|
||||||
interaction.setGroupManager(gm);
|
|
||||||
} else if (interaction) {
|
|
||||||
interaction.setGroupManager(null);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [groups]);
|
}, [groups]);
|
||||||
|
|
||||||
@@ -368,11 +436,10 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeLayerId, layers]);
|
}, [activeLayerId, layers]);
|
||||||
|
|
||||||
// Sync grid/snap/ortho toggles
|
// Sync grid/snap/ortho toggles (CAD-14: Dispatcher liest snap/ortho via Refs)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const renderEngine = renderEngineRef.current;
|
const renderEngine = renderEngineRef.current;
|
||||||
const interaction = interactionRef.current;
|
if (!renderEngine) return;
|
||||||
if (!renderEngine || !interaction) return;
|
|
||||||
renderEngine.setOptions({ showGrid: gridEnabled });
|
renderEngine.setOptions({ showGrid: gridEnabled });
|
||||||
renderEngine.setOptions({ showSnapPoints: snapEnabled });
|
renderEngine.setOptions({ showSnapPoints: snapEnabled });
|
||||||
renderEngine.setOptions({ showOrtho: orthoEnabled });
|
renderEngine.setOptions({ showOrtho: orthoEnabled });
|
||||||
@@ -391,25 +458,14 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
backgroundOpacity: bgConfig?.opacity ?? 0.5,
|
backgroundOpacity: bgConfig?.opacity ?? 0.5,
|
||||||
});
|
});
|
||||||
renderEngine.render();
|
renderEngine.render();
|
||||||
interaction.setSnapEnabled(snapEnabled);
|
|
||||||
interaction.setOrthoEnabled(orthoEnabled);
|
|
||||||
interaction.setPolarEnabled(polarEnabled);
|
|
||||||
interaction.setUnits(unit, scaleFactor);
|
|
||||||
interaction.setGridSize(gridSize);
|
|
||||||
renderEngine.render();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled, unit, gridSize, scaleFactor, canvasBgColor, gridColor, rulerEnabled, bgConfig]);
|
}, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled, unit, gridSize, scaleFactor, canvasBgColor, gridColor, rulerEnabled, bgConfig]);
|
||||||
|
|
||||||
// Sync active tool
|
// Sync active tool (CAD-14: ausschliesslich Dispatcher)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interaction = interactionRef.current;
|
// CAD-14 Phase 4c: Legacy-Engine entfernt — der Dispatcher ist die
|
||||||
if (!interaction) return;
|
// EINZIGE Interaktions-Engine fuer alle Werkzeuge.
|
||||||
interaction.setTool(activeTool as ToolType);
|
dispatcherRef.current?.setActive(activeTool);
|
||||||
// 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,
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTool]);
|
}, [activeTool]);
|
||||||
|
|
||||||
@@ -433,14 +489,6 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sync selected template to interaction engine
|
|
||||||
useEffect(() => {
|
|
||||||
const interaction = interactionRef.current;
|
|
||||||
if (!interaction) return;
|
|
||||||
interaction.setSelectedTemplate(selectedTemplate ?? null);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selectedTemplate]);
|
|
||||||
|
|
||||||
// Compute screen positions for remote cursors
|
// Compute screen positions for remote cursors
|
||||||
const [cursorScreenPositions, setCursorScreenPositions] = useState<Array<{ cursor: UserCursor; sx: number; sy: number }>>([]);
|
const [cursorScreenPositions, setCursorScreenPositions] = useState<Array<{ cursor: UserCursor; sx: number; sy: number }>>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -500,9 +548,10 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleZoomFit = () => {
|
const handleZoomFit = () => {
|
||||||
const interaction = interactionRef.current;
|
// CAD-14 Phase 4c: zoomFit direkt ueber ZoomPan (V2-Dokument-Elemente)
|
||||||
if (interaction) {
|
const zoomPan = zoomPanRef.current;
|
||||||
interaction.zoomFit();
|
if (zoomPan) {
|
||||||
|
zoomPan.zoomFit(getActiveDocument()?.getAllElements() ?? elements);
|
||||||
}
|
}
|
||||||
onZoomFit();
|
onZoomFit();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 type { LeftSidebarProps } from '../types/ui.types';
|
||||||
import { SEATING_TEMPLATES } from '../services/seatingService';
|
import { SEATING_TEMPLATES } from '../services/seatingService';
|
||||||
|
import { pluginRegistry } from '../plugins';
|
||||||
|
|
||||||
interface ToolDef {
|
interface ToolDef {
|
||||||
tool: string;
|
tool: string;
|
||||||
@@ -138,6 +139,35 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ activeTool, onToolChange, sel
|
|||||||
</div>
|
</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) && (
|
{(onGroup || onUngroup) && (
|
||||||
<div className="tool-section" key="grouping">
|
<div className="tool-section" key="grouping">
|
||||||
{renderSectionLabel("Gruppierung", "grouping")}
|
{renderSectionLabel("Gruppierung", "grouping")}
|
||||||
|
|||||||
@@ -1,72 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useUIMode } from '../services/uiModeService';
|
|
||||||
import { useT } from '../i18n/strings';
|
|
||||||
import type { RibbonBarProps, RibbonTab } from '../types/ui.types';
|
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 }> = [
|
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: '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: 'draw', label: 'Zeichnen', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></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: '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: '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> },
|
{ 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> },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** V2-Werkzeug-Kategorien → Gruppen-Label im Zeichnen-Tab. */
|
|
||||||
const V2_GROUP_LABELS: Record<string, string> = {
|
|
||||||
canvas: 'Zeichnen & Ändern',
|
|
||||||
format: 'Bemaßung & Text',
|
|
||||||
insert: 'Bestuhlung & Traversen',
|
|
||||||
};
|
|
||||||
|
|
||||||
const RibbonBar: React.FC<RibbonBarProps> = ({
|
const RibbonBar: React.FC<RibbonBarProps> = ({
|
||||||
activeTab, onTabChange, onAction,
|
activeTab, onTabChange, onAction,
|
||||||
canvasBgColor = '#1e1e2e', gridColor = '#3a3a4a', rulerEnabled = true,
|
canvasBgColor = '#1e1e2e', gridColor = '#3a3a4a', rulerEnabled = true,
|
||||||
onCanvasBgColorChange, onGridColorChange, onToggleRuler,
|
onCanvasBgColorChange, onGridColorChange, onToggleRuler,
|
||||||
onIsV2ToolActive,
|
onIsV2ToolActive,
|
||||||
}) => {
|
}) => {
|
||||||
const uiMode = useUIMode();
|
|
||||||
|
|
||||||
/** Rendert die V2-Werkzeuge einer Kategorie als Button-Gruppe. */
|
|
||||||
const renderV2Group = (category: string) => {
|
|
||||||
const tools = pluginRegistry.getToolsV2()
|
|
||||||
.filter((tool) => tool.manifest.ribbonTab === category)
|
|
||||||
.filter((tool) => uiMode === 'pro' || (tool.manifest.tags ?? []).includes('basic'));
|
|
||||||
if (tools.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div className="ribbon-group">
|
|
||||||
<div className="ribbon-group-btns">
|
|
||||||
{tools.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}`)}
|
|
||||||
className={`ribbon-v2-btn${isActive ? ' active' : ''}`}
|
|
||||||
>
|
|
||||||
<span className="ribbon-v2-icon">{tool.manifest.icon}</span>
|
|
||||||
<span className="ribbon-v2-label">{tool.manifest.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{category === 'insert' && (
|
|
||||||
<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">{V2_GROUP_LABELS[category] ?? category}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
|
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
|
||||||
<div className="ribbon-tabs" role="tablist">
|
<div className="ribbon-tabs" role="tablist">
|
||||||
@@ -147,15 +93,6 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ===== ZEICHNEN TAB ===== */}
|
|
||||||
{activeTab === 'draw' && V2_TOOLS_ENABLED && (
|
|
||||||
<div className="ribbon-draw-content">
|
|
||||||
{renderV2Group('canvas')}
|
|
||||||
{renderV2Group('format')}
|
|
||||||
{renderV2Group('insert')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ===== ANSICHT TAB ===== */}
|
{/* ===== ANSICHT TAB ===== */}
|
||||||
{activeTab === 'view' && (
|
{activeTab === 'view' && (
|
||||||
<>
|
<>
|
||||||
@@ -215,6 +152,10 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
|||||||
<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>
|
<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>
|
<span>Gäste</span>
|
||||||
</button>
|
</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>
|
||||||
<div className="ribbon-group-label">Extras</div>
|
<div className="ribbon-group-label">Extras</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useCallback } from 'react';
|
|||||||
import type { RightSidebarProps, RightPanel } from '../types/ui.types';
|
import type { RightSidebarProps, RightPanel } from '../types/ui.types';
|
||||||
import type { LibraryProviderExtension } from '../plugins/types';
|
import type { LibraryProviderExtension } from '../plugins/types';
|
||||||
import PropertiesPanel from './PropertiesPanel';
|
import PropertiesPanel from './PropertiesPanel';
|
||||||
|
import ToolOptionsPanel from './ToolOptionsPanel';
|
||||||
import LayerPanel from './LayerPanel';
|
import LayerPanel from './LayerPanel';
|
||||||
import BlockLibrary from './BlockLibrary';
|
import BlockLibrary from './BlockLibrary';
|
||||||
import BlockLibraryTree from './BlockLibraryTree';
|
import BlockLibraryTree from './BlockLibraryTree';
|
||||||
@@ -23,6 +24,7 @@ const tabs: Array<{ id: RightPanel; label: string; svg: React.ReactNode; badge?:
|
|||||||
|
|
||||||
const RightSidebar: React.FC<RightSidebarProps> = ({
|
const RightSidebar: React.FC<RightSidebarProps> = ({
|
||||||
activePanel, onPanelChange, selectedElement, layers, blocks,
|
activePanel, onPanelChange, selectedElement, layers, blocks,
|
||||||
|
activeToolId,
|
||||||
className,
|
className,
|
||||||
activeLayerId, onSelectLayer, onAddLayer, onToggleLayer,
|
activeLayerId, onSelectLayer, onAddLayer, onToggleLayer,
|
||||||
onDeleteLayer, onRenameLayer, onDuplicateLayer, onToggleLock,
|
onDeleteLayer, onRenameLayer, onDuplicateLayer, onToggleLock,
|
||||||
@@ -116,6 +118,7 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="rightbar-content">
|
<div className="rightbar-content">
|
||||||
<div className={`rightbar-panel${activePanel === 'tool' ? ' active' : ''}`} data-panel-content="tool">
|
<div className={`rightbar-panel${activePanel === 'tool' ? ' active' : ''}`} data-panel-content="tool">
|
||||||
|
<ToolOptionsPanel activeToolId={activeToolId} />
|
||||||
{activePanel === 'tool' && selectedElement && onUpdateElement && (
|
{activePanel === 'tool' && selectedElement && onUpdateElement && (
|
||||||
<PropertiesPanel selectedElement={selectedElement} layers={layers} unit={unit} scaleFactor={scaleFactor} onDelete={selectedElement && onElementsDeleted ? () => onElementsDeleted([selectedElement.id]) : undefined} onUpdateProperty={(key, value) => {
|
<PropertiesPanel selectedElement={selectedElement} layers={layers} unit={unit} scaleFactor={scaleFactor} onDelete={selectedElement && onElementsDeleted ? () => onElementsDeleted([selectedElement.id]) : undefined} onUpdateProperty={(key, value) => {
|
||||||
if (!selectedElement || !onUpdateElement) return;
|
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')}
|
aria-label={uiMode === 'pro' ? t('mode.toSimple') : t('mode.toPro')}
|
||||||
onClick={toggleUIMode}
|
onClick={toggleUIMode}
|
||||||
>
|
>
|
||||||
{uiMode === 'pro' ? '😊' : '🛠'}
|
{uiMode === 'pro' ? 'Einfach' : 'Pro'}
|
||||||
</button>
|
</button>
|
||||||
<button className="icon-btn-top" aria-label="Rückgängig (Strg+Z)" title="Rückgängig (Strg+Z)" onClick={onUndo}>
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import type { ZoomPanController } from '../canvas/ZoomPanController';
|
|||||||
|
|
||||||
export interface DispatcherDeps {
|
export interface DispatcherDeps {
|
||||||
zoomPan: ZoomPanController;
|
zoomPan: ZoomPanController;
|
||||||
|
/** CAD-14 Phase 4c: Snap-Funktion (Weltkoordinaten) fuer Tool-Events. */
|
||||||
|
snap?(x: number, y: number): { x: number; y: number };
|
||||||
|
/** CAD-14 Phase 4c: Cursor-Meldung (auch ohne aktives Tool). */
|
||||||
|
onCursorMoved?(worldX: number, worldY: number): void;
|
||||||
|
/** CAD-14 Phase 4c: Render-Anstoess nach Pan/Wheel (CanvasArea ruft renderEngine.render). */
|
||||||
|
requestRender?(): void;
|
||||||
|
/** CAD-14 Phase 4c: Ortho-Anwendung gegen den letzten gesnappten Down-Punkt. */
|
||||||
|
ortho?(x: number, y: number, last: { x: number; y: number } | null): { x: number; y: number };
|
||||||
/** Liefert Optionsstände je ToolId (typisch: toolStore.optionValues). */
|
/** Liefert Optionsstände je ToolId (typisch: toolStore.optionValues). */
|
||||||
getOptions?(toolId: string): Record<string, unknown>;
|
getOptions?(toolId: string): Record<string, unknown>;
|
||||||
/** Statuszeile (typisch: StatusBar-Bridge). */
|
/** Statuszeile (typisch: StatusBar-Bridge). */
|
||||||
@@ -81,6 +89,9 @@ export class InteractionDispatcher {
|
|||||||
window.removeEventListener('pointermove', this.onMove);
|
window.removeEventListener('pointermove', this.onMove);
|
||||||
window.removeEventListener('pointerup', this.onUp);
|
window.removeEventListener('pointerup', this.onUp);
|
||||||
window.removeEventListener('keydown', this.onKey);
|
window.removeEventListener('keydown', this.onKey);
|
||||||
|
this.canvas.removeEventListener('wheel', this.onWheelNative as EventListener);
|
||||||
|
window.removeEventListener('pointermove', this.onPanMove);
|
||||||
|
window.removeEventListener('pointerup', this.onPanEnd);
|
||||||
this.bound = false;
|
this.bound = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +103,46 @@ export class InteractionDispatcher {
|
|||||||
window.addEventListener('pointermove', this.onMove);
|
window.addEventListener('pointermove', this.onMove);
|
||||||
window.addEventListener('pointerup', this.onUp);
|
window.addEventListener('pointerup', this.onUp);
|
||||||
window.addEventListener('keydown', this.onKey);
|
window.addEventListener('keydown', this.onKey);
|
||||||
|
// CAD-14 Phase 4c: natives Pan + Wheel (ersetzt Legacy-Engine)
|
||||||
|
this.canvas.addEventListener('wheel', this.onWheelNative as EventListener, { passive: false });
|
||||||
|
window.addEventListener('pointermove', this.onPanMove);
|
||||||
|
window.addEventListener('pointerup', this.onPanEnd);
|
||||||
this.bound = true;
|
this.bound = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CAD-14 Phase 4c: natives Pan (Mittel-/Rechtsklick-Drag) ──
|
||||||
|
private panActive = false;
|
||||||
|
private lastPanScreen = { x: 0, y: 0 };
|
||||||
|
/** CAD-14 Phase 4c: Letzter gesnappter Down-Punkt (Ortho-Referenz). */
|
||||||
|
private lastSnappedDown: { x: number; y: number } | null = null;
|
||||||
|
|
||||||
|
private onPanMove = (e: PointerEvent): void => {
|
||||||
|
if (!this.panActive) return;
|
||||||
|
const dx = e.clientX - this.lastPanScreen.x;
|
||||||
|
const dy = e.clientY - this.lastPanScreen.y;
|
||||||
|
this.lastPanScreen = { x: e.clientX, y: e.clientY };
|
||||||
|
this.deps.zoomPan.pan(dx, dy);
|
||||||
|
this.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onPanEnd = (): void => {
|
||||||
|
this.panActive = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private onWheelNative = (e: WheelEvent): void => {
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const cx = e.clientX - rect.left;
|
||||||
|
const cy = e.clientY - rect.top;
|
||||||
|
this.deps.zoomPan.zoomAt(cx, cy, factor);
|
||||||
|
this.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
private requestRender(): void {
|
||||||
|
this.deps.requestRender?.();
|
||||||
|
}
|
||||||
|
|
||||||
private buildContext(): import('../plugins/types').FullToolContext {
|
private buildContext(): import('../plugins/types').FullToolContext {
|
||||||
const toolId = this.active?.manifest.id ?? '';
|
const toolId = this.active?.manifest.id ?? '';
|
||||||
return {
|
return {
|
||||||
@@ -112,26 +160,31 @@ export class InteractionDispatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private onDown = (e: PointerEvent): void => {
|
private onDown = (e: PointerEvent): void => {
|
||||||
if (!this.active) return;
|
// CAD-14 Phase 4c: natives Pan bei Mittel-/Rechtsklick (auch ohne Tool)
|
||||||
// Rechtsklick = Abbruch wie ESC
|
if (e.button === 1 || e.button === 2) {
|
||||||
if (e.button === 2) {
|
this.panActive = true;
|
||||||
this.cancelActive();
|
this.lastPanScreen = { x: e.clientX, y: e.clientY };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
|
if (!this.active) return;
|
||||||
|
const tpe = this.applySnapToEvent(toToolPointerEvent(e, this.canvas, this.deps.zoomPan));
|
||||||
|
this.lastSnappedDown = { x: tpe.world.x, y: tpe.world.y };
|
||||||
this.session.begin();
|
this.session.begin();
|
||||||
this.active.handlers.down?.(tpe, this.buildContext());
|
this.active.handlers.down?.(tpe, this.buildContext());
|
||||||
};
|
};
|
||||||
|
|
||||||
private onMove = (e: PointerEvent): void => {
|
private onMove = (e: PointerEvent): void => {
|
||||||
|
const tpeRaw = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
|
||||||
|
// CAD-14 Phase 4c: Cursor-Meldung auch ohne aktives Tool (Statusleiste)
|
||||||
|
this.deps.onCursorMoved?.(tpeRaw.world.x, tpeRaw.world.y);
|
||||||
if (!this.active || !this.session.isActive) return;
|
if (!this.active || !this.session.isActive) return;
|
||||||
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
|
const tpe = this.applySnapToEvent(tpeRaw);
|
||||||
this.active.handlers.move?.(tpe, this.buildContext());
|
this.active.handlers.move?.(tpe, this.buildContext());
|
||||||
};
|
};
|
||||||
|
|
||||||
private onUp = (e: PointerEvent): void => {
|
private onUp = (e: PointerEvent): void => {
|
||||||
if (!this.active || !this.session.isActive) return;
|
if (!this.active || !this.session.isActive) return;
|
||||||
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
|
const tpe = this.applySnapToEvent(toToolPointerEvent(e, this.canvas, this.deps.zoomPan));
|
||||||
try {
|
try {
|
||||||
this.active.handlers.up?.(tpe, this.buildContext());
|
this.active.handlers.up?.(tpe, this.buildContext());
|
||||||
} finally {
|
} finally {
|
||||||
@@ -149,6 +202,20 @@ export class InteractionDispatcher {
|
|||||||
if (consumed) e.preventDefault();
|
if (consumed) e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** CAD-14 Phase 4c: wendet Snap aus Deps auf ein Tool-Pointer-Event an. */
|
||||||
|
private applySnapToEvent(tpe: import('../plugins/types').ToolPointerEvent): import('../plugins/types').ToolPointerEvent {
|
||||||
|
if (!this.deps.snap && !this.deps.ortho) return tpe;
|
||||||
|
let p = { x: tpe.world.x, y: tpe.world.y };
|
||||||
|
if (this.deps.snap) {
|
||||||
|
const snapped = this.deps.snap(p.x, p.y);
|
||||||
|
p = { x: snapped.x, y: snapped.y };
|
||||||
|
}
|
||||||
|
if (this.deps.ortho) {
|
||||||
|
p = this.deps.ortho(p.x, p.y, this.lastSnappedDown);
|
||||||
|
}
|
||||||
|
return { ...tpe, world: p };
|
||||||
|
}
|
||||||
|
|
||||||
private cancelActive(): void {
|
private cancelActive(): void {
|
||||||
if (!this.active) return;
|
if (!this.active) return;
|
||||||
this.active.handlers.cancel?.(this.buildContext());
|
this.active.handlers.cancel?.(this.buildContext());
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,14 @@ import React from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
|
import { registerBuiltinPlugins, pluginRegistry } from './plugins';
|
||||||
|
|
||||||
|
// CAD-14 Audit-Fix: Registry VOR dem React-Render initialisieren, damit
|
||||||
|
// der erste CanvasArea-Mount (Child-Effekte laufen vor Parent-Effekten)
|
||||||
|
// gefuellte Tools vorfindet — sonst '[InteractionDispatcher] kein
|
||||||
|
// aktives V2-Tool select' beim App-Start.
|
||||||
|
registerBuiltinPlugins();
|
||||||
|
pluginRegistry.initDefaults();
|
||||||
|
|
||||||
const container = document.getElementById('root');
|
const container = document.getElementById('root');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
|
|||||||
@@ -1022,15 +1022,99 @@ export const mlineTool: ToolExtensionV2 = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = {
|
export const coreDrawingPlugin: PluginV2 = {
|
||||||
manifest: {
|
manifest: {
|
||||||
id: 'core-drawing',
|
id: 'core-drawing',
|
||||||
name: 'Zeichnen (Kern)',
|
name: 'Zeichnen (Kern)',
|
||||||
version: '1.5.0',
|
version: '1.6.0',
|
||||||
author: 'web-cad team',
|
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',
|
category: 'tools',
|
||||||
enabledByDefault: true,
|
enabledByDefault: true,
|
||||||
},
|
},
|
||||||
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool, mlineTool],
|
tools: [selectTool, lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool, mlineTool],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -157,10 +157,12 @@ export interface ToolContext {
|
|||||||
export interface ToolSelectionBridge {
|
export interface ToolSelectionBridge {
|
||||||
/** Aktuell selektierte Element-IDs. */
|
/** Aktuell selektierte Element-IDs. */
|
||||||
getIds(): string[];
|
getIds(): string[];
|
||||||
/** Setzt die Selektion vollständig. */
|
/** Setzt die Selektion vollständig (additive = zur bestehenden hinzufügen). */
|
||||||
setIds(ids: string[]): void;
|
setIds(ids: string[], additive?: boolean): void;
|
||||||
/** Element-Treffer an Weltposition (oder null). */
|
/** Element-Treffer an Weltposition (oder null). */
|
||||||
hitTest(worldX: number, worldY: number, tolerancePx: number): CADElement | 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). */
|
/** Erweiterter Kontext für Tools mit Dokument-/Selektionszugriff (A3). */
|
||||||
|
|||||||
@@ -230,7 +230,9 @@ export async function deleteElement(token: string, elementId: string): Promise<v
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to delete element');
|
// CAD-14 Audit-Fix: 404 = bereits geloescht (idempotent, Race-sicher —
|
||||||
|
// CRDT-Sync und REST-Kanal laufen parallel, zweiter Versuch ist kein Fehler).
|
||||||
|
if (!res.ok && res.status !== 404) throw new Error('Failed to delete element');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Layers ─────────────────────────────────────────────
|
// ─── Layers ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
}
|
||||||
+25
-8
@@ -335,7 +335,10 @@ a:hover { text-decoration: underline; }
|
|||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: var(--ribbon-h);
|
height: auto;
|
||||||
|
min-height: var(--ribbon-h);
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
z-index: 25;
|
z-index: 25;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -370,20 +373,22 @@ a:hover { text-decoration: underline; }
|
|||||||
.ribbon-content {
|
.ribbon-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
gap: var(--spacing-lg);
|
gap: var(--spacing-lg);
|
||||||
overflow: hidden;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.ribbon-group {
|
.ribbon-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
position: relative;
|
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 {
|
.ribbon-group-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
@@ -3081,9 +3086,9 @@ body.drawer-open { overflow: hidden; }
|
|||||||
/* ─── CAD-10: Zeichnen-Tab V2-Buttons ─── */
|
/* ─── CAD-10: Zeichnen-Tab V2-Buttons ─── */
|
||||||
.ribbon-draw-content {
|
.ribbon-draw-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
padding: 4px 0;
|
padding: 2px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.ribbon-v2-btn {
|
.ribbon-v2-btn {
|
||||||
@@ -3120,3 +3125,15 @@ body.drawer-open { overflow: hidden; }
|
|||||||
.ribbon-v2-btn.active .ribbon-v2-label {
|
.ribbon-v2-btn.active .ribbon-v2-label {
|
||||||
color: #4a90d9;
|
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; }
|
||||||
|
|||||||
@@ -3,13 +3,22 @@
|
|||||||
*/
|
*/
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { CADElement, CADLayer, BlockDefinition, ToolType } from './cad.types';
|
import type { CADElement, CADLayer, BlockDefinition, ToolType } from './cad.types';
|
||||||
import type { ToolState } from '../interaction';
|
/** CAD-14 Phase 4c: ToolState/ToolPhase (ehem. interaction/index) — V2-nativ. */
|
||||||
|
export type ToolPhase = 'idle' | 'drawing' | 'modifying' | 'panning' | 'selecting';
|
||||||
|
export interface ToolState {
|
||||||
|
activeTool: string | null;
|
||||||
|
phase: ToolPhase;
|
||||||
|
points: Array<{ x: number; y: number }>;
|
||||||
|
previewElement: import('./cad.types').CADElement | null;
|
||||||
|
ortho: boolean;
|
||||||
|
snapEnabled: boolean;
|
||||||
|
}
|
||||||
import type { BackgroundConfig } from '../services/backgroundService';
|
import type { BackgroundConfig } from '../services/backgroundService';
|
||||||
import type { UserCursor } from '../crdt';
|
import type { UserCursor } from '../crdt';
|
||||||
import type { UnitType } from '../utils/format';
|
import type { UnitType } from '../utils/format';
|
||||||
|
|
||||||
export type ViewMode = '2d' | 'iso' | 'front' | 'top';
|
export type ViewMode = '2d' | 'iso' | 'front' | 'top';
|
||||||
export type RibbonTab = 'start' | 'draw' | 'view' | 'ki';
|
export type RibbonTab = 'start' | 'view' | 'ki';
|
||||||
export type RightPanel = 'tool' | 'layer' | 'library' | 'ki';
|
export type RightPanel = 'tool' | 'layer' | 'library' | 'ki';
|
||||||
export type Theme = 'light' | 'dark';
|
export type Theme = 'light' | 'dark';
|
||||||
export type DrawerTab = 'tool' | 'layer' | 'library' | 'ki';
|
export type DrawerTab = 'tool' | 'layer' | 'library' | 'ki';
|
||||||
@@ -172,6 +181,8 @@ export interface RightSidebarProps {
|
|||||||
layers: CADLayer[];
|
layers: CADLayer[];
|
||||||
elements?: CADElement[];
|
elements?: CADElement[];
|
||||||
blocks: BlockDefinition[];
|
blocks: BlockDefinition[];
|
||||||
|
/** CAD-14 Phase 4a: Aktives V2-Werkzeug (fuer Werkzeug-Optionen-Panel). */
|
||||||
|
activeToolId?: string | null;
|
||||||
activeLayerId?: string;
|
activeLayerId?: string;
|
||||||
onSelectLayer?: (id: string) => void;
|
onSelectLayer?: (id: string) => void;
|
||||||
onAddLayer?: () => void;
|
onAddLayer?: () => void;
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ describe('RibbonBar', () => {
|
|||||||
it('should call onTabChange when clicking a tab', () => {
|
it('should call onTabChange when clicking a tab', () => {
|
||||||
const onTabChange = vi.fn();
|
const onTabChange = vi.fn();
|
||||||
render(<RibbonBar activeTab="start" onTabChange={onTabChange} onAction={() => {}} />);
|
render(<RibbonBar activeTab="start" onTabChange={onTabChange} onAction={() => {}} />);
|
||||||
// CAD-10: Klick auf 'Zeichnen'
|
// CAD-13-fix: Werkzeuge nur noch in Sidebar — Ribbon hat Start/Ansicht/KI
|
||||||
fireEvent.click(screen.getByText('Zeichnen'));
|
fireEvent.click(screen.getByText('Ansicht'));
|
||||||
expect(onTabChange).toHaveBeenCalledWith('draw');
|
expect(onTabChange).toHaveBeenCalledWith('view');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onAction when clicking an action button', () => {
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,9 @@ import type { ZoomPanController } from '../src/canvas/ZoomPanController';
|
|||||||
function makeFakeZoomPan(): ZoomPanController {
|
function makeFakeZoomPan(): ZoomPanController {
|
||||||
return {
|
return {
|
||||||
screenToWorld: (sx: number, sy: number) => ({ x: sx * 2, y: sy * 3 }),
|
screenToWorld: (sx: number, sy: number) => ({ x: sx * 2, y: sy * 3 }),
|
||||||
|
// CAD-14 Phase 4c: natives Pan/Wheel im Dispatcher (spies für Assertions)
|
||||||
|
pan: vi.fn(),
|
||||||
|
zoomAt: vi.fn(),
|
||||||
} as unknown as ZoomPanController;
|
} as unknown as ZoomPanController;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,11 +63,12 @@ describe('InteractionDispatcher', () => {
|
|||||||
function setup(tool?: ToolExtensionV2) {
|
function setup(tool?: ToolExtensionV2) {
|
||||||
canvas = document.createElement('canvas');
|
canvas = document.createElement('canvas');
|
||||||
document.body.appendChild(canvas);
|
document.body.appendChild(canvas);
|
||||||
|
const zoomPan = makeFakeZoomPan();
|
||||||
const dispatcher = new InteractionDispatcher(canvas, {
|
const dispatcher = new InteractionDispatcher(canvas, {
|
||||||
zoomPan: makeFakeZoomPan(),
|
zoomPan,
|
||||||
toolLookup: tool ? () => tool : undefined,
|
toolLookup: tool ? () => tool : undefined,
|
||||||
});
|
});
|
||||||
return { dispatcher };
|
return { dispatcher, zoomPan };
|
||||||
}
|
}
|
||||||
|
|
||||||
it('routet down→move→up in Reihenfolge an das aktive Tool', () => {
|
it('routet down→move→up in Reihenfolge an das aktive Tool', () => {
|
||||||
@@ -103,13 +107,17 @@ describe('InteractionDispatcher', () => {
|
|||||||
expect(log).toEqual(['down', 'cancel']);
|
expect(log).toEqual(['down', 'cancel']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Rechtsklick bricht ab, ohne down durchzureichen', () => {
|
it('Rechtsklick startet natives Pan (kein Tool-Event)', () => {
|
||||||
const { tool, log } = makeRecordingTool();
|
const { tool, log } = makeRecordingTool();
|
||||||
const { dispatcher } = setup(tool);
|
const { dispatcher, zoomPan } = setup(tool);
|
||||||
dispatcher.setActive('fake');
|
dispatcher.setActive('fake');
|
||||||
|
|
||||||
fire(canvas, 'pointerdown', { button: 2 });
|
fire(canvas, 'pointerdown', { button: 2 });
|
||||||
expect(log).toEqual(['cancel']);
|
fire(window, 'pointermove', { clientX: 120, clientY: 60 });
|
||||||
|
fire(window, 'pointerup', { button: 2, clientX: 120, clientY: 60 });
|
||||||
|
// CAD-14 Phase 4c: Rechtsklick = natives Pan, Tool bleibt unberührt
|
||||||
|
expect(log).toEqual([]);
|
||||||
|
expect(zoomPan.pan).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('setActive(null) deaktiviert Routing komplett', () => {
|
it('setActive(null) deaktiviert Routing komplett', () => {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* CAD-14 Phase 4c: Dispatcher-native Pan/Wheel/Cursor/Snap.
|
||||||
|
* Ersetzt die Legacy-InteractionEngine vollstaendig:
|
||||||
|
* - Snap/Ortho werden auf Tool-Pointer-Events angewandt (falls Deps gesetzt)
|
||||||
|
* - Pan bei Mittel-/Rechtsklick-Drag (auch ohne aktives Tool)
|
||||||
|
* - Wheel-Zoom am Cursor
|
||||||
|
* - Cursor-Meldung bei jedem Move (auch ohne Tool)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { InteractionDispatcher } from '../src/interaction/dispatcher';
|
||||||
|
import type { ToolExtensionV2 } from '../src/plugins/types';
|
||||||
|
|
||||||
|
function makeCanvas(): HTMLCanvasElement {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.getBoundingClientRect = () => ({ left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600, x: 0, y: 0, toJSON: () => ({}) } as DOMRect);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomPan = {
|
||||||
|
screenToWorld: (sx: number, sy: number) => ({ x: sx, y: sy }),
|
||||||
|
pan: vi.fn(),
|
||||||
|
zoomAt: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function lineTool(): ToolExtensionV2 {
|
||||||
|
return {
|
||||||
|
manifest: { id: 'line', label: 'Linie', icon: 'L', ribbonTab: 'canvas', tags: ['basic'] },
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down: vi.fn(),
|
||||||
|
move: vi.fn(),
|
||||||
|
up: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireMouse(canvas: HTMLCanvasElement, type: string, opts: { button?: number; x?: number; y?: number } = {}) {
|
||||||
|
const e = new MouseEvent(type, { button: opts.button ?? 0, clientX: opts.x ?? 0, clientY: opts.y ?? 0, bubbles: true });
|
||||||
|
canvas.dispatchEvent(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CAD-14 Phase 4c: dispatcher native pan/wheel/cursor', () => {
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
let onCursor: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
canvas = makeCanvas();
|
||||||
|
onCursor = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Pan: Mittelklick-Drag verschiebt via zoomPan.pan (auch ohne Tool)', () => {
|
||||||
|
const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never, onCursorMoved: onCursor });
|
||||||
|
canvas.dispatchEvent(new MouseEvent('pointerdown', { button: 1, clientX: 100, clientY: 100 }));
|
||||||
|
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 140, clientY: 120 }));
|
||||||
|
window.dispatchEvent(new MouseEvent('pointerup', { button: 1, clientX: 140, clientY: 120 }));
|
||||||
|
expect(zoomPan.pan).toHaveBeenCalled();
|
||||||
|
d.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Wheel: zoomAt am Cursor mit Faktor', () => {
|
||||||
|
const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never });
|
||||||
|
canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, clientX: 300, clientY: 200, cancelable: true }));
|
||||||
|
expect(zoomPan.zoomAt).toHaveBeenCalled();
|
||||||
|
d.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cursor-Meldung: onCursorMoved feuert bei pointermove ohne aktives Tool', () => {
|
||||||
|
const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never, onCursorMoved: onCursor });
|
||||||
|
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 50, clientY: 60 }));
|
||||||
|
expect(onCursor).toHaveBeenCalledWith(50, 60);
|
||||||
|
d.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Snap: down/up Positionen werden gesnappt (snap-Deps)', () => {
|
||||||
|
const snap = vi.fn((x: number, y: number) => ({ x: Math.round(x / 10) * 10, y: Math.round(y / 10) * 10 }));
|
||||||
|
const tool = lineTool();
|
||||||
|
const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never, snap, toolLookup: () => tool } as never);
|
||||||
|
d.setActive('line');
|
||||||
|
expect(d.getActive()).toBe(tool);
|
||||||
|
canvas.dispatchEvent(new MouseEvent('pointerdown', { button: 0, clientX: 12, clientY: 12 }));
|
||||||
|
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 35, clientY: 35 }));
|
||||||
|
window.dispatchEvent(new MouseEvent('pointerup', { button: 0, clientX: 35, clientY: 35 }));
|
||||||
|
expect(tool.handlers.down).toHaveBeenCalled();
|
||||||
|
expect((tool.handlers.down as any).mock.calls[0][0].world).toEqual({ x: 10, y: 10 });
|
||||||
|
expect((tool.handlers.up as any).mock.calls[0][0].world).toEqual({ x: 40, y: 40 });
|
||||||
|
d.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
setUIMode('pro');
|
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');
|
const { registerBuiltinPlugins, pluginRegistry } = await import('../src/plugins');
|
||||||
registerBuiltinPlugins();
|
registerBuiltinPlugins();
|
||||||
pluginRegistry.initDefaults();
|
pluginRegistry.initDefaults();
|
||||||
const RibbonBar = (await import('../src/components/RibbonBar')).default;
|
const all = pluginRegistry.getToolsV2();
|
||||||
render(<RibbonBar activeTab="draw" onTabChange={() => {}} onAction={() => {}} />);
|
expect(all.length).toBeGreaterThan(10);
|
||||||
const all = screen.getAllByRole('button').length;
|
const basics = all.filter((t) => (t.manifest.tags ?? []).includes('basic'));
|
||||||
expect(all).toBeGreaterThan(10);
|
expect(basics.length).toBeGreaterThan(0);
|
||||||
cleanup();
|
expect(basics.length).toBeLessThan(all.length);
|
||||||
setUIMode('simple');
|
|
||||||
render(<RibbonBar activeTab="draw" onTabChange={() => {}} onAction={() => {}} />);
|
|
||||||
const simpleCount = screen.getAllByRole('button').length;
|
|
||||||
expect(simpleCount).toBeLessThan(all);
|
|
||||||
expect(simpleCount).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
localStorage.clear();
|
||||||
setUIMode('simple');
|
setUIMode('simple');
|
||||||
const { registerBuiltinPlugins, pluginRegistry } = await import('../src/plugins');
|
const { registerBuiltinPlugins, pluginRegistry } = await import('../src/plugins');
|
||||||
registerBuiltinPlugins();
|
registerBuiltinPlugins();
|
||||||
pluginRegistry.initDefaults();
|
pluginRegistry.initDefaults();
|
||||||
const RibbonBar = (await import('../src/components/RibbonBar')).default;
|
const visible = pluginRegistry.getToolsV2()
|
||||||
render(<RibbonBar activeTab="draw" onTabChange={() => {}} onAction={() => {}} />);
|
.filter((t) => (t.manifest.tags ?? []).includes('basic'))
|
||||||
const html = document.body.innerHTML;
|
.map((t) => t.manifest.id);
|
||||||
expect(html).toContain('data-v2tool="line"');
|
expect(visible).toContain('line');
|
||||||
expect(html).not.toContain('data-v2tool="rotate"');
|
expect(visible).not.toContain('rotate');
|
||||||
expect(html).not.toContain('data-v2tool="mirror"');
|
expect(visible).not.toContain('mirror');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('CommandLine-Komponente: pro-only rendering via useUIMode', async () => {
|
it('CommandLine-Komponente: pro-only rendering via useUIMode', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user