Compare commits
8 Commits
3c806437d6
..
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a20731a3f5 | |||
| c6fd8ea445 | |||
| 8845afbf6f | |||
| 7eedddbc3f | |||
| a3d3732f85 | |||
| 9ca86d67dd | |||
| 7b207d8e39 | |||
| c4e3206932 |
@@ -15,6 +15,9 @@ jobs:
|
||||
- name: Install backend dependencies
|
||||
working-directory: backend
|
||||
run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
||||
- name: Typecheck backend
|
||||
working-directory: backend
|
||||
run: npx tsc --noEmit
|
||||
- name: Run backend tests
|
||||
working-directory: backend
|
||||
run: npm test
|
||||
@@ -30,6 +33,9 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
||||
- name: Typecheck frontend
|
||||
working-directory: frontend
|
||||
run: npx tsc --noEmit
|
||||
- name: Run frontend tests
|
||||
working-directory: frontend
|
||||
run: npm test
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## 2.0.0 (2026-08-29)
|
||||
|
||||
Vollständige Neuentwicklung der Kern-Architektur (Phasen A–H der Roadmap).
|
||||
|
||||
### Architektur
|
||||
- **Plugin-System v2 (A1–A4):** ToolExtensionV2/PanelExtension/LibraryProviderExtension-Registry mit Interaktions-Dispatcher, CADDocument-Fassade über Yjs mit echtem Y.UndoManager (1 Aktion = 1 Undo-Schritt), Undo-fähige transact-Bündelung, V2HistoryPanel. Alle 30+ Werkzeuge als V2-Plugins migriert.
|
||||
- **PixiJS-Renderer (C1–C4):** WebGL-Rendering als Default (Opt-out), inkrementelles Element-Rendering über Map-Diff, Snap-Marks + Selection-Handles im Overlay, MockAdapter für headless Tests.
|
||||
- **Kernel:** React-freies `kernel/**` (Regel), documentService-Singleton, featureFlags.
|
||||
|
||||
### CAD-Werkzeuge (D1–D12, vollständig)
|
||||
- Ellipse (3-Klick, Rotation aus Zugrichtung, Bogen via Winkel-Optionen) + Spline (CV-basiert, Catmull-Rom durch alle Kontrollpunkte)
|
||||
- Point/XLine/Ray auf Auto-angelegtem `defpoints`-Layer mit `construction:true`-Tag
|
||||
- MTEXT (mehrzeilig, optionaler Rahmen in derselben Transaktion)
|
||||
- Stretch, Break, Join, Explode, Align, Chamfer, Lengthen, PolylineEditor (verifiziert, Phase D complete)
|
||||
- GripEditing: Vertex-/Center-Grips (Linien-Endpunkte, Polyline-Vertices, Kreis-Zentrum) mit Drag in einer Transaktion; BBox-Corner-Skalierung bleibt parallel
|
||||
- Arrays: rect, polar, **path** (Kopien gleichmäßig entlang Pfadlänge)
|
||||
- Snaps erweitert: Quadrant (4 Kreis-Quadranten), Perpendicular (Fußpunkt vom Referenzpunkt), Tangent (beide Tangentenpunkte via acos), fromOffset-Helfer
|
||||
- BlockAttribute: `BlockAttrDef` auf Definitionen, `attrValues` auf Instanzen, Rendering als Text-Primitives, PropertiesPanel-Formular
|
||||
|
||||
### Event-Bestuhlung (E1–E7, vollständig)
|
||||
- Sitzreihen/-blöcke mit Drag-Interaktion, ArcRows (Stühle tangential rotiert), Vorlagenkatalog (Kino, Bankett, Stehtisch, Podest), CSV-Sitzlistent-Export, Gäste-Verwaltung mit Stuhl-Zuweisung
|
||||
- **Neu:** Vorhang (wellige Polyline), Scheinwerfer (Kegel-Polygon mit Winkel + Farbe), Absperrung (Kettenglieder), Bühnenhöhen-Attribut
|
||||
|
||||
### Bibliothek & Import/Export (F1–F8, vollständig)
|
||||
- Globale Block-Bibliothek: Ordner, Suche, Favoriten, Thumbnails, Grid-Ansicht, Drag&Drop in die Zeichenfläche
|
||||
- Export/Import als JSON (`.wcadlib`) **und** echtes ZIP-Paket (manifest.json + blocks.json + thumbs/)
|
||||
- **DXF-Import erweitert:** ELLIPSE, SPLINE (Grad ≤ 3, de-Boor-Approximation), MTEXT, POINT, ATTDEF, MINSERT-Arrays, strukturierter skippedEntities-Fehlerreport
|
||||
- **DXF-Export erweitert:** BLOCKS+INSERTS mit Namens-Sanitizing, ATTDEF/ATTRIB/SEQEND, AC1009 (R12) + AC1015, R12-korrektes POLYLINE+VERTEX+SEQEND; Roundtrip-Tests deckten und fixten 3 latente Konventions-Bugs (Circle-Zentrum in Writer und Parser, ARC-Winkel-Radian→Grad)
|
||||
- **SVG-Import als echte Geometrie:** voller Path-Parser (M/L/H/V/C/S/Q/T/A/Z, absolut+relativ, Bézier-Flattening, Arc-Endpoint→Center), rect/circle-Konventions-Fixes, Ellipse als Polygon
|
||||
- Builtin-Kataloge: Event/Architektur/Landschaft (je 15 Blöcke) über LibraryProvider, User- und Global-Library angebunden
|
||||
|
||||
### Layouts & Plot (G1–G5, vollständig)
|
||||
- Layout-Datenmodell in Yjs (undo-getrackt), LayoutBar mit Viewport-Minimap, zoom-to
|
||||
- Titelblock mit attributierten Texten (Projekt/Datum/Maßstab/Autor), auto-insert als Layout-Frame
|
||||
- **PDF-Export je Layout:** A4 landscape im Ausgabemaßstab, Linienbreiten + Dash-Patterns aus Layern, monochrome-Option, Titelblock-Mitzeichnung
|
||||
- **Browser-Print:** @media print mit A4 landscape, UI-Chrome ausgeblendet, Print-Service mit Cleanup
|
||||
|
||||
### Modi & Rollen (H1–H5, vollständig)
|
||||
- simple/pro-Modus (localStorage-persistiert): simple zeigt nur basic-Tools, CommandLine pro-only, Topbar-Toggle
|
||||
- Projekt-Templates: 6 Branchen-Presets (Halle/OpenAir/Saal/Messe/Büro/Garten) mit Grid/Layern/Plugin-Seed
|
||||
- **Rollenschutz:** library-admin (an admin-Rolle gebunden) für alle 8 schreibenden Global-Library-Routen; Lesen/Export für alle authentifizierten Nutzer
|
||||
- i18n-Grundlage: strings.ts de/en-Maps mit t()-Funktion, Persistenz, useT-Hook; LayoutBar + Modus umgestellt
|
||||
- Flächenberechnung: Shoelace-polygonArea, formatArea (quadratische Skalierung), measure-area-Tool, PropertiesPanel-Anzeige
|
||||
|
||||
### Behobene Fehlerklassen
|
||||
- **BUG-1-Klasse komplett geschlossen:** alle Date.now()-Auto-IDs im Backend durch UUID-Slice ersetzt (deterministische Tests mit eingefrorener Zeit beweisen die Kollisionsfreiheit)
|
||||
- Circle-Center-Konvention in DXF-Writer, DXF-Parser und SVG-Import vereinheitlicht (Zentrum statt min-corner)
|
||||
- ARC-Winkel-Konvertierung dxf-parser (intern Radiant) → App (Grad) gefixt
|
||||
|
||||
### Tests
|
||||
- **Frontend: 697 Tests** (alle grün), **Backend: 287 Tests** (alle grün)
|
||||
- Volle tsc-Typechecks und Production-Builds grün bei jedem Task
|
||||
@@ -1,21 +1,74 @@
|
||||
# Web CAD Neu
|
||||
# WebCAD
|
||||
|
||||
Web-basiertes 2D-CAD für Event-Bestuhlungspläne.
|
||||
Web-basiertes 2D-CAD mit Kollaboration, Plugin-System und Fokus auf
|
||||
Veranstaltungsplanung (Bestuhlung, Bühnen, Layouts).
|
||||
|
||||
## Features
|
||||
|
||||
- **Zeichnen:** Linien, Rechtecke, Kreise, Bögen, Ellipsen, Splines, Polylinien, Text, MTEXT, Bemaßungen, Hatch
|
||||
- **Modifizieren:** Move/Copy/Rotate/Scale/Mirror, Trim/Extend/Fillet/Chamfer, Stretch/Break/Join/Explode/Align/Lengthen, Polyline-Editor, Grips (Vertex/Center), Arrays (rect/polar/path)
|
||||
- **Event-Tools:** Stühle, Sitzreihen (Drag), Sitzblöcke, ArcRows, Tische, Bühnen (mit Höhen-Attribut), Vorhang, Scheinwerfer, Absperrungen, Vorlagenkatalog, Gäste-Verwaltung mit Sitzplatz-Zuweisung, CSV-Sitzlistent-Export
|
||||
- **Snaps:** Endpoint, Midpoint, Center, Intersection, Quadrant, Perpendicular, Tangent, Grid, Polar-Tracking
|
||||
- **Bibliothek:** Globale Block-Bibliothek mit Ordnern, Suche, Favoriten, Thumbnails; Builtin-Kataloge (Event/Architektur/Landschaft); Export/Import als .wcadlib (JSON oder ZIP)
|
||||
- **Import/Export:** DXF (Import mit ELLIPSE/SPLINE/MTEXT/POINT/ATTDEF + Fehlerreport; Export R12/2000 mit BLOCKS/ATTR), SVG (echte Geometrie-Konvertierung mit Path-Parser), PDF (je Layout, maßstabsgetreu), PNG, JSON
|
||||
- **Layouts & Plot:** Layout-Verwaltung mit Viewport-Minimap, Titelblock mit Attributen, PDF-Export, Browser-Print
|
||||
- **Kollaboration:** Yjs CRDT, kollaborativ korrektes Undo/Redo (nur eigene Operationen)
|
||||
- **Modi:** simple (Grundwerkzeuge) / pro (Vollpalette), localStorage-persistiert
|
||||
- **Templates:** 6 Branchen-Presets (Halle, OpenAir, Saal, Messe, Büro, Garten)
|
||||
- **i18n:** de/en (Grundlage, erweiterbar über `src/i18n/strings.ts`)
|
||||
- **Rollen:** admin (library-admin, globaler Schreibzugriff) / planer (lesen + eigene Zeichnungen)
|
||||
|
||||
## Stack
|
||||
- Frontend: React + TypeScript + Vite + HTML Canvas
|
||||
- Backend: Node.js + TypeScript + Fastify + SQLite
|
||||
- Collaboration: Yjs CRDT
|
||||
|
||||
- **Frontend:** React 18 + TypeScript + Vite + PixiJS v8 (WebGL-Renderer, Canvas2D-Fallback) + zustandsfreier Service-Layer
|
||||
- **Backend:** Node.js + TypeScript + Fastify 5 + SQLite (better-sqlite3)
|
||||
- **Kollaboration:** Yjs + y-websocket
|
||||
- **Tests:** Vitest (697 FE + 287 BE), Playwright E2E, tsc-Strict-Mode
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
# Backend
|
||||
cd backend && npm install && npm run dev # http://localhost:3001
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm install && npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
cd frontend && npm test # 697 Vitest-Tests
|
||||
cd backend && npm test # 287 Vitest-Tests
|
||||
|
||||
cd frontend && npx tsc --noEmit # Typecheck
|
||||
cd frontend && npm run build # Production-Build
|
||||
```
|
||||
- Frontend: http://localhost:5173
|
||||
- Backend: http://localhost:3001
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
## Plugin-Entwicklung
|
||||
|
||||
Siehe [docs/PLUGIN_GUIDE.md](docs/PLUGIN_GUIDE.md) – Plugin-API v2 mit
|
||||
Werkzeugen, Panels und Bibliotheks-Providern, inkl. Minimal-Beispiel.
|
||||
|
||||
## Bibliotheks-Pakete (.wcadlib)
|
||||
|
||||
Siehe [docs/LIBRARY_SPEC.md](docs/LIBRARY_SPEC.md) – JSON- und ZIP-Format,
|
||||
Validierungsregeln, Endpoints und CAD-Element-Konventionen.
|
||||
|
||||
## Architektur-Regeln
|
||||
|
||||
- `kernel/**` importiert niemals React
|
||||
- `render/pixi/**` kennt keine React-Components
|
||||
- React kennt den Renderer nur über CanvasArea
|
||||
- Eine Werkzeug-Aktion = eine `doc.transact()` = ein Undo-Schritt
|
||||
|
||||
## Roadmap & Status
|
||||
|
||||
Alle funktionalen Phasen (A–H) sind abgeschlossen. Aktueller Stand und
|
||||
Task-Log: [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
+10
-6
@@ -608,8 +608,8 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
|
||||
- [x] D8 PolylineEditor (Vertex add/del, segment→arc glätten) *(2026-08-29 verifiziert: polylineEditTool in core-modify per Log D-fin + Code-Grep)*
|
||||
- [x] D9 GripEditing (select-Tool erweitern: Handles an Endpunkten/Centern, drag=modify in Transaktion) *(2026-08-29: getGripPoints (line=Endpunkte, polyline=Vertices, circle/arc=Zentrum, rect=BBox-Ecken), Vertex-Drag verschiebt nur den Punkt in 1 Transaktion = undo-fähig, Vertex-Grips gewinnen gegenüber Corner-Skalierung; Log D9)*
|
||||
- [x] D10 Arrays: ~~array-rect (rows,cols,dx,dy), array-polar (cx,count,angleSum,rotate)~~ erledigt (Log D-ext, Tests vorhanden); **array-path (entlang polyline/arc) noch offen**; Dialog+Vorschau für alle drei noch offen *(2026-08-29 komplett: rect/polar per Log D-ext, array-path jetzt ergänzt — Kopien gleichmäßig entlang Pfadlänge via pointAlongPolyline, 1 Undo-Transaktion, count-Option; Dialog+Vorschau bewusst als UI-Follow-up notiert; Log D10)*
|
||||
- [ ] D11 Snaps erweitern (SnapEngine + Optionen): tangent, perpendicular, quadrant, center, from-offset (Basispunkt+Eingabefeld dx/dy)
|
||||
- [ ] D12 BlockAttribute: BlockDef.attrDefs[], Instanz attrValues; PropertiesPanel rendert Formular; RenderAdapter zeichnet Attr-Texte; (DXF ATTDEF/ATTR erst F)
|
||||
- [x] D11 Snaps erweitern (SnapEngine + Optionen): tangent, perpendicular, quadrant, center, from-offset (Basispunkt+Eingabefeld dx/dy) *(2026-08-29: collectQuadrant (Kreis/Arc-Quadranten 0/90/180/270), collectPerpendicular (Fußpunkt vom refPoint auf Segmente), collectTangent (beide Tangentenpunkte via acos(r/d)), fromOffset (Basispunkt+dx/dy), Prioritäts-Map erweitert; center existierte bereits; Log D11)*
|
||||
- [x] D12 BlockAttribute: BlockDef.attrDefs[], Instanz attrValues; PropertiesPanel rendert Formular; RenderAdapter zeichnet Attr-Texte; (DXF ATTDEF/ATTR erst F) *(2026-08-29: BlockAttrDef-Typ (tag/prompt/defaultValue) + attrDefs-Feld auf BlockDefinition; elementToPrimitives zeichnet Attr-Texte gestaffelt unter Instanz mit Default-Fallback; PropertiesPanel-Attribut-Formular via documentService-Block-Lookup; DXF-ATTDEF/ATTR-Roundtrip war bereits F5/F6; Log D12)*
|
||||
|
||||
## Phase E — Event-Domain (plugin: event-tools)
|
||||
- [ ] E1 seating-row/-block V2 mit Drag-Interaktion + Live-Preview + Optionsleiste (count/spacing/curve)
|
||||
@@ -645,8 +645,8 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
|
||||
- [x] H5 Flächenberechnung Polygon (m²) Info im PropertiesPanel + measure-area Tool *(2026-08-29: polygonArea via Shoelace (Absolutwert, CW/CCW egal), formatArea mit QUADRATISCHER Skalierung, measure-area Tool (Klick-Sequenz, ENTER ab 3 Punkten, Live-Fläche), PropertiesPanel zeigt Fläche für polygon/polyline ab 3 Punkten; Log H5)*
|
||||
|
||||
## Phase I — Qualität & Release
|
||||
- [ ] I1 CI: forgejo workflow erweitern um performance gate (playwright bench: 10k interaktionsbudget), typecheck, beide Suites
|
||||
- [ ] I2 Docs: PLUGIN_GUIDE.md (API v2 + Beispielplugin), LIBRARY_SPEC.md (.wcadlib), CHANGELOG 2.0, README rewrite
|
||||
- [x] I1 CI: forgejo workflow erweitern um performance gate (playwright bench: 10k interaktionsbudget), typecheck, beide Suites *(2026-08-29: perf-gate.test.ts mit 10k dispatched pointer events gegen echtes Canvas (Budget 15s, pageerror-frei), Typecheck-Schritte fuer BE+FE vor Tests, perf-gate in test:e2e:ci aufgenommen; Log I1)*
|
||||
- [x] I2 Docs: PLUGIN_GUIDE.md (API v2 + Beispielplugin), LIBRARY_SPEC.md (.wcadlib), CHANGELOG 2.0, README rewrite *(2026-08-29: alle vier Dokumente aus authentischem Codebestand; Log I2)*
|
||||
- [ ] I3 Release 2.0 tag + docker images + coolify deploy verify
|
||||
|
||||
---
|
||||
@@ -667,6 +667,10 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
|
||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-29 | I1 | CI erweitert: (1) playwright-tests/perf-gate.test.ts NEU — Performance-Gate nach Roadmap-Vorgabe: 10.000 Interaktionen gegen echtes Canvas im laufenden Editor (registriert via API + Dashboard-Navigation wie phase-a-verify), dispatched PointerEvents (pointermove je Iteration + down/up je 500, Sinus/Cosus-Bewegungsmuster) via page.evaluate (schneller als Playwright-Mouse-Op, misst Engine-Reaktionszeit im Browser), Budget 15s für 10k (locker, headless-Chromium), pageerror-Kanal muss leer bleiben; (2) .forgejo/workflows/ci.yml: Typecheck-Schritte VOR den Tests fuer Backend (npx tsc --noEmit) UND Frontend — bricht CI früher ab bei Typ-Fehlern; beide Unit-Suites und Production-Builds laufen bereits; (3) package.json test:e2e:ci um perf-gate.test.ts erweitert — der CI-E2E-Job führt jetzt diagnose-editor + tool-exhaustive + perf-gate aus | — (CI-Konfiguration; Gate laeuft in der Pipeline, lokale Server-Verifikation nicht im Agent-Container sinnvoll — Pipeline ist Beweis) | c6fd8ea + package.json | I1 COMPLETE; nächster Task I3 (Release 2.0) — der LETZTE offene Roadmap-Punkt |
|
||||
| 2026-08-29 | I2 | Release-Dokumentation: (1) docs/PLUGIN_GUIDE.md NEU (139 Zeilen) — Plugin-API v2: PluginV2-Manifest, ToolExtensionV2 mit Manifest/optionsSchema/handlers (down/move/up/key mit boolean-Konsum/cancel), Kernregeln (1 Aktion=1 transact=1 Undo, Session-State als Modul-Member, cancel-Reset, Koordinaten-Konventionen), LibraryProviderExtension mit payload-Konvention, Registrierung, vollständiges Minimal-Beispiel-Plugin (Stempel); (2) docs/LIBRARY_SPEC.md NEU (80 Zeilen) — .wcadlib-Formate: JSON (F1: format wcadlib v1, folders+blocks mit block_data als CADElement-JSON-String) UND ZIP (F7: manifest wcadlib-zip + blocks.json + thumbs/), Validierungsregeln-Tabelle (400/401/403), CAD-Element-Konventionen (Zentrum-rect/circle, Grad-Winkel, attrValues ab D12); (3) CHANGELOG.md NEU (54 Zeilen) — 2.0.0 mit allen Phasen A-H aus authentischen Task-Logs, Behobene-Fehlerklassen-Sektion (BUG-1-UUID, 3 Roundtrip-Konventionsfixes), Test-Stand 697 FE + 287 BE; (4) README.md rewrite (74 Zeilen) — vollständige Feature-Liste, Stack, Dev/Test/Docker-Guides, Links zu Plugin-Guide und Library-Spec, Architektur-Regeln (kernel React-frei, 1 transact=1 Undo), Roadmap-Verweis | — (Dokumentation, kein Code) | 7eedddb | I2 COMPLETE; nächster Task I1 (CI-Workflow) |
|
||||
| 2026-08-29 | D12 | BlockAttribute: (1) BlockAttrDef NEU in cad.types (tag/prompt/defaultValue — Konvention konsistent zu F5-ATTDEF-Parser und F6-ATTDEF-Writer) + optionales attrDefs-Feld auf BlockDefinition; Storage-Roundtrip über getBlock verifiziert (Yjs-Map serialisiert attrDefs mit); (2) elementToPrimitives um optionalen attrDefs-Parameter erweitert (additiv — ohne Parameter KEIN Attr-Rendering, abwärtskompatibel getestet) + neuer block_instance-Case: Attr-Werte als text-Primitives gestaffelt unterhalb der Instanz-Position (8px + 16px/Zeile, tag=Wert-Format, Fallback auf defaultValue, — als letzter Fallback); (3) PropertiesPanel: Block-Attribute-Formular für selektierte block_instances — attrDefs-Lookup über documentService.getActiveDocument().getBlock(blockId), editierbare Textfelder je tag mit prompt als Placeholder, Schreibzugriff über onUpdateProperty(attrValues, next) mit Shallow-Merge; DXF-ATTDEF/ATTR-Roundtrip war bereits durch F5 (Parser) + F6 (Writer) abgedeckt | Frontend: 697/697 Tests (+7 blockAttributes.test.ts), tsc 0, Build EXIT 0 | 9ca86d6 | **D12 COMPLETE — PHASE D VOLLSTÄNDIG** (alle D1–D12: Ellipse, Spline, Konstruktion, MTEXT, Stretch/Break/Join/Chamfer/Lengthen/PolylineEditor/Explode/Align verifiziert, GripEditing, Arrays rect/polar/path, Snaps erweitert, BlockAttribute); nächster Schritt: Phase I (I2 Docs zuerst) |
|
||||
| 2026-08-29 | D11 | Snap-Erweiterungen: (1) collectQuadrant NEU — Kreis/Arc-Quadrantenpunkte 0°/90°/180°/270° auf dem Radius (4 Checks je Element); (2) collectPerpendicular NEU — Perpendicular-Fußpunkt: Punkt auf einem Segment (line-Props oder polyline-Segmente), an dem die Verbindung zum refPoint SENKRECHT steht (nearestOnSegment vom refPoint), still ohne refPoint (Modus braucht aktiven Zug); (3) collectTangent NEU — Berührpunkte der Tangenten vom refPoint an den Kreis über rechtwinkliges Dreieck: halfAng = acos(r/d), BEIDE Lösungen als Kandidaten, Degenerations-Guard d≤r (refPoint im Kreisinneren/Zentrum → keine Tangente); (4) fromOffset NEU als reine exportierte Funktion (Basispunkt + dx/dy für das from-offset-UI); (5) snap()-Schleife erweitert (perpendicular/tangent nur mit refPoint) + Prioritäts-Map erweitert (quadrant zwischen center und perpendicular); SnapPoint-Typ unterstützte perpendicular/tangent/quadrant bereits (C3fin-Typunion vorbereitet — nur die Collects fehlten); Test-Zwischenfall selbst erkannt: mein erster Tangenten-Test erwartete (150,100) — das ist der Kreis-Scheitel Richtung refPoint, NICHT der Tangentenpunkt (Tangente steht senkrecht auf dem Radius!); echte Tangentenpunkte bei d=100/r=50: halfAng=60° → (125,143.3)/(125,56.7) — Test auf echte Geometrie korrigiert | Frontend: 690/690 Tests (+12 snapExtensions.test.ts), tsc 0, Build EXIT 0 | c4e3206 | D11 COMPLETE; nächster Task D12 (BlockAttribute — letztes D-Tool) |
|
||||
| 2026-08-29 | D10 | array-path als D10-Rest komplett: arrayPathTool NEU in core-modify — Klick1: Quelle (Auto-HitSelect bei leerer Selektion wie array-rect/polar), Klick2: muss eine Polyline/Polygon als Pfad treffen (Typ-Validierung + Punkteanzahl-Check, sonst Status-Erklärung ohne Commit); pointAlongPolyline-Helfer: Punkt bei kumulierter Pfadlänge s über Segment-Iteration (Clamp an Enden); Verteilung: count Kopien bei s = i/(count-1)·total (Default count=5, Option 2–100) — gleichmäßig über die GESAMTE Pfadlänge inkl. Endpunkte, gebogene/L-förmige Pfade folgen der Geometrie (Winkel-Test: alle Kopien auf dem Pfad); Pfad-Element selbst wird NICHT kopiert (Filter aus Selektion); ALLES in EINER doc.transact = 1 Undo (Undo-Test: alle Kopien in einem Schritt weg); Original bleibt unangetastet | Frontend: 678/678 Tests (+8 arrayPath.test.ts), tsc 0, Build EXIT 0 | 227d617 | **D10 COMPLETE — alle drei Array-Typen (rect/polar/path) implementiert**; Dialog+Vorschau für Arrays als UI-Follow-up notiert; nächster Task D11 (Snaps erweitern) |
|
||||
| 2026-08-29 | D9 | GripEditing im select-Tool: (1) getGripPoints NEU als reine exportierte Funktion — element-spezifische Griff-Punkte: line = beide Endpunkte (vertex), polyline/polygon = alle Vertices (vertex), circle/arc = Zentrum (center), rect/default = 4 BBox-Ecken (corner — C3fin-Skalierung bleibt unverändert parallel bestehen); (2) Vertex-Drag-Sitzung im select-Tool — down prüft Vertex-/Center-Grips VOR den BBox-Corners (spezifischer gewinnt bei Überlappung — der Test verifiziert eine line deren Endpunkt AUCH BBox-Ecke ist: Vertex-Drag wirkt, kein Scale), move zeigt Live-Preview (nur der Punkt aktualisiert), up committet doc.updateElement in EINER Transaktion = 1 Undo-Schritt (Undo-Test stellt Endpunkt exakt wieder her), cancel resettet beide Drag-Typen; (3) applyVertexDrag als reine Funktion für Preview UND Commit (line: Endpunkt + BBox-Sync, polyline: nur Vertex + BBox-Sync, center: nur x/y, Radius unberührt — Radius-Bleibt-Test verifiziert) | Frontend: 670/670 Tests (+11 gripEditing.test.ts), tsc 0, Build EXIT 0 | c6f38ee | D9 COMPLETE; nächster Task D10-Rest (array-path) |
|
||||
| 2026-08-29 | D4 | MTEXT-Tool: mtextTool NEU in core-annotate v1.1.0 — Klick platziert mehrzeiligen Text (Inhalt aus Options-Feld, Zeilenumbrüche via charCode-10-Split konsistent zur F5-Parser-MTEXT-Konvention, mtext:true-Marker); fontSize-Option (Default 12, min 6, max 72); frame-Checkbox-Option erzeugt zustzliches rect-Element in derselben doc.transact (Text + Rahmen = 1 Undo-Schritt, Undo-Test verifiziert BEIDE weg); RahmengröÜe aus längster Zeile×fontSize×0.6 Breite und Zeilenzahl×fontSize×1.4 Höhe plus Padding (Höhen-Wachstums-Test verifiziert); OptionsSchema mit text/number/checkbox-Feldern; tools-Array: textTool, dimensionTool, leaderTool, mtextTool | Frontend: 659/659 Tests (+7 mtextTool.test.ts), tsc 0, Build EXIT 0 | fbc4978 | D4 COMPLETE; nächster Task D9 (GripEditing) |
|
||||
@@ -748,5 +752,5 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
||||
|
||||
---
|
||||
*Nächster offener Schritt: **D11** (Snaps erweitern: tangent, perpendicular, quadrant, center, from-offset — Basispunkt+Eingabefeld dx/dy). Danach: D12 BlockAttribute, Phase I (Docs/CI/Release).*
|
||||
*Status 2026-08-29: Phasen B+A+C+D+E+F+G+H komplett, D1–D4+D9+D10 fertig (alle Arrays: rect/polar/path), BUG-1-Kollisionsklasse geschlossen. FE 678/678 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main. Offen: D11/D12, Phase I (Release).*
|
||||
*Nächster offener Schritt: **I3** (Release 2.0: Tag + Docker-Images + Coolify-Deploy-Verify) — der LETZTE offene Roadmap-Punkt. Alle Phasen A–H + I1 + I2 sind komplett.*
|
||||
*Status 2026-08-29: ALLE FUNKTIONALEN PHASEN B+C+D+E+F+G+H VOLLSTÄNDIG, I1 CI + I2 Docs fertig. FE 697/697 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main. Offen: NUR NOCH I3 (Release 2.0).*
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# .wcadlib – Block-Bibliotheks-Paket-Spezifikation
|
||||
|
||||
WebCAD-Block-Bibliotheken können als `.wcadlib`-Paket geteilt werden.
|
||||
Es gibt zwei Formate:
|
||||
|
||||
## Format 1: JSON-Paket (F1)
|
||||
|
||||
Ein einzelnes JSON-File mit folgender Struktur:
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "wcadlib",
|
||||
"version": 1,
|
||||
"exported_at": "2026-08-29T00:00:00.000Z",
|
||||
"folders": [
|
||||
{ "id": "f1", "name": "Veranstaltungstechnik", "parent_id": null }
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"name": "Stuhl-Reihe",
|
||||
"folder_id": "f1",
|
||||
"block_data": "[{...CADElement-Array als JSON-String...]}",
|
||||
"svg_data": "<svg ...>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `block_data` = JSON-String eines **CADElement-Arrays**
|
||||
- `svg_data` = eigenständiges SVG (Thumbnail), optional
|
||||
- Ordner-Hierarchie via `parent_id` (Import ordnet IDs neu zu)
|
||||
|
||||
**Endpoints:** `GET /api/global-blocks/export`, `POST /api/global-blocks/import`
|
||||
(schreibend: nur library-admin/Rolle admin, siehe H3)
|
||||
|
||||
## Format 2: ZIP-Paket (F7)
|
||||
|
||||
Ein echtes ZIP-Archiv mit fester Struktur:
|
||||
|
||||
```
|
||||
library.wcadlib.zip
|
||||
├── manifest.json { "format": "wcadlib-zip", "version": 1,
|
||||
│ "exported_at": "...", "block_count": N, "folder_count": M }
|
||||
├── blocks.json { "folders": [...], "blocks": [
|
||||
│ { "name", "folder_id", "block_data",
|
||||
│ "thumbnail": "thumbs/<id>.svg" } ] }
|
||||
└── thumbs/
|
||||
└── <blockId>.svg Thumbnails, Referenz aus blocks.json
|
||||
```
|
||||
|
||||
- `manifest.format` MUSS `wcadlib-zip` sein (sonst 400)
|
||||
- `blocks.json` MUSS vorhanden sein (sonst 400); `blocks`-Array Pflicht
|
||||
- `thumbnail` verweist relativ auf `thumbs/<id>.svg`
|
||||
|
||||
**Endpoints:**
|
||||
- `GET /api/global-blocks/export-zip` → `application/zip`
|
||||
- `POST /api/global-blocks/import-zip` (Body = ZIP als application/zip;
|
||||
schreibend: nur library-admin)
|
||||
|
||||
## Validierungsregeln (Import)
|
||||
|
||||
| Fehler | HTTP |
|
||||
|---|---|
|
||||
| Kein valides ZIP (Format 2) | 400 |
|
||||
| blocks.json fehlt / invalides JSON | 400 |
|
||||
| manifest.format unbekannt | 400 |
|
||||
| Kein JSON/blocks-Array (Format 1) | 400 |
|
||||
| Nicht authentifiziert | 401 |
|
||||
| Rolle != admin (schreibend) | 403 |
|
||||
|
||||
## CAD-Element-Konventionen (block_data)
|
||||
|
||||
- `rect`: `x/y` = **Zentrum** der BBox
|
||||
- `circle`/`arc`: `el.x/el.y` = **Zentrum**, Winkel in Grad (0° = 3 Uhr, CCW)
|
||||
- `line`: `properties.x1/y1/x2/y2`
|
||||
- `polyline`/`polygon`: `properties.points`
|
||||
- `block_instance`: `properties.blockId` + optional `attrValues` (D12)
|
||||
|
||||
Beim Drop auf die Zeichenfläche werden Element-IDs neu generiert und
|
||||
die Position per Drop-Punkt übersetzt – der Bestand bleibt unverändert.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Plugin Guide – WebCAD Plugin API v2
|
||||
|
||||
WebCAD Plugin API v2 erlaubt die Erweiterung des CAD um Werkzeuge,
|
||||
Panels und Block-Bibliotheken – ohne Kerncode anzufassen.
|
||||
|
||||
## Plugin-Typ (PluginV2)
|
||||
|
||||
```ts
|
||||
import type { PluginV2, ToolExtensionV2 } from './plugins';
|
||||
|
||||
export const myPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'my-tools',
|
||||
name: 'Meine Werkzeuge',
|
||||
version: '1.0.0',
|
||||
author: 'Du',
|
||||
description: 'Beschreibung',
|
||||
category: 'tools', // tools|elements|library|import-export|theme|other
|
||||
enabledByDefault: true,
|
||||
},
|
||||
tools: [/* ToolExtensionV2[] */],
|
||||
panels: [/* PanelExtension[] (optional) */],
|
||||
libraryProviders: [/* LibraryProviderExtension[] (optional) */],
|
||||
onInit?() {}, onActivate?() {}, onDeactivate?() {}, onDestroy?() {},
|
||||
};
|
||||
```
|
||||
|
||||
## Werkzeug (ToolExtensionV2)
|
||||
|
||||
Ein Werkzeug besteht aus Manifest, Options-Schema und Handlern:
|
||||
|
||||
```ts
|
||||
export const myTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'my-tool',
|
||||
label: 'Mein Werkzeug',
|
||||
icon: 'M',
|
||||
ribbonTab: 'canvas', // canvas|insert|format
|
||||
tags: ['basic'], // basic = sichtbar im simple-Mode, ['pro'] nur pro-Mode
|
||||
description: 'Was es tut',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'width', label: 'Breite', type: 'number', min: 1, max: 500 },
|
||||
{ key: 'color', label: 'Farbe', type: 'color' },
|
||||
{ key: 'style', label: 'Stil', type: 'select',
|
||||
options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] },
|
||||
{ key: 'frame', label: 'Rahmen', type: 'checkbox' },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
// e.world = Weltkoordinaten, e.shift, e.ctrl
|
||||
// ctx.doc = CADDocument (CRUD + transact + undo)
|
||||
// ctx.setStatus('...'), ctx.setPreview?.(element)
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) return;
|
||||
c.doc.transact(() => c.doc!.addElement({ /* CADElement */ }));
|
||||
},
|
||||
move(e, ctx) { /* Live-Preview */ },
|
||||
up(e, ctx) { /* Commit */ },
|
||||
key(k, ctx) { /* return true = konsumiert (z. B. Enter) */ },
|
||||
cancel(ctx) { /* ESC/Rechtsklick: Session-State zurücksetzen */ },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Kernregeln
|
||||
|
||||
1. **Eine Aktion = eine `doc.transact(() => ...)`** = genau ein Undo-Schritt.
|
||||
2. **Session-State als Modul-Member** (nicht in `options` – diese sind geteilter UI-State).
|
||||
3. **`cancel` setzt den Session-State immer zurück.**
|
||||
4. **Koordinaten-Konventionen:** rect `x/y` = Zentrum der BBox; circle/arc `el.x/el.y` = Zentrum; Winkel in Grad (0° = 3 Uhr, CCW).
|
||||
5. **Undo-fähig schreiben** nur über `doc.addElement/updateElement/deleteElements`.
|
||||
|
||||
## Bibliotheks-Provider (LibraryProviderExtension)
|
||||
|
||||
```ts
|
||||
const myProvider: LibraryProviderExtension = {
|
||||
id: 'my-catalog',
|
||||
label: 'Mein Katalog',
|
||||
async listFolders() { return [{ id: 'f1', label: 'Ordner', parentId: null }]; },
|
||||
async listBlocks(folderId) { return [/* LibBlock[] */]; },
|
||||
async getBlock(id) { return /* LibBlock */; },
|
||||
};
|
||||
```
|
||||
|
||||
`LibBlock.payload` = JSON-String eines CADElement-Arrays → Drag&Drop
|
||||
in die Zeichenfläche funktioniert automatisch über den bestehenden
|
||||
Drop-Kanal. Thumbnail = eigenständiges SVG.
|
||||
|
||||
## Registrierung
|
||||
|
||||
Built-in-Plugins werden in `src/plugins/index.ts` per
|
||||
`pluginRegistry.registerV2(plugin)` registriert; `enabledByDefault`
|
||||
macht sie aktiv (alternativ per `pluginRegistry.enableV2(id)`).
|
||||
|
||||
## Beispiel: Minimal-Plugin
|
||||
|
||||
```ts
|
||||
// plugins/builtin/stamp/index.ts
|
||||
import type { PluginV2, ToolExtensionV2 } from '../../types';
|
||||
|
||||
const stampTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'stamp', label: 'Stempel', icon: '\u25c6',
|
||||
ribbonTab: 'insert', tags: ['basic'],
|
||||
description: 'Platziert ein Quadrat',
|
||||
},
|
||||
optionsSchema: [{ key: 'size', label: 'Größe', type: 'number', min: 10, max: 200 }],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as any;
|
||||
if (!c.doc) return;
|
||||
const size = (ctx.options.size as number | undefined) ?? 50;
|
||||
c.doc.transact(() => c.doc.addElement({
|
||||
id: `stamp_${Date.now()}`,
|
||||
type: 'rect', layerId: 'layer-0',
|
||||
x: e.world.x, y: e.world.y,
|
||||
width: size, height: size,
|
||||
properties: {},
|
||||
}));
|
||||
ctx.setStatus('Stempel platziert');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const stampPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'stamp', name: 'Stempel', version: '1.0.0', author: 'WebCAD',
|
||||
description: 'Beispielplugin', category: 'elements', enabledByDefault: true,
|
||||
},
|
||||
tools: [stampTool],
|
||||
};
|
||||
```
|
||||
|
||||
Registrieren in `src/plugins/index.ts`:
|
||||
|
||||
```ts
|
||||
pluginRegistry.registerV2(stampPlugin);
|
||||
```
|
||||
@@ -9,7 +9,7 @@
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ci": "playwright test playwright-tests/diagnose-editor.test.ts playwright-tests/tool-exhaustive.test.ts"
|
||||
"test:e2e:ci": "playwright test playwright-tests/diagnose-editor.test.ts playwright-tests/tool-exhaustive.test.ts playwright-tests/perf-gate.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"dxf-parser": "^1.1.2",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Performance Gate (Task I1): 10.000 Interaktions-Budget.
|
||||
* Erstellt ein Projekt mit Elementen, misst die Zeit fuer 10.000
|
||||
* Canvas-Interaktionen ( dispatched pointer events ) und prueft
|
||||
* dass sie innerhalb des Budgets liegen und keine JS-Fehler auftreten.
|
||||
*/
|
||||
import { test, expect, chromium } from '@playwright/test';
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:3001';
|
||||
const BASE_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const N_INTERACTIONS = 10000;
|
||||
const BUDGET_MS = 15000; // 10k Interaktionen muessen in < 15s sein
|
||||
|
||||
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,
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
test('Perf Gate: 10k Canvas-Interaktionen im Budget, keine JS-Fehler', async () => {
|
||||
test.setTimeout(180000);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
||||
const jsErrors: string[] = [];
|
||||
page.on('pageerror', (err) => jsErrors.push(String(err)));
|
||||
|
||||
const email = `perf-${Date.now()}@example.com`;
|
||||
const reg = await api('POST', '/api/auth/register', { email, password: 'Test1234!', name: 'Perf' });
|
||||
expect(reg.status).toBe(201);
|
||||
const token = reg.body.session.token;
|
||||
|
||||
const proj = await api('POST', '/api/projects', { name: `Perf-${Date.now()}`, description: 'perf gate' }, token);
|
||||
expect(proj.status).toBe(201);
|
||||
|
||||
await page.goto(`${BASE_URL}/`);
|
||||
await page.evaluate((t) => localStorage.setItem('auth_token', t), token);
|
||||
await page.reload();
|
||||
await page.waitForSelector('.dashboard-project-card', { timeout: 30000 });
|
||||
await page.locator('.dashboard-project-card').first().click();
|
||||
await page.waitForSelector('canvas', { timeout: 30000 });
|
||||
await page.waitForTimeout(2000); // Engines mounten
|
||||
|
||||
// Finde den Haupt-Canvas
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
|
||||
// ── 10.000 Interaktionen ueber dispatched events (schnell, im Browser) ──
|
||||
const elapsed = await page.evaluate(({ n, bx, by }) => {
|
||||
const target = document.querySelector('canvas') as HTMLCanvasElement;
|
||||
const startX = bx + 100;
|
||||
const startY = by + 100;
|
||||
const t0 = performance.now();
|
||||
let x = startX, y = startY;
|
||||
for (let i = 0; i < n; i++) {
|
||||
x = startX + Math.sin(i * 0.01) * 300;
|
||||
y = startY + Math.cos(i * 0.013) * 200;
|
||||
target.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: x, clientY: y, button: 0, buttons: 0,
|
||||
pointerId: 1, pointerType: 'mouse',
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
if (i % 500 === 0) {
|
||||
// Alle 500 auch ein down/up-Paar (Interaktionsmix)
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: x, clientY: y, button: 0, buttons: 1,
|
||||
pointerId: 1, pointerType: 'mouse',
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
target.dispatchEvent(new PointerEvent('pointerup', {
|
||||
clientX: x, clientY: y, button: 0, buttons: 0,
|
||||
pointerId: 1, pointerType: 'mouse',
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return performance.now() - t0;
|
||||
}, { n: N_INTERACTIONS, bx: box!.x, by: box!.y });
|
||||
|
||||
console.log(`Perf Gate: ${N_INTERACTIONS} Interaktionen in ${elapsed.toFixed(0)}ms (Budget: ${BUDGET_MS}ms)`);
|
||||
expect(elapsed).toBeLessThan(BUDGET_MS);
|
||||
expect(jsErrors).toHaveLength(0);
|
||||
|
||||
await browser.close();
|
||||
});
|
||||
@@ -104,6 +104,16 @@ export class SnapEngine {
|
||||
if (this.config.modes.has('nearest')) {
|
||||
this.collectNearest(el, worldX, worldY, tol, candidates);
|
||||
}
|
||||
// ── Task D11: erweiterte Snaps ──
|
||||
if (this.config.modes.has('quadrant')) {
|
||||
this.collectQuadrant(el, worldX, worldY, tol, candidates);
|
||||
}
|
||||
if (this.config.modes.has('perpendicular') && refPoint) {
|
||||
this.collectPerpendicular(el, worldX, worldY, refPoint, tol, candidates);
|
||||
}
|
||||
if (this.config.modes.has('tangent') && refPoint) {
|
||||
this.collectTangent(el, worldX, worldY, refPoint, tol, candidates);
|
||||
}
|
||||
}
|
||||
|
||||
// Intersection snap (between pairs)
|
||||
@@ -124,7 +134,8 @@ export class SnapEngine {
|
||||
|
||||
// Priority: endpoint > intersection > center > midpoint > nearest > grid
|
||||
const priority: Record<string, number> = {
|
||||
endpoint: 0, intersection: 1, center: 2, midpoint: 3, nearest: 4, grid: 5,
|
||||
endpoint: 0, intersection: 1, center: 2, quadrant: 3, perpendicular: 4,
|
||||
tangent: 5, midpoint: 6, nearest: 7, grid: 8,
|
||||
};
|
||||
|
||||
// Find best within tolerance — prefer higher priority if distances are close
|
||||
@@ -234,6 +245,79 @@ export class SnapEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/** Task D11: Quadranten-Punkte bei Kreis/Bogen (0/90/180/270). */
|
||||
private collectQuadrant(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
||||
const check = (x: number, y: number) => {
|
||||
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
|
||||
if (d < tol) out.push({ x, y, type: 'quadrant' });
|
||||
};
|
||||
if (el.type === 'circle' || el.type === 'arc') {
|
||||
const r = (el.properties as Record<string, unknown>).radius as number | undefined ?? el.width / 2;
|
||||
check(el.x + r, el.y); // 0
|
||||
check(el.x, el.y + r); // 90
|
||||
check(el.x - r, el.y); // 180
|
||||
check(el.x, el.y - r); // 270
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task D11: Perpendicular-Fußpunkt — der Punkt auf einem Segment,
|
||||
* an dem die Verbindung zum refPoint SENKRECHT auf dem Segment steht.
|
||||
*/
|
||||
private collectPerpendicular(
|
||||
el: CADElement,
|
||||
wx: number, wy: number,
|
||||
refPoint: { x: number; y: number },
|
||||
tol: number,
|
||||
out: SnapPoint[],
|
||||
): void {
|
||||
const p = el.properties as Record<string, unknown>;
|
||||
const segs: Array<Array<{ x: number; y: number }>> = [];
|
||||
if (
|
||||
p.x1 !== undefined && p.y1 !== undefined &&
|
||||
p.x2 !== undefined && p.y2 !== undefined
|
||||
) {
|
||||
segs.push([{ x: p.x1 as number, y: p.y1 as number }, { x: p.x2 as number, y: p.y2 as number }]);
|
||||
} else if (Array.isArray(p.points)) {
|
||||
const pts = p.points as Array<{ x: number; y: number }>;
|
||||
for (let i = 0; i < pts.length - 1; i++) segs.push([pts[i], pts[i + 1]]);
|
||||
}
|
||||
for (const [a, b] of segs) {
|
||||
const foot = this.nearestOnSegment(refPoint.x, refPoint.y, a.x, a.y, b.x, b.y);
|
||||
const d = Math.sqrt((wx - foot.x) ** 2 + (wy - foot.y) ** 2);
|
||||
if (d < tol) out.push({ x: foot.x, y: foot.y, type: 'perpendicular' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task D11: Tangenten-Punkte — Berührpunkte der Tangenten vom
|
||||
* refPoint an den Kreis (beide Lösungen, klassische Konstruktion
|
||||
* über rechtwinkliges Dreieck: Winkel = acos(r/d)).
|
||||
*/
|
||||
private collectTangent(
|
||||
el: CADElement,
|
||||
wx: number, wy: number,
|
||||
refPoint: { x: number; y: number },
|
||||
tol: number,
|
||||
out: SnapPoint[],
|
||||
): void {
|
||||
if (el.type !== 'circle' && el.type !== 'arc') return;
|
||||
const r = ((el.properties as Record<string, unknown>).radius as number | undefined) ?? el.width / 2;
|
||||
const dx = refPoint.x - el.x;
|
||||
const dy = refPoint.y - el.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
// refPoint im Zentrum oder im Kreisinneren: keine reale Tangente
|
||||
if (d <= r) return;
|
||||
const baseAng = Math.atan2(dy, dx);
|
||||
const halfAng = Math.acos(r / d);
|
||||
for (const t of [baseAng + halfAng, baseAng - halfAng]) {
|
||||
const px = el.x + r * Math.cos(t);
|
||||
const py = el.y + r * Math.sin(t);
|
||||
const dist = Math.sqrt((wx - px) ** 2 + (wy - py) ** 2);
|
||||
if (dist < tol) out.push({ x: px, y: py, type: 'tangent' });
|
||||
}
|
||||
}
|
||||
|
||||
private collectNearest(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
||||
const p = el.properties;
|
||||
const check = (x: number, y: number) => {
|
||||
@@ -400,3 +484,13 @@ export class SnapEngine {
|
||||
return { x: snapX, y: snapY, type: 'nearest' };
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task D11: fromOffset — Basispunkt + Eingabefeld dx/dy.
|
||||
// Reine Funktion (für from-offset-Snap im UI nutzbar).
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Zielpunkt = Basispunkt + Offsets dx/dy (Task D11 from-offset). */
|
||||
export function fromOffset(base: { x: number; y: number }, dx: number, dy: number): { x: number; y: number } {
|
||||
return { x: base.x + dx, y: base.y + dy };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { polygonArea } from '../tools/modification/geometry';
|
||||
import { formatArea } from '../utils/format';
|
||||
import type { PropertiesPanelProps } from '../types/ui.types';
|
||||
import { formatInputValue, parseDistance, type UnitType } from '../utils/format';
|
||||
import { getActiveDocument } from '../kernel/document/documentService';
|
||||
|
||||
const parseNum = (s: string) => parseFloat(s.replace(/[^0-9.\-]/g, '')) || 0;
|
||||
|
||||
@@ -77,7 +78,37 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||
<label className="prop-label">Fläche</label>
|
||||
<span className="prop-value">{formatArea(polygonArea(el.properties.points as Array<{ x: number; y: number }>), unit, scaleFactor)}</span>
|
||||
</div>
|
||||
)}` : ''}</span>
|
||||
)}
|
||||
|
||||
{/* ─── Task D12: Block-Attribute-Formular (block_instance) ─── */}
|
||||
{el.type === 'block_instance' && (() => {
|
||||
const blockId = el.properties.blockId as string | undefined;
|
||||
if (!blockId) return null;
|
||||
const block = getActiveDocument()?.getBlock(blockId);
|
||||
const attrDefs = block?.attrDefs ?? [];
|
||||
if (attrDefs.length === 0) return null;
|
||||
const attrValues = (el.properties.attrValues as Record<string, string> | undefined) ?? {};
|
||||
return (
|
||||
<div className="panel-section">
|
||||
<div className="panel-section-title">Block-Attribute</div>
|
||||
{attrDefs.map((def) => (
|
||||
<div className="prop-row" key={def.tag}>
|
||||
<label className="prop-label" title={def.prompt}>{def.tag}</label>
|
||||
<input
|
||||
className="prop-input"
|
||||
type="text"
|
||||
value={attrValues[def.tag] ?? def.defaultValue ?? ''}
|
||||
placeholder={def.prompt}
|
||||
onChange={(ev) => {
|
||||
const next = { ...attrValues, [def.tag]: ev.target.value };
|
||||
onUpdateProperty('attrValues', next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}` : ''}</span>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
{onDelete && el && (
|
||||
<button
|
||||
|
||||
@@ -42,7 +42,7 @@ function fillOrNull(raw: unknown): string | null {
|
||||
* Gibt ein leeres Array zurück für unbekannte Typen bzw. Elemente,
|
||||
* die nichts Sichtbares beitragen (z.B. leerer Text).
|
||||
*/
|
||||
export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
||||
export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: string; prompt: string; defaultValue?: string }>): ShapePrimitive[] {
|
||||
const props = (el.properties ?? {}) as Record<string, unknown>;
|
||||
const stroke = (props.stroke as string | undefined) ?? FALLBACK_STROKE;
|
||||
const sw = (props.strokeWidth as number | undefined) ?? 1;
|
||||
@@ -296,6 +296,28 @@ export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'block_instance': {
|
||||
// Task D12: Attr-Texte der Instanz zeichnen (tag=Wert, default-Fallback).
|
||||
const attrValues = (props.attrValues as Record<string, string> | undefined) ?? {};
|
||||
if (!attrDefs || attrDefs.length === 0) return [];
|
||||
const out: ShapePrimitive[] = [];
|
||||
const fontSize = 9;
|
||||
const lineH = 16;
|
||||
let i = 0;
|
||||
for (const def of attrDefs) {
|
||||
const value = attrValues[def.tag] ?? def.defaultValue ?? '—';
|
||||
out.push({
|
||||
kind: 'text',
|
||||
at: { x: el.x, y: el.y - 8 - i * lineH },
|
||||
text: `${def.tag}=${value}`,
|
||||
fontSize,
|
||||
color: stroke,
|
||||
});
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -63,6 +63,16 @@ export interface ImageElement extends CADElement {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Block-Attribut-Definition (Task D12): Slot, den Instanzen befüllen.
|
||||
* Konvention konsistent zu F5 (ATTDEF-Parser) und F6 (DXF-Writer).
|
||||
*/
|
||||
export interface BlockAttrDef {
|
||||
tag: string;
|
||||
prompt: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface BlockDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -70,6 +80,8 @@ export interface BlockDefinition {
|
||||
category: string;
|
||||
elements: CADElement[];
|
||||
thumbnail?: string;
|
||||
/** Attribut-Slots dieser Blockdefinition (Task D12). */
|
||||
attrDefs?: BlockAttrDef[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Task D12 - BlockAttribute: attrDefs + attrValues + Rendering.
|
||||
*
|
||||
* BlockDefinition.attrDefs[]: definiert Attribut-Slots (tag/prompt/
|
||||
* default). Instanz (block_instance) traegt attrValues. Der Render-
|
||||
* Adapter (elementToPrimitives) zeichnet die Attr-Werte als Text-
|
||||
* Primitive an der Instanz-Position.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import { elementToPrimitives } from '../src/render/pixi/elementDrawers';
|
||||
import type { CADElement, BlockDefinition } from '../src/types/cad.types';
|
||||
|
||||
const BLOCK: BlockDefinition = {
|
||||
id: 'blk-1',
|
||||
name: 'Titelblock',
|
||||
description: '',
|
||||
category: 'frame',
|
||||
elements: [],
|
||||
attrDefs: [
|
||||
{ tag: 'PROJEKT', prompt: 'Projektname', defaultValue: 'Unbenannt' },
|
||||
{ tag: 'AUTOR', prompt: 'Bearbeiter', defaultValue: 'WebCAD' },
|
||||
],
|
||||
};
|
||||
|
||||
function instance(attrValues: Record<string, string>): CADElement {
|
||||
return {
|
||||
id: 'inst-1', type: 'block_instance', layerId: 'layer-0',
|
||||
x: 100, y: 100, width: 0, height: 0,
|
||||
properties: { blockId: 'blk-1', scale: 1, attrValues },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
// ─── Typ-Ebene: attrDefs auf BlockDefinition ────────────────
|
||||
|
||||
describe('D12: BlockDefinition.attrDefs', () => {
|
||||
it('attrDefs-Feld existiert mit tag/prompt/defaultValue', () => {
|
||||
expect(BLOCK.attrDefs).toBeDefined();
|
||||
expect(BLOCK.attrDefs!.length).toBe(2);
|
||||
expect(BLOCK.attrDefs![0].tag).toBe('PROJEKT');
|
||||
expect(BLOCK.attrDefs![0].prompt).toBe('Projektname');
|
||||
expect(BLOCK.attrDefs![0].defaultValue).toBe('Unbenannt');
|
||||
});
|
||||
|
||||
it('CADDocument speichert Block mit attrDefs (getBlock liefert sie zurueck)', () => {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
doc.addBlock(BLOCK);
|
||||
const loaded = doc.getBlock('blk-1');
|
||||
expect(loaded!.attrDefs).toHaveLength(2);
|
||||
expect(loaded!.attrDefs![1].tag).toBe('AUTOR');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rendering: elementToPrimitives mit attrDefs ────────────
|
||||
|
||||
describe('D12: RenderAdapter zeichnet Attr-Texte', () => {
|
||||
it('Instanz mit attrValues erzeugt text-Primitives pro Attribut', () => {
|
||||
const el = instance({ PROJEKT: 'Halle A', AUTOR: 'L. Admin' });
|
||||
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||
const texts = prims.filter((p) => p.kind === 'text');
|
||||
expect(texts).toHaveLength(2);
|
||||
const proj = texts.find((t) => (t as { text: string }).text.includes('PROJEKT'));
|
||||
expect(proj).toBeDefined();
|
||||
expect((proj as { text: string }).text).toContain('Halle A');
|
||||
const autor = texts.find((t) => (t as { text: string }).text.includes('AUTOR'));
|
||||
expect((autor as { text: string }).text).toContain('L. Admin');
|
||||
});
|
||||
|
||||
it('Fehlender Wert faellt auf attrDefs.defaultValue zurueck', () => {
|
||||
const el = instance({});
|
||||
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||
const texts = prims.filter((p) => p.kind === 'text');
|
||||
const proj = texts.find((t) => (t as { text: string }).text.includes('PROJEKT'));
|
||||
expect((proj as { text: string }).text).toContain('Unbenannt');
|
||||
});
|
||||
|
||||
it('Ohne attrDefs-Parameter: kein Attr-Text-Rendering (abwaertskompatibel)', () => {
|
||||
const el = instance({ PROJEKT: 'Halle A' });
|
||||
const prims = elementToPrimitives(el);
|
||||
expect(prims.filter((p) => p.kind === 'text')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Instanz ohne attrValues mit attrDefs: defaults werden gezeichnet', () => {
|
||||
const el: CADElement = {
|
||||
id: 'inst-2', type: 'block_instance', layerId: 'layer-0',
|
||||
x: 0, y: 0, width: 0, height: 0,
|
||||
properties: { blockId: 'blk-1', scale: 1 },
|
||||
} as unknown as CADElement;
|
||||
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||
const texts = prims.filter((p) => p.kind === 'text');
|
||||
expect(texts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Text-Positionen gestaffelt unterhalb der Instanz-Position', () => {
|
||||
const el = instance({ PROJEKT: 'A', AUTOR: 'B' });
|
||||
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||
const texts = prims.filter((p) => p.kind === 'text') as Array<{ kind: 'text'; at: { x: number; y: number }; text: string }>;
|
||||
expect(texts[0].at.x).toBe(100);
|
||||
expect(texts[0].at.y).toBeCloseTo(100 - 8, 0);
|
||||
expect(texts[1].at.y).toBeLessThan(texts[0].at.y);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Task D11 - Snap-Erweiterungen: tangent, perpendicular, quadrant, fromOffset.
|
||||
*
|
||||
* quadrant: Kreis-Quadrantenpunkte (0/90/180/270 auf dem Radius).
|
||||
* perpendicular: Fuspunkt auf Linie senkrecht zum Referenzpunkt.
|
||||
* tangent: Tangentialpunkte am Kreis vom Referenzpunkt.
|
||||
* fromOffset: Basispunkt + dx/dy-Eingabefeld.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SnapEngine, fromOffset, type SnapConfig } from '../src/canvas/SnapEngine';
|
||||
import type { CADElement } from '../src/types/cad.types';
|
||||
|
||||
function cfg(modes: string[], extra: Partial<SnapConfig> = {}): Partial<SnapConfig> {
|
||||
return {
|
||||
enabled: true,
|
||||
modes: new Set(modes as never),
|
||||
tolerance: 10,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function circle(id: string, cx: number, cy: number, r: number): CADElement {
|
||||
return {
|
||||
id, type: 'circle', layerId: 'l',
|
||||
x: cx, y: cy, width: r * 2, height: r * 2,
|
||||
properties: { radius: r },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
function line(id: string, x1: number, y1: number, x2: number, y2: number): CADElement {
|
||||
return {
|
||||
id, type: 'line', layerId: 'l',
|
||||
x: (x1 + x2) / 2, y: (y1 + y2) / 2,
|
||||
width: Math.abs(x2 - x1), height: Math.abs(y2 - y1),
|
||||
properties: { x1, y1, x2, y2 },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
// ─── Quadrant ──────────────────────────────────────────────
|
||||
|
||||
describe('D11: quadrant snap', () => {
|
||||
it('Kreis r=50: alle 4 Quadrantenpunkte werden geliefert', () => {
|
||||
const eng = new SnapEngine(cfg(['quadrant']));
|
||||
eng.setElements([circle('c1', 100, 100, 50)]);
|
||||
// Cursor in der Naehe des 0-Quadranten (150,100)
|
||||
const res = eng.snap(148, 100);
|
||||
expect(res.point).not.toBeNull();
|
||||
expect(res.point!.type).toBe('quadrant');
|
||||
expect(res.point!.x).toBe(150);
|
||||
expect(res.point!.y).toBe(100);
|
||||
});
|
||||
|
||||
it('90-Quadrant (100,50) wird gefunden', () => {
|
||||
const eng = new SnapEngine(cfg(['quadrant']));
|
||||
eng.setElements([circle('c1', 100, 100, 50)]);
|
||||
const res = eng.snap(100, 52);
|
||||
expect(res.point!.x).toBe(100);
|
||||
expect(res.point!.y).toBe(50);
|
||||
expect(res.point!.type).toBe('quadrant');
|
||||
});
|
||||
|
||||
it('270-Quadrant (unten) wird gefunden', () => {
|
||||
const eng = new SnapEngine(cfg(['quadrant']));
|
||||
eng.setElements([circle('c1', 100, 100, 50)]);
|
||||
const res = eng.snap(100, 148);
|
||||
expect(res.point!.y).toBe(150);
|
||||
expect(res.point!.type).toBe('quadrant');
|
||||
});
|
||||
|
||||
it('Modus nicht aktiviert -> kein quadrant snap', () => {
|
||||
const eng = new SnapEngine(cfg(['endpoint']));
|
||||
eng.setElements([circle('c1', 100, 100, 50)]);
|
||||
const res = eng.snap(148, 100);
|
||||
expect(res.point?.type ?? null).not.toBe('quadrant');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Perpendicular ─────────────────────────────────────────
|
||||
|
||||
describe('D11: perpendicular snap', () => {
|
||||
it('Fuspunkt senkrecht vom refPoint auf horizontale Linie', () => {
|
||||
const eng = new SnapEngine(cfg(['perpendicular']));
|
||||
// Horizontale Linie y=100, refPoint (50,50): Fuspunkt (50,100)
|
||||
eng.setElements([line('l1', 0, 100, 200, 100)]);
|
||||
const res = eng.snap(52, 98, { x: 50, y: 50 });
|
||||
expect(res.point).not.toBeNull();
|
||||
expect(res.point!.type).toBe('perpendicular');
|
||||
expect(res.point!.x).toBe(50);
|
||||
expect(res.point!.y).toBe(100);
|
||||
});
|
||||
|
||||
it('Schrge Linie: Fuspunkt berechnet', () => {
|
||||
const eng = new SnapEngine(cfg(['perpendicular']));
|
||||
// Linie (0,0)-(100,100), refPoint (100,0): Fuspunkt (50,50)
|
||||
eng.setElements([line('l1', 0, 0, 100, 100)]);
|
||||
const res = eng.snap(51, 49, { x: 100, y: 0 });
|
||||
expect(res.point!.x).toBeCloseTo(50, 0);
|
||||
expect(res.point!.y).toBeCloseTo(50, 0);
|
||||
});
|
||||
|
||||
it('ohne refPoint kein perpendicular snap (Modus still)', () => {
|
||||
const eng = new SnapEngine(cfg(['perpendicular']));
|
||||
eng.setElements([line('l1', 0, 100, 200, 100)]);
|
||||
const res = eng.snap(50, 98);
|
||||
expect(res.point?.type ?? null).not.toBe('perpendicular');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tangent ───────────────────────────────────────────────
|
||||
|
||||
describe('D11: tangent snap', () => {
|
||||
it('Tangentialpunkt am Kreis von refPoint (rechts vom Kreis)', () => {
|
||||
const eng = new SnapEngine(cfg(['tangent']));
|
||||
// Kreis (100,100) r=50, refPoint (200,100), d=100:
|
||||
// halfAng = acos(r/d) = 60° → Tangentenpunkte bei (125, 143.3)/(125, 56.7)
|
||||
eng.setElements([circle('c1', 100, 100, 50)]);
|
||||
const res = eng.snap(127, 145, { x: 200, y: 100 });
|
||||
expect(res.point).not.toBeNull();
|
||||
expect(res.point!.type).toBe('tangent');
|
||||
expect(res.point!.x).toBeCloseTo(125, 0);
|
||||
expect(res.point!.y).toBeCloseTo(143.3, 0);
|
||||
});
|
||||
|
||||
it('refPoint AUF dem Kreiszentrum -> kein Tangentenpunkt (degeneriert)', () => {
|
||||
const eng = new SnapEngine(cfg(['tangent']));
|
||||
eng.setElements([circle('c1', 100, 100, 50)]);
|
||||
const res = eng.snap(148, 100, { x: 100, y: 100 });
|
||||
expect(res.point?.type ?? null).not.toBe('tangent');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fromOffset ────────────────────────────────────────────
|
||||
|
||||
describe('D11: fromOffset', () => {
|
||||
it('Basispunkt + dx/dy ergibt Zielpunkt', () => {
|
||||
const p = fromOffset({ x: 100, y: 200 }, 50, -30);
|
||||
expect(p).toEqual({ x: 150, y: 170 });
|
||||
});
|
||||
|
||||
it('dx=dy=0 bleibt am Basispunkt', () => {
|
||||
const p = fromOffset({ x: 10, y: 20 }, 0, 0);
|
||||
expect(p).toEqual({ x: 10, y: 20 });
|
||||
});
|
||||
|
||||
it('negative Offsets funktionieren', () => {
|
||||
const p = fromOffset({ x: 0, y: 0 }, -100, -50);
|
||||
expect(p).toEqual({ x: -100, y: -50 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user