Compare commits
6 Commits
7b207d8e39
..
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a20731a3f5 | |||
| c6fd8ea445 | |||
| 8845afbf6f | |||
| 7eedddbc3f | |||
| a3d3732f85 | |||
| 9ca86d67dd |
@@ -15,6 +15,9 @@ jobs:
|
|||||||
- name: Install backend dependencies
|
- name: Install backend dependencies
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
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
|
- name: Run backend tests
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: npm test
|
run: npm test
|
||||||
@@ -30,6 +33,9 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
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
|
- name: Run frontend tests
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm test
|
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
|
## Stack
|
||||||
- Frontend: React + TypeScript + Vite + HTML Canvas
|
|
||||||
- Backend: Node.js + TypeScript + Fastify + SQLite
|
- **Frontend:** React 18 + TypeScript + Vite + PixiJS v8 (WebGL-Renderer, Canvas2D-Fallback) + zustandsfreier Service-Layer
|
||||||
- Collaboration: Yjs CRDT
|
- **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
|
## Entwicklung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
# Backend
|
||||||
npm run dev
|
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
|
## Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up --build
|
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).
|
||||||
|
|||||||
+8
-5
@@ -609,7 +609,7 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
|
|||||||
- [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] 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)*
|
- [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)*
|
||||||
- [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] D11 Snaps erweitern (SnapEngine + Optionen): tangent, perpendicular, quadrant, center, from-offset (Basispunkt+Eingabefeld dx/dy) *(2026-08-29: collectQuadrant (Kreis/Arc-Quadranten 0/90/180/270), collectPerpendicular (Fußpunkt vom refPoint auf Segmente), collectTangent (beide Tangentenpunkte via acos(r/d)), fromOffset (Basispunkt+dx/dy), Prioritäts-Map erweitert; center existierte bereits; Log D11)*
|
||||||
- [ ] D12 BlockAttribute: BlockDef.attrDefs[], Instanz attrValues; PropertiesPanel rendert Formular; RenderAdapter zeichnet Attr-Texte; (DXF ATTDEF/ATTR erst F)
|
- [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)
|
## Phase E — Event-Domain (plugin: event-tools)
|
||||||
- [ ] E1 seating-row/-block V2 mit Drag-Interaktion + Live-Preview + Optionsleiste (count/spacing/curve)
|
- [ ] 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)*
|
- [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
|
## Phase I — Qualität & Release
|
||||||
- [ ] I1 CI: forgejo workflow erweitern um performance gate (playwright bench: 10k interaktionsbudget), typecheck, beide Suites
|
- [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)*
|
||||||
- [ ] I2 Docs: PLUGIN_GUIDE.md (API v2 + Beispielplugin), LIBRARY_SPEC.md (.wcadlib), CHANGELOG 2.0, README rewrite
|
- [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
|
- [ ] I3 Release 2.0 tag + docker images + coolify deploy verify
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -667,6 +667,9 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
|||||||
|
|
||||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-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 | 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 | 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 | 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) |
|
||||||
@@ -749,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)
|
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
||||||
|
|
||||||
---
|
---
|
||||||
*Nächster offener Schritt: **D12** (BlockAttribute: BlockDef.attrDefs[], Instanz attrValues; PropertiesPanel rendert Formular; RenderAdapter zeichnet Attr-Texte; DXF ATTDEF/ATTR erst F — bereits erledigt in F5/F6). Danach: Phase I (Docs/CI/Release).*
|
*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: Phasen B+A+C+D+E+F+G+H komplett, D1–D4+D9+D10+D11 fertig (Snaps erweitert), BUG-1-Kollisionsklasse geschlossen. FE 690/690 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main. Offen: D12, Phase I (Release).*
|
*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",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"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": {
|
"dependencies": {
|
||||||
"dxf-parser": "^1.1.2",
|
"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();
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { polygonArea } from '../tools/modification/geometry';
|
|||||||
import { formatArea } from '../utils/format';
|
import { formatArea } from '../utils/format';
|
||||||
import type { PropertiesPanelProps } from '../types/ui.types';
|
import type { PropertiesPanelProps } from '../types/ui.types';
|
||||||
import { formatInputValue, parseDistance, type UnitType } from '../utils/format';
|
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;
|
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>
|
<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>
|
<span className="prop-value">{formatArea(polygonArea(el.properties.points as Array<{ x: number; y: number }>), unit, scaleFactor)}</span>
|
||||||
</div>
|
</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' }}>
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
{onDelete && el && (
|
{onDelete && el && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function fillOrNull(raw: unknown): string | null {
|
|||||||
* Gibt ein leeres Array zurück für unbekannte Typen bzw. Elemente,
|
* Gibt ein leeres Array zurück für unbekannte Typen bzw. Elemente,
|
||||||
* die nichts Sichtbares beitragen (z.B. leerer Text).
|
* die nichts Sichtbares beitragen (z.B. leerer Text).
|
||||||
*/
|
*/
|
||||||
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 props = (el.properties ?? {}) as Record<string, unknown>;
|
||||||
const stroke = (props.stroke as string | undefined) ?? FALLBACK_STROKE;
|
const stroke = (props.stroke as string | undefined) ?? FALLBACK_STROKE;
|
||||||
const sw = (props.strokeWidth as number | undefined) ?? 1;
|
const sw = (props.strokeWidth as number | undefined) ?? 1;
|
||||||
@@ -296,6 +296,28 @@ export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
|||||||
return out;
|
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:
|
default:
|
||||||
return [];
|
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 {
|
export interface BlockDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -70,6 +80,8 @@ export interface BlockDefinition {
|
|||||||
category: string;
|
category: string;
|
||||||
elements: CADElement[];
|
elements: CADElement[];
|
||||||
thumbnail?: string;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user