Compare commits
79 Commits
6dd4e083b0
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a20731a3f5 | |||
| c6fd8ea445 | |||
| 8845afbf6f | |||
| 7eedddbc3f | |||
| a3d3732f85 | |||
| 9ca86d67dd | |||
| 7b207d8e39 | |||
| c4e3206932 | |||
| 3c806437d6 | |||
| 227d617bb1 | |||
| eeaa7806c6 | |||
| c6f38eee5b | |||
| 68b66ba190 | |||
| fbc4978a40 | |||
| b8b67ec7ec | |||
| bfd500bbc2 | |||
| 1664404948 | |||
| fa3dc7d7e1 | |||
| 40fa7891d5 | |||
| 5db421da5e | |||
| 5cfd29cf1a | |||
| b210ccc5b8 | |||
| 21e18b81d8 | |||
| e6fdb39a86 | |||
| 10eacdeaf5 | |||
| d4030400b4 | |||
| e3fde5012e | |||
| 131a922390 | |||
| 2b9a3bef16 | |||
| 8774fea773 | |||
| ed71100178 | |||
| d9a0dffb05 | |||
| a093630d34 | |||
| cb23638610 | |||
| f6b5046a60 | |||
| bbb469e25e | |||
| 1eb6a99025 | |||
| 7feed9e9e3 | |||
| d4e1b781a6 | |||
| e750a45d7a | |||
| d51111fc2c | |||
| 9176d7c9a4 | |||
| f4dbb8caaa | |||
| c6b7881bae | |||
| 09948bc587 | |||
| 9013d72c03 | |||
| d311820351 | |||
| bdf3baf4bd | |||
| 75de6dd1f7 | |||
| 3c2f015acf | |||
| 6857694f64 | |||
| a9c3ba786b | |||
| 2efbbf9bd2 | |||
| ec7cb4043b | |||
| c78d8d8334 | |||
| 7c94de1590 | |||
| 5d8253a22b | |||
| 8baba7e124 | |||
| 8b50c0a612 | |||
| 83a94e4f42 | |||
| 8ab816667b | |||
| 5e9e70cb59 | |||
| 63c8968f87 | |||
| 5cbfe55d03 | |||
| 1e7fb8bcd0 | |||
| 626d565429 | |||
| 9e8255f70f | |||
| 5eb741d2a7 | |||
| e99cf597f8 | |||
| 6a214c2e9c | |||
| 0696df1e0d | |||
| 214e57c9a5 | |||
| c3b976758c | |||
| 633cf3d647 | |||
| 85105d3f01 | |||
| 86b1e0b8db | |||
| b875012afd | |||
| 4c9bd2382e | |||
| 5500462bd8 |
@@ -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).
|
||||||
|
|||||||
+78
-750
File diff suppressed because it is too large
Load Diff
Generated
+77
@@ -14,6 +14,7 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^11.0.0",
|
||||||
"fastify": "^5.9.0",
|
"fastify": "^5.9.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"y-leveldb": "^0.2.0",
|
"y-leveldb": "^0.2.0",
|
||||||
"y-protocols": "^1.0.7",
|
"y-protocols": "^1.0.7",
|
||||||
"yjs": "^13.6.0"
|
"yjs": "^13.6.0"
|
||||||
@@ -1503,6 +1504,11 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -1886,6 +1892,11 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -1926,6 +1937,11 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
||||||
|
},
|
||||||
"node_modules/isomorphic.js": {
|
"node_modules/isomorphic.js": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||||
@@ -1958,6 +1974,44 @@
|
|||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/level": {
|
"node_modules/level": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/level/-/level-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/level/-/level-8.0.1.tgz",
|
||||||
@@ -2135,6 +2189,14 @@
|
|||||||
"url": "https://github.com/sponsors/dmonad"
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/light-my-request": {
|
"node_modules/light-my-request": {
|
||||||
"version": "6.6.0",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||||
@@ -2587,6 +2649,11 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||||
|
},
|
||||||
"node_modules/path-scurry": {
|
"node_modules/path-scurry": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||||
@@ -2722,6 +2789,11 @@
|
|||||||
"node": ">= 0.6.0"
|
"node": ">= 0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
||||||
|
},
|
||||||
"node_modules/process-warning": {
|
"node_modules/process-warning": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||||
@@ -2969,6 +3041,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^11.0.0",
|
||||||
"fastify": "^5.9.0",
|
"fastify": "^5.9.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"y-leveldb": "^0.2.0",
|
"y-leveldb": "^0.2.0",
|
||||||
"y-protocols": "^1.0.7",
|
"y-protocols": "^1.0.7",
|
||||||
"yjs": "^13.6.0"
|
"yjs": "^13.6.0"
|
||||||
|
|||||||
@@ -44,3 +44,20 @@ export function requireAdmin(request: FastifyRequest, reply: FastifyReply, authS
|
|||||||
}
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require library-admin role (Task H3: global library write guard).
|
||||||
|
* Library-admin is bound to the existing admin role: only admins may
|
||||||
|
* write (create/update/delete/import) the GLOBAL block library, while
|
||||||
|
* every authenticated user may read/export it. Own drawing-scoped
|
||||||
|
* blocks are unaffected (per-user ownership there).
|
||||||
|
*/
|
||||||
|
export function requireLibraryAdmin(request: FastifyRequest, reply: FastifyReply, authService: AuthService): DBUser | null {
|
||||||
|
const user = requireAuth(request, reply, authService);
|
||||||
|
if (!user) return null;
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
reply.code(403).send({ error: 'Library admin access required' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export interface DBGlobalBlock {
|
|||||||
name: string;
|
name: string;
|
||||||
block_data: string;
|
block_data: string;
|
||||||
svg_data: string | null;
|
svg_data: string | null;
|
||||||
|
is_favorite: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -213,4 +214,23 @@ export interface DatabaseInterface {
|
|||||||
createGlobalBlock(data: Partial<DBGlobalBlock>): DBGlobalBlock;
|
createGlobalBlock(data: Partial<DBGlobalBlock>): DBGlobalBlock;
|
||||||
updateGlobalBlock(id: string, data: Partial<DBGlobalBlock>): DBGlobalBlock | null;
|
updateGlobalBlock(id: string, data: Partial<DBGlobalBlock>): DBGlobalBlock | null;
|
||||||
deleteGlobalBlock(id: string): boolean;
|
deleteGlobalBlock(id: string): boolean;
|
||||||
|
|
||||||
|
// Guests (Task E6)
|
||||||
|
listGuests(drawingId: string): DBGuest[];
|
||||||
|
getGuest(id: string): DBGuest | null;
|
||||||
|
createGuest(data: Partial<DBGuest>): DBGuest;
|
||||||
|
updateGuest(id: string, data: Partial<DBGuest>): DBGuest | null;
|
||||||
|
deleteGuest(id: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Guests (Task E6) ──────────────────────────────────
|
||||||
|
export interface DBGuest {
|
||||||
|
id: string;
|
||||||
|
drawing_id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
category: string; // 'standard' | 'vip' | 'staff'
|
||||||
|
seat_element_id?: string; // Verknüpfung zu einem chair-Element
|
||||||
|
notes?: string;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
DBElement, DBBlock, DBSetting, DBUser, DBSession,
|
DBElement, DBBlock, DBSetting, DBUser, DBSession,
|
||||||
DBNotification, DBProjectShare,
|
DBNotification, DBProjectShare,
|
||||||
DBGlobalBlockFolder, DBGlobalBlock,
|
DBGlobalBlockFolder, DBGlobalBlock,
|
||||||
|
DBGuest,
|
||||||
DBProjectFolder,
|
DBProjectFolder,
|
||||||
} from './DatabaseInterface.js';
|
} from './DatabaseInterface.js';
|
||||||
|
|
||||||
@@ -34,6 +35,11 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
if (!columns.some(col => col.name === 'folder_id')) {
|
if (!columns.some(col => col.name === 'folder_id')) {
|
||||||
this.db.exec('ALTER TABLE projects ADD COLUMN folder_id TEXT REFERENCES project_folders(id) ON DELETE SET NULL');
|
this.db.exec('ALTER TABLE projects ADD COLUMN folder_id TEXT REFERENCES project_folders(id) ON DELETE SET NULL');
|
||||||
}
|
}
|
||||||
|
// Add is_favorite column to global_blocks table for existing databases (Task F2)
|
||||||
|
const gbColumns = this.db.prepare("PRAGMA table_info('global_blocks')").all() as { name: string }[];
|
||||||
|
if (!gbColumns.some(col => col.name === 'is_favorite')) {
|
||||||
|
this.db.exec('ALTER TABLE global_blocks ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
@@ -50,7 +56,7 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createUser(data: Partial<DBUser>): DBUser {
|
createUser(data: Partial<DBUser>): DBUser {
|
||||||
const id = data.id ?? `user-${Date.now()}`;
|
const id = data.id ?? `user-${randomUUID().slice(0, 8)}`;
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)',
|
||||||
).run(id, data.email!, data.password_hash!, data.name!, data.role ?? 'planer');
|
).run(id, data.email!, data.password_hash!, data.name!, data.role ?? 'planer');
|
||||||
@@ -104,7 +110,7 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createProject(data: Partial<DBProject>): DBProject {
|
createProject(data: Partial<DBProject>): DBProject {
|
||||||
const id = data.id ?? `proj-${Date.now()}`;
|
const id = data.id ?? `proj-${randomUUID().slice(0, 8)}`;
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
'INSERT INTO projects (id, name, description, owner_id, folder_id) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO projects (id, name, description, owner_id, folder_id) VALUES (?, ?, ?, ?, ?)',
|
||||||
).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default', data.folder_id ?? null);
|
).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default', data.folder_id ?? null);
|
||||||
@@ -143,7 +149,7 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createProjectFolder(data: Partial<DBProjectFolder>): DBProjectFolder {
|
createProjectFolder(data: Partial<DBProjectFolder>): DBProjectFolder {
|
||||||
const id = data.id ?? `pfolder-${Date.now()}`;
|
const id = data.id ?? `pfolder-${randomUUID().slice(0, 8)}`;
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
'INSERT INTO project_folders (id, name, parent_id, owner_id) VALUES (?, ?, ?, ?)',
|
'INSERT INTO project_folders (id, name, parent_id, owner_id) VALUES (?, ?, ?, ?)',
|
||||||
).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null, data.owner_id ?? 'user-default');
|
).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null, data.owner_id ?? 'user-default');
|
||||||
@@ -177,7 +183,7 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createDrawing(data: Partial<DBDrawing>): DBDrawing {
|
createDrawing(data: Partial<DBDrawing>): DBDrawing {
|
||||||
const id = data.id ?? `draw-${Date.now()}`;
|
const id = data.id ?? `draw-${randomUUID().slice(0, 8)}`;
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
'INSERT INTO drawings (id, project_id, name, data_json) VALUES (?, ?, ?, ?)',
|
'INSERT INTO drawings (id, project_id, name, data_json) VALUES (?, ?, ?, ?)',
|
||||||
).run(id, data.project_id!, data.name ?? 'Unbenannt', data.data_json ?? '{}');
|
).run(id, data.project_id!, data.name ?? 'Unbenannt', data.data_json ?? '{}');
|
||||||
@@ -206,7 +212,7 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createLayer(data: Partial<DBLayer>): DBLayer {
|
createLayer(data: Partial<DBLayer>): DBLayer {
|
||||||
const id = data.id ?? `layer-${Date.now()}`;
|
const id = data.id ?? `layer-${randomUUID().slice(0, 8)}`;
|
||||||
// Idempotent: if a layer with the same id already exists, return it.
|
// Idempotent: if a layer with the same id already exists, return it.
|
||||||
// This prevents race conditions under React StrictMode double-render.
|
// This prevents race conditions under React StrictMode double-render.
|
||||||
const existing = this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer | undefined;
|
const existing = this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer | undefined;
|
||||||
@@ -243,7 +249,7 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createElement(data: Partial<DBElement>): DBElement {
|
createElement(data: Partial<DBElement>): DBElement {
|
||||||
const id = data.id ?? `elem-${Date.now()}`;
|
const id = data.id ?? `elem-${randomUUID().slice(0, 8)}`;
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
).run(id, data.drawing_id!, data.layer_id!, data.type!, data.x ?? 0, data.y ?? 0,
|
).run(id, data.drawing_id!, data.layer_id!, data.type!, data.x ?? 0, data.y ?? 0,
|
||||||
@@ -275,7 +281,7 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createBlock(data: Partial<DBBlock>): DBBlock {
|
createBlock(data: Partial<DBBlock>): DBBlock {
|
||||||
const id = data.id ?? `block-${Date.now()}`;
|
const id = data.id ?? `block-${randomUUID().slice(0, 8)}`;
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
'INSERT INTO blocks (id, drawing_id, name, description, category, elements_json, thumbnail) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO blocks (id, drawing_id, name, description, category, elements_json, thumbnail) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
).run(id, data.drawing_id!, data.name ?? 'Block', data.description ?? null,
|
).run(id, data.drawing_id!, data.name ?? 'Block', data.description ?? null,
|
||||||
@@ -346,7 +352,7 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createNotification(data: Partial<DBNotification>): DBNotification {
|
createNotification(data: Partial<DBNotification>): DBNotification {
|
||||||
const id = data.id ?? `notif-${Date.now()}`;
|
const id = data.id ?? `notif-${randomUUID().slice(0, 8)}`;
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
'INSERT INTO notifications (id, user_id, type, title, message, read) VALUES (?, ?, ?, ?, ?, ?)',
|
'INSERT INTO notifications (id, user_id, type, title, message, read) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
).run(id, data.user_id!, data.type ?? 'info', data.title!, data.message!, data.read ?? 0);
|
).run(id, data.user_id!, data.type ?? 'info', data.title!, data.message!, data.read ?? 0);
|
||||||
@@ -371,7 +377,7 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createProjectShare(data: Partial<DBProjectShare>): DBProjectShare {
|
createProjectShare(data: Partial<DBProjectShare>): DBProjectShare {
|
||||||
const id = data.id ?? `share-${Date.now()}`;
|
const id = data.id ?? `share-${randomUUID().slice(0, 8)}`;
|
||||||
const shareToken = data.share_token ?? randomUUID();
|
const shareToken = data.share_token ?? randomUUID();
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
'INSERT INTO project_shares (id, project_id, shared_with_email, shared_by, permission, share_token) VALUES (?, ?, ?, ?, ?, ?)',
|
'INSERT INTO project_shares (id, project_id, shared_with_email, shared_by, permission, share_token) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
@@ -399,7 +405,7 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createGlobalFolder(data: Partial<DBGlobalBlockFolder>): DBGlobalBlockFolder {
|
createGlobalFolder(data: Partial<DBGlobalBlockFolder>): DBGlobalBlockFolder {
|
||||||
const id = data.id ?? `gfolder-${Date.now()}`;
|
const id = data.id ?? `gfolder-${randomUUID().slice(0, 8)}`;
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
'INSERT INTO global_block_folders (id, name, parent_id) VALUES (?, ?, ?)',
|
'INSERT INTO global_block_folders (id, name, parent_id) VALUES (?, ?, ?)',
|
||||||
).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null);
|
).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null);
|
||||||
@@ -422,25 +428,31 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Global Blocks ─────────────────────────────────────
|
// ─── Global Blocks ─────────────────────────────────────
|
||||||
|
// Map DB row to DBGlobalBlock (is_favorite 0/1 → boolean, Task F2)
|
||||||
|
private mapGlobalBlockRow(row: Record<string, unknown>): DBGlobalBlock {
|
||||||
|
return { ...(row as unknown as DBGlobalBlock), is_favorite: Number(row.is_favorite ?? 0) === 1 };
|
||||||
|
}
|
||||||
|
|
||||||
listGlobalBlocks(folderId?: string | null): DBGlobalBlock[] {
|
listGlobalBlocks(folderId?: string | null): DBGlobalBlock[] {
|
||||||
if (folderId === undefined) {
|
if (folderId === undefined) {
|
||||||
return this.db.prepare('SELECT * FROM global_blocks ORDER BY updated_at DESC').all() as DBGlobalBlock[];
|
return (this.db.prepare('SELECT * FROM global_blocks ORDER BY updated_at DESC').all() as Record<string, unknown>[]).map(r => this.mapGlobalBlockRow(r));
|
||||||
}
|
}
|
||||||
if (folderId === null) {
|
if (folderId === null) {
|
||||||
return this.db.prepare('SELECT * FROM global_blocks WHERE folder_id IS NULL ORDER BY updated_at DESC').all() as DBGlobalBlock[];
|
return (this.db.prepare('SELECT * FROM global_blocks WHERE folder_id IS NULL ORDER BY updated_at DESC').all() as Record<string, unknown>[]).map(r => this.mapGlobalBlockRow(r));
|
||||||
}
|
}
|
||||||
return this.db.prepare('SELECT * FROM global_blocks WHERE folder_id = ? ORDER BY updated_at DESC').all(folderId) as DBGlobalBlock[];
|
return (this.db.prepare('SELECT * FROM global_blocks WHERE folder_id = ? ORDER BY updated_at DESC').all(folderId) as Record<string, unknown>[]).map(r => this.mapGlobalBlockRow(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
getGlobalBlock(id: string): DBGlobalBlock | null {
|
getGlobalBlock(id: string): DBGlobalBlock | null {
|
||||||
return (this.db.prepare('SELECT * FROM global_blocks WHERE id = ?').get(id) as DBGlobalBlock) ?? null;
|
const row = this.db.prepare('SELECT * FROM global_blocks WHERE id = ?').get(id) as Record<string, unknown> | undefined;
|
||||||
|
return row ? this.mapGlobalBlockRow(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
createGlobalBlock(data: Partial<DBGlobalBlock>): DBGlobalBlock {
|
createGlobalBlock(data: Partial<DBGlobalBlock>): DBGlobalBlock {
|
||||||
const id = data.id ?? `gblock-${Date.now()}`;
|
const id = data.id ?? `gblock-${randomUUID().slice(0, 8)}`;
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
'INSERT INTO global_blocks (id, folder_id, name, block_data, svg_data) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO global_blocks (id, folder_id, name, block_data, svg_data, is_favorite) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
).run(id, data.folder_id ?? null, data.name ?? 'Global Block', data.block_data ?? '{}', data.svg_data ?? null);
|
).run(id, data.folder_id ?? null, data.name ?? 'Global Block', data.block_data ?? '{}', data.svg_data ?? null, data.is_favorite ? 1 : 0);
|
||||||
return this.getGlobalBlock(id)!;
|
return this.getGlobalBlock(id)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +463,7 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
if (data.folder_id !== undefined) { sets.push('folder_id = ?'); vals.push(data.folder_id); }
|
if (data.folder_id !== undefined) { sets.push('folder_id = ?'); vals.push(data.folder_id); }
|
||||||
if (data.block_data !== undefined) { sets.push('block_data = ?'); vals.push(data.block_data); }
|
if (data.block_data !== undefined) { sets.push('block_data = ?'); vals.push(data.block_data); }
|
||||||
if (data.svg_data !== undefined) { sets.push('svg_data = ?'); vals.push(data.svg_data); }
|
if (data.svg_data !== undefined) { sets.push('svg_data = ?'); vals.push(data.svg_data); }
|
||||||
|
if (data.is_favorite !== undefined) { sets.push('is_favorite = ?'); vals.push(data.is_favorite ? 1 : 0); }
|
||||||
sets.push("updated_at = datetime('now')");
|
sets.push("updated_at = datetime('now')");
|
||||||
this.db.prepare(`UPDATE global_blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
this.db.prepare(`UPDATE global_blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||||
return this.getGlobalBlock(id);
|
return this.getGlobalBlock(id);
|
||||||
@@ -459,4 +472,37 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
deleteGlobalBlock(id: string): boolean {
|
deleteGlobalBlock(id: string): boolean {
|
||||||
return this.db.prepare('DELETE FROM global_blocks WHERE id = ?').run(id).changes > 0;
|
return this.db.prepare('DELETE FROM global_blocks WHERE id = ?').run(id).changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Guests (Task E6) ──────────────────────────────────
|
||||||
|
|
||||||
|
listGuests(drawingId: string): DBGuest[] {
|
||||||
|
return this.db.prepare('SELECT * FROM guests WHERE drawing_id = ? ORDER BY created_at').all(drawingId) as DBGuest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
getGuest(id: string): DBGuest | null {
|
||||||
|
return (this.db.prepare('SELECT * FROM guests WHERE id = ?').get(id) as DBGuest) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createGuest(data: Partial<DBGuest>): DBGuest {
|
||||||
|
const id = data.id ?? randomUUID();
|
||||||
|
const created_at = new Date().toISOString();
|
||||||
|
this.db.prepare(
|
||||||
|
'INSERT INTO guests (id, drawing_id, name, email, category, seat_element_id, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
).run(id, data.drawing_id!, data.name!, data.email ?? null, data.category ?? 'standard', data.seat_element_id ?? null, data.notes ?? null, created_at);
|
||||||
|
return this.getGuest(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGuest(id: string, data: Partial<DBGuest>): DBGuest | null {
|
||||||
|
const existing = this.getGuest(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
const merged = { ...existing, ...data, id: existing.id, drawing_id: existing.drawing_id, created_at: existing.created_at };
|
||||||
|
this.db.prepare(
|
||||||
|
'UPDATE guests SET name=?, email=?, category=?, seat_element_id=?, notes=? WHERE id=?'
|
||||||
|
).run(merged.name, merged.email ?? null, merged.category, merged.seat_element_id ?? null, merged.notes ?? null, id);
|
||||||
|
return this.getGuest(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGuest(id: string): boolean {
|
||||||
|
return this.db.prepare('DELETE FROM guests WHERE id = ?').run(id).changes > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ CREATE TABLE IF NOT EXISTS global_blocks (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
block_data TEXT NOT NULL DEFAULT '{}',
|
block_data TEXT NOT NULL DEFAULT '{}',
|
||||||
svg_data TEXT,
|
svg_data TEXT,
|
||||||
|
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (folder_id) REFERENCES global_block_folders(id) ON DELETE SET NULL
|
FOREIGN KEY (folder_id) REFERENCES global_block_folders(id) ON DELETE SET NULL
|
||||||
@@ -178,3 +179,17 @@ CREATE TABLE IF NOT EXISTS global_blocks (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_global_blocks_folder ON global_blocks(folder_id);
|
CREATE INDEX IF NOT EXISTS idx_global_blocks_folder ON global_blocks(folder_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_global_block_folders_parent ON global_block_folders(parent_id);
|
CREATE INDEX IF NOT EXISTS idx_global_block_folders_parent ON global_block_folders(parent_id);
|
||||||
|
|
||||||
|
-- ─── Guests (Task E6) ────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS guests (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
drawing_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
category TEXT DEFAULT 'standard',
|
||||||
|
seat_element_id TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guests_drawing ON guests(drawing_id);
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
import { requireAuth, requireLibraryAdmin } from '../auth/authMiddleware.js';
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
import { validateName, validateIdParam } from '../utils/validation.js';
|
import { validateName, validateIdParam } from '../utils/validation.js';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
|
||||||
export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||||
// ─── Global Block Folders ──────────────────────────────
|
// ─── Global Block Folders ──────────────────────────────
|
||||||
@@ -35,7 +36,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
|||||||
|
|
||||||
// Create a new folder
|
// Create a new folder
|
||||||
fastify.post('/api/global-folders', async (request, reply) => {
|
fastify.post('/api/global-folders', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||||
const body = request.body as { name?: string; parent_id?: string | null };
|
const body = request.body as { name?: string; parent_id?: string | null };
|
||||||
const nameErr = validateName(body.name, 'name');
|
const nameErr = validateName(body.name, 'name');
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||||
@@ -48,7 +49,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
|||||||
|
|
||||||
// Rename / move a folder
|
// Rename / move a folder
|
||||||
fastify.patch('/api/global-folders/:id', async (request, reply) => {
|
fastify.patch('/api/global-folders/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const idErr = validateIdParam(id, 'id');
|
const idErr = validateIdParam(id, 'id');
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
@@ -70,7 +71,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
|||||||
|
|
||||||
// Delete a folder (cascades to children, blocks get folder_id = NULL)
|
// Delete a folder (cascades to children, blocks get folder_id = NULL)
|
||||||
fastify.delete('/api/global-folders/:id', async (request, reply) => {
|
fastify.delete('/api/global-folders/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const idErr = validateIdParam(id, 'id');
|
const idErr = validateIdParam(id, 'id');
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
@@ -106,7 +107,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
|||||||
|
|
||||||
// Create a new global block
|
// Create a new global block
|
||||||
fastify.post('/api/global-blocks', async (request, reply) => {
|
fastify.post('/api/global-blocks', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||||
const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string };
|
const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string };
|
||||||
const nameErr = validateName(body.name, 'name');
|
const nameErr = validateName(body.name, 'name');
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||||
@@ -130,15 +131,18 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
|||||||
|
|
||||||
// Update a global block (rename, move, update data)
|
// Update a global block (rename, move, update data)
|
||||||
fastify.patch('/api/global-blocks/:id', async (request, reply) => {
|
fastify.patch('/api/global-blocks/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const idErr = validateIdParam(id, 'id');
|
const idErr = validateIdParam(id, 'id');
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string };
|
const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string; is_favorite?: boolean };
|
||||||
if (body.name !== undefined) {
|
if (body.name !== undefined) {
|
||||||
const nameErr = validateName(body.name, 'name');
|
const nameErr = validateName(body.name, 'name');
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||||
}
|
}
|
||||||
|
if (body.is_favorite !== undefined && typeof body.is_favorite !== 'boolean') {
|
||||||
|
return reply.code(400).send({ error: 'is_favorite must be a boolean' });
|
||||||
|
}
|
||||||
if (body.folder_id !== undefined && body.folder_id !== null) {
|
if (body.folder_id !== undefined && body.folder_id !== null) {
|
||||||
const fidErr = validateIdParam(body.folder_id, 'folder_id');
|
const fidErr = validateIdParam(body.folder_id, 'folder_id');
|
||||||
if (fidErr) return reply.code(400).send({ error: fidErr });
|
if (fidErr) return reply.code(400).send({ error: fidErr });
|
||||||
@@ -156,7 +160,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
|||||||
|
|
||||||
// Delete a global block
|
// Delete a global block
|
||||||
fastify.delete('/api/global-blocks/:id', async (request, reply) => {
|
fastify.delete('/api/global-blocks/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const idErr = validateIdParam(id, 'id');
|
const idErr = validateIdParam(id, 'id');
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
@@ -164,4 +168,194 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
|||||||
if (!ok) return reply.code(404).send({ error: 'Global block not found' });
|
if (!ok) return reply.code(404).send({ error: 'Global block not found' });
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Task F1: Library-Paket Export/Import (.wcadlib) ──────
|
||||||
|
|
||||||
|
// Export: komplette Library als JSON-Paket (Ordner + Blöcke)
|
||||||
|
fastify.get('/api/global-blocks/export', async (request, reply) => {
|
||||||
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
|
const folders = db.listGlobalFolders();
|
||||||
|
const blocks = db.listGlobalBlocks();
|
||||||
|
return {
|
||||||
|
format: 'wcadlib',
|
||||||
|
version: 1,
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
folders: folders.map((f) => ({ id: f.id, name: f.name, parent_id: f.parent_id })),
|
||||||
|
blocks: blocks.map((b) => ({
|
||||||
|
name: b.name,
|
||||||
|
folder_id: b.folder_id,
|
||||||
|
block_data: b.block_data,
|
||||||
|
svg_data: b.svg_data,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import: .wcadlib-Paket einspielen (IDs neu, Ordner-Hierarchie über Mapping erhalten)
|
||||||
|
fastify.post('/api/global-blocks/import', async (request, reply) => {
|
||||||
|
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||||
|
const pkg = request.body as {
|
||||||
|
format?: string;
|
||||||
|
folders?: Array<{ id: string; name: string; parent_id: string | null }>;
|
||||||
|
blocks?: Array<{ name: string; folder_id: string | null; block_data: string; svg_data: string | null }>;
|
||||||
|
};
|
||||||
|
if (!pkg || pkg.format !== 'wcadlib' || !Array.isArray(pkg.blocks)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid wcadlib package (format != wcadlib or blocks missing)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordner mit ID-Mapping importieren:
|
||||||
|
const folderIdMap = new Map<string, string>();
|
||||||
|
const sortedFolders = [...(pkg.folders ?? [])].sort((a, b) => {
|
||||||
|
if (a.parent_id === b.parent_id) return 0;
|
||||||
|
if (a.parent_id === null) return -1;
|
||||||
|
if (b.parent_id === null) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
for (const f of sortedFolders) {
|
||||||
|
const newParent = f.parent_id ? folderIdMap.get(f.parent_id) ?? null : null;
|
||||||
|
const created = db.createGlobalFolder({ name: f.name, parent_id: newParent });
|
||||||
|
folderIdMap.set(f.id, created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blöcke importieren:
|
||||||
|
let imported = 0;
|
||||||
|
for (const b of pkg.blocks) {
|
||||||
|
const newFolderId = b.folder_id ? folderIdMap.get(b.folder_id) ?? null : null;
|
||||||
|
db.createGlobalBlock({
|
||||||
|
name: b.name,
|
||||||
|
folder_id: newFolderId,
|
||||||
|
block_data: b.block_data,
|
||||||
|
svg_data: b.svg_data,
|
||||||
|
});
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
return reply.code(201).send({ imported_blocks: imported, imported_folders: folderIdMap.size });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task F7: .wcadlib als echtes ZIP-Paket ──────────────
|
||||||
|
|
||||||
|
// ZIP-Export: manifest.json + blocks.json + thumbs/ (SVG je Block)
|
||||||
|
fastify.get('/api/global-blocks/export-zip', async (request, reply) => {
|
||||||
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
|
const folders = db.listGlobalFolders();
|
||||||
|
const blocks = db.listGlobalBlocks();
|
||||||
|
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({
|
||||||
|
format: 'wcadlib-zip',
|
||||||
|
version: 1,
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
block_count: blocks.length,
|
||||||
|
folder_count: folders.length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const blockEntries = [] as Array<{
|
||||||
|
name: string;
|
||||||
|
folder_id: string | null;
|
||||||
|
block_data: string;
|
||||||
|
thumbnail: string | null;
|
||||||
|
}>;
|
||||||
|
for (const b of blocks) {
|
||||||
|
let thumbnail: string | null = null;
|
||||||
|
if (b.svg_data) {
|
||||||
|
thumbnail = `thumbs/${b.id}.svg`;
|
||||||
|
zip.file(thumbnail, b.svg_data);
|
||||||
|
}
|
||||||
|
blockEntries.push({
|
||||||
|
name: b.name,
|
||||||
|
folder_id: b.folder_id,
|
||||||
|
block_data: b.block_data,
|
||||||
|
thumbnail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
zip.file('blocks.json', JSON.stringify({
|
||||||
|
folders: folders.map((f) => ({ id: f.id, name: f.name, parent_id: f.parent_id })),
|
||||||
|
blocks: blockEntries,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
|
||||||
|
reply.header('content-type', 'application/zip');
|
||||||
|
reply.header('content-disposition', 'attachment; filename="library.wcadlib.zip"');
|
||||||
|
return reply.send(buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ZIP-Import: .wcadlib-ZIP einspielen (Ordner-Mapping, Blöcke, Thumbnails)
|
||||||
|
fastify.post('/api/global-blocks/import-zip', async (request, reply) => {
|
||||||
|
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||||
|
const body = request.body;
|
||||||
|
if (!Buffer.isBuffer(body) || body.length === 0) {
|
||||||
|
return reply.code(400).send({ error: 'ZIP body fehlt oder leer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let zip: JSZip;
|
||||||
|
try {
|
||||||
|
zip = await JSZip.loadAsync(body);
|
||||||
|
} catch {
|
||||||
|
return reply.code(400).send({ error: 'Kein valides ZIP-Format' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocksFile = zip.file('blocks.json');
|
||||||
|
if (!blocksFile) {
|
||||||
|
return reply.code(400).send({ error: 'blocks.json fehlt im ZIP' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let pkg: { folders?: Array<{ id: string; name: string; parent_id: string | null }>; blocks?: Array<{ name: string; folder_id: string | null; block_data: string; thumbnail?: string | null }> };
|
||||||
|
try {
|
||||||
|
pkg = JSON.parse(await blocksFile.async('string'));
|
||||||
|
} catch {
|
||||||
|
return reply.code(400).send({ error: 'blocks.json ist kein valides JSON' });
|
||||||
|
}
|
||||||
|
if (!Array.isArray(pkg.blocks)) {
|
||||||
|
return reply.code(400).send({ error: 'blocks.json enthaelt kein blocks-Array' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: manifest pruefen (falls vorhanden)
|
||||||
|
const manifestFile = zip.file('manifest.json');
|
||||||
|
if (manifestFile) {
|
||||||
|
try {
|
||||||
|
const manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
if (manifest.format && manifest.format !== 'wcadlib-zip') {
|
||||||
|
return reply.code(400).send({ error: `Unbekanntes Paket-Format: ${manifest.format}` });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return reply.code(400).send({ error: 'manifest.json ist kein valides JSON' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordner mit ID-Mapping importieren (Eltern zuerst):
|
||||||
|
const folderIdMap = new Map<string, string>();
|
||||||
|
const sortedFolders = [...(pkg.folders ?? [])].sort((a, b) => {
|
||||||
|
if (a.parent_id === null) return -1;
|
||||||
|
if (b.parent_id === null) return 1;
|
||||||
|
if (a.parent_id === b.parent_id) return 0;
|
||||||
|
const aRes = folderIdMap.has(a.parent_id!) ? -1 : 0;
|
||||||
|
return aRes;
|
||||||
|
});
|
||||||
|
for (const f of sortedFolders) {
|
||||||
|
const newParent = f.parent_id ? folderIdMap.get(f.parent_id) ?? null : null;
|
||||||
|
const created = db.createGlobalFolder({ name: f.name, parent_id: newParent });
|
||||||
|
folderIdMap.set(f.id, created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blöcke importieren; Thumbnail aus thumbs/ lesen wenn referenziert:
|
||||||
|
let imported = 0;
|
||||||
|
for (const b of pkg.blocks) {
|
||||||
|
let svgData: string | null = null;
|
||||||
|
if (b.thumbnail) {
|
||||||
|
const thumbFile = zip.file(b.thumbnail);
|
||||||
|
if (thumbFile) {
|
||||||
|
try { svgData = await thumbFile.async('string'); } catch { svgData = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newFolderId = b.folder_id ? folderIdMap.get(b.folder_id) ?? null : null;
|
||||||
|
db.createGlobalBlock({
|
||||||
|
name: b.name,
|
||||||
|
folder_id: newFolderId,
|
||||||
|
block_data: b.block_data,
|
||||||
|
svg_data: svgData,
|
||||||
|
});
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(201).send({ imported_blocks: imported, imported_folders: folderIdMap.size });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Guests Routes – CRUD for event guests (Task E6)
|
||||||
|
*/
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
||||||
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
|
import { validateIdParam } from '../utils/validation.js';
|
||||||
|
|
||||||
|
export function registerGuestRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||||
|
// List guests for a drawing
|
||||||
|
fastify.get('/api/drawings/:drawingId/guests', async (request, reply) => {
|
||||||
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
|
const { drawingId } = request.params as { drawingId: string };
|
||||||
|
const idErr = validateIdParam(drawingId, 'drawingId');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
|
return db.listGuests(drawingId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create guest
|
||||||
|
fastify.post('/api/drawings/:drawingId/guests', async (request, reply) => {
|
||||||
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
|
const { drawingId } = request.params as { drawingId: string };
|
||||||
|
const idErr = validateIdParam(drawingId, 'drawingId');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
|
const body = request.body as { name?: string; email?: string; category?: string; seat_element_id?: string; notes?: string };
|
||||||
|
if (!body.name || typeof body.name !== 'string' || body.name.trim() === '') {
|
||||||
|
return reply.code(400).send({ error: 'name is required' });
|
||||||
|
}
|
||||||
|
const guest = db.createGuest({
|
||||||
|
drawing_id: drawingId,
|
||||||
|
name: body.name.trim(),
|
||||||
|
email: body.email,
|
||||||
|
category: body.category ?? 'standard',
|
||||||
|
seat_element_id: body.seat_element_id,
|
||||||
|
notes: body.notes,
|
||||||
|
});
|
||||||
|
return reply.code(201).send(guest);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update guest
|
||||||
|
fastify.patch('/api/guests/:id', async (request, reply) => {
|
||||||
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
|
const body = request.body as Partial<{ name: string; email: string; category: string; seat_element_id: string; notes: string }>;
|
||||||
|
const updated = db.updateGuest(id, body);
|
||||||
|
if (!updated) return reply.code(404).send({ error: 'Guest not found' });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete guest
|
||||||
|
fastify.delete('/api/guests/:id', async (request, reply) => {
|
||||||
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
|
const ok = db.deleteGuest(id);
|
||||||
|
if (!ok) return reply.code(404).send({ error: 'Guest not found' });
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,6 +22,11 @@ export interface ServerOptions {
|
|||||||
export async function createServer(opts: ServerOptions) {
|
export async function createServer(opts: ServerOptions) {
|
||||||
const fastify = Fastify({ logger: true });
|
const fastify = Fastify({ logger: true });
|
||||||
|
|
||||||
|
// ─── Task F7: binäre Body für .wcadlib ZIP-Import ───
|
||||||
|
fastify.addContentTypeParser('application/zip', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
|
||||||
|
fastify.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
|
||||||
|
fastify.addContentTypeParser('application/x-zip-compressed', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
|
||||||
|
|
||||||
// CORS — restrictive origin list
|
// CORS — restrictive origin list
|
||||||
await fastify.register(cors, {
|
await fastify.register(cors, {
|
||||||
origin: (origin: string | undefined, cb: (err: Error | null, allow: boolean) => void) => {
|
origin: (origin: string | undefined, cb: (err: Error | null, allow: boolean) => void) => {
|
||||||
@@ -66,6 +71,7 @@ export async function createServer(opts: ServerOptions) {
|
|||||||
const { registerAIRoutes } = await import('./routes/ai.js');
|
const { registerAIRoutes } = await import('./routes/ai.js');
|
||||||
const { registerNotificationRoutes } = await import('./routes/notifications.js');
|
const { registerNotificationRoutes } = await import('./routes/notifications.js');
|
||||||
const { registerShareRoutes } = await import('./routes/shares.js');
|
const { registerShareRoutes } = await import('./routes/shares.js');
|
||||||
|
const { registerGuestRoutes } = await import('./routes/guests.js');
|
||||||
const { registerGlobalBlockRoutes } = await import('./routes/globalBlocks.js');
|
const { registerGlobalBlockRoutes } = await import('./routes/globalBlocks.js');
|
||||||
const { registerProjectFolderRoutes } = await import('./routes/projectFolders.js');
|
const { registerProjectFolderRoutes } = await import('./routes/projectFolders.js');
|
||||||
|
|
||||||
@@ -103,6 +109,7 @@ export async function createServer(opts: ServerOptions) {
|
|||||||
registerNotificationRoutes(fastify, opts.db, authService);
|
registerNotificationRoutes(fastify, opts.db, authService);
|
||||||
registerShareRoutes(fastify, opts.db, authService);
|
registerShareRoutes(fastify, opts.db, authService);
|
||||||
registerGlobalBlockRoutes(fastify, opts.db, authService);
|
registerGlobalBlockRoutes(fastify, opts.db, authService);
|
||||||
|
registerGuestRoutes(fastify, opts.db, authService);
|
||||||
registerProjectFolderRoutes(fastify, opts.db, authService);
|
registerProjectFolderRoutes(fastify, opts.db, authService);
|
||||||
|
|
||||||
// Yjs collaboration WebSocket
|
// Yjs collaboration WebSocket
|
||||||
|
|||||||
@@ -381,3 +381,72 @@ describe('SqliteAdapter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── ID-Kollisionsklasse: Date.now() → randomUUID (BUG-1-Familie) ──
|
||||||
|
// Deterministischer Beweis: Date.now() wird via Fake-Timers EINGEFROREN,
|
||||||
|
// damit zwei Creates garantiert im selben Tick landen. Mit Date.now()-IDs
|
||||||
|
// kollidiert der zweite INSERT am PRIMARY KEY (UNIQUE-Exception); mit
|
||||||
|
// randomUUID sind die IDs stets eindeutig.
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
const FROZEN_NOW = 1756000000000;
|
||||||
|
|
||||||
|
describe('SqliteAdapter – auto-ID uniqueness within same tick', () => {
|
||||||
|
let db: SqliteAdapter;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(FROZEN_NOW);
|
||||||
|
db = new SqliteAdapter(':memory:');
|
||||||
|
await db.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
db.close();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createUser liefert unterschiedliche IDs bei zwei Creates im selben Tick', () => {
|
||||||
|
const a = db.createUser({ email: `tick-a-${Date.now()}@x.de`, password_hash: 'h', name: 'A' });
|
||||||
|
const b = db.createUser({ email: `tick-b-${Date.now()}@x.de`, password_hash: 'h', name: 'B' });
|
||||||
|
expect(b.id).not.toBe(a.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createProject/createProjectFolder liefern unterschiedliche IDs im selben Tick', () => {
|
||||||
|
const p1 = db.createProject({ name: 'P1' });
|
||||||
|
const p2 = db.createProject({ name: 'P2' });
|
||||||
|
expect(p2.id).not.toBe(p1.id);
|
||||||
|
const f1 = db.createProjectFolder({ name: 'F1' });
|
||||||
|
const f2 = db.createProjectFolder({ name: 'F2' });
|
||||||
|
expect(f2.id).not.toBe(f1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createDrawing/createLayer/createElement/createBlock liefern unterschiedliche IDs im selben Tick', () => {
|
||||||
|
const proj = db.createProject({ name: 'DrawHost' });
|
||||||
|
const d1 = db.createDrawing({ project_id: proj.id, name: 'D1' });
|
||||||
|
const d2 = db.createDrawing({ project_id: proj.id, name: 'D2' });
|
||||||
|
expect(d2.id).not.toBe(d1.id);
|
||||||
|
|
||||||
|
const l1 = db.createLayer({ drawing_id: d1.id, name: 'L1' });
|
||||||
|
const l2 = db.createLayer({ drawing_id: d1.id, name: 'L2' });
|
||||||
|
expect(l2.id).not.toBe(l1.id);
|
||||||
|
|
||||||
|
const e1 = db.createElement({ drawing_id: d1.id, layer_id: l1.id, type: 'line' });
|
||||||
|
const e2 = db.createElement({ drawing_id: d1.id, layer_id: l1.id, type: 'line' });
|
||||||
|
expect(e2.id).not.toBe(e1.id);
|
||||||
|
|
||||||
|
const b1 = db.createBlock({ drawing_id: d1.id, name: 'B1' });
|
||||||
|
const b2 = db.createBlock({ drawing_id: d1.id, name: 'B2' });
|
||||||
|
expect(b2.id).not.toBe(b1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createNotification/createProjectShare liefern unterschiedliche IDs im selben Tick', () => {
|
||||||
|
const proj = db.createProject({ name: 'ShareHost' });
|
||||||
|
const n1 = db.createNotification({ user_id: 'user-default', title: 'T1', message: 'M1' });
|
||||||
|
const n2 = db.createNotification({ user_id: 'user-default', title: 'T2', message: 'M2' });
|
||||||
|
expect(n2.id).not.toBe(n1.id);
|
||||||
|
|
||||||
|
const s1 = db.createProjectShare({ project_id: proj.id, shared_with_email: 'a@x.de', shared_by: 'user-default' });
|
||||||
|
const s2 = db.createProjectShare({ project_id: proj.id, shared_with_email: 'b@x.de', shared_by: 'user-default' });
|
||||||
|
expect(s2.id).not.toBe(s1.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -424,3 +424,191 @@ describe('Global Blocks API', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Task F1: Library-Paket Export/Import (.wcadlib) ──────
|
||||||
|
|
||||||
|
describe('Global Blocks API – F1 export/import', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: SqliteAdapter;
|
||||||
|
let authToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = new SqliteAdapter(':memory:');
|
||||||
|
await db.init();
|
||||||
|
app = await createServer({ db, port: 0 });
|
||||||
|
await app.ready();
|
||||||
|
const regRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/register',
|
||||||
|
payload: { email: 'f1-export@example.com', password: 'Password123!', name: 'F1 Test', role: 'admin' },
|
||||||
|
});
|
||||||
|
authToken = JSON.parse(regRes.body).session.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/global-blocks/export liefert wcadlib-Paket mit folders+blocks', async () => {
|
||||||
|
// Setup: 1 Ordner + 1 Block
|
||||||
|
const folder = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/global-folders',
|
||||||
|
headers: { authorization: `Bearer ${authToken}` },
|
||||||
|
payload: { name: 'F1-Ordner' },
|
||||||
|
});
|
||||||
|
const folderId = JSON.parse(folder.body).id;
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/global-blocks',
|
||||||
|
headers: { authorization: `Bearer ${authToken}` },
|
||||||
|
payload: { name: 'F1-Block', folder_id: folderId, block_data: '{"type":"rect"}' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/global-blocks/export',
|
||||||
|
headers: { authorization: `Bearer ${authToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const pkg = JSON.parse(res.body);
|
||||||
|
expect(pkg.format).toBe('wcadlib');
|
||||||
|
expect(Array.isArray(pkg.folders)).toBe(true);
|
||||||
|
expect(Array.isArray(pkg.blocks)).toBe(true);
|
||||||
|
expect(pkg.folders.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(pkg.blocks.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/global-blocks/import spielt Paket mit Ordner-Mapping ein', async () => {
|
||||||
|
const pkg = {
|
||||||
|
format: 'wcadlib',
|
||||||
|
version: 1,
|
||||||
|
folders: [
|
||||||
|
{ id: 'src-folder-1', name: 'Import-Ordner', parent_id: null },
|
||||||
|
],
|
||||||
|
blocks: [
|
||||||
|
{ name: 'Import-Block', folder_id: 'src-folder-1', block_data: '{"type":"circle"}', svg_data: null },
|
||||||
|
{ name: 'Import-Block-Root', folder_id: null, block_data: '{}', svg_data: null },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/global-blocks/import',
|
||||||
|
headers: { authorization: `Bearer ${authToken}` },
|
||||||
|
payload: pkg,
|
||||||
|
});
|
||||||
|
expect(res.statusCode, res.body).toBe(201);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.imported_blocks).toBe(2);
|
||||||
|
expect(body.imported_folders).toBe(1);
|
||||||
|
|
||||||
|
// Block im importierten Ordner auffindbar:
|
||||||
|
const blocks = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/global-blocks',
|
||||||
|
headers: { authorization: `Bearer ${authToken}` },
|
||||||
|
});
|
||||||
|
const list = JSON.parse(blocks.body) as Array<{ name: string; folder_id: string | null }>;
|
||||||
|
const importedBlock = list.find((b) => b.name === 'Import-Block');
|
||||||
|
expect(importedBlock).toBeDefined();
|
||||||
|
expect(importedBlock!.folder_id).not.toBeNull(); // Mapping erhalten
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Import mit falschem format liefert 400', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/global-blocks/import',
|
||||||
|
headers: { authorization: `Bearer ${authToken}` },
|
||||||
|
payload: { format: 'wrong', blocks: [] },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task F2: Block-Favoriten ─────────────────────────────
|
||||||
|
describe('Global Blocks API – F2 favorite', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: SqliteAdapter;
|
||||||
|
let authToken: string;
|
||||||
|
let blockId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = new SqliteAdapter(':memory:');
|
||||||
|
await db.init();
|
||||||
|
app = await createServer({ db, port: 0 });
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const regRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/register',
|
||||||
|
payload: { email: 'fav-test@example.com', password: 'Password123!', name: 'Fav Test', role: 'admin' },
|
||||||
|
});
|
||||||
|
authToken = JSON.parse(regRes.body).session.token;
|
||||||
|
|
||||||
|
const blockRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/global-blocks',
|
||||||
|
headers: { authorization: `Bearer ${authToken}` },
|
||||||
|
payload: { name: 'Fav-Block', block_data: '{"type":"block"}' },
|
||||||
|
});
|
||||||
|
blockId = JSON.parse(blockRes.body).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neuer Block startet mit is_favorite=false', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/global-blocks/${blockId}`,
|
||||||
|
headers: { authorization: `Bearer ${authToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).is_favorite).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH is_favorite:true setzt Favorit', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/global-blocks/${blockId}`,
|
||||||
|
headers: { authorization: `Bearer ${authToken}` },
|
||||||
|
payload: { is_favorite: true },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).is_favorite).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is_favorite ist persistent nach GET', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/global-blocks/${blockId}`,
|
||||||
|
headers: { authorization: `Bearer ${authToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).is_favorite).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH is_favorite:false hebt Favorit auf', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/global-blocks/${blockId}`,
|
||||||
|
headers: { authorization: `Bearer ${authToken}` },
|
||||||
|
payload: { is_favorite: false },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).is_favorite).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH mit nicht-boolean is_favorite liefert 400', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/global-blocks/${blockId}`,
|
||||||
|
headers: { authorization: `Bearer ${authToken}` },
|
||||||
|
payload: { is_favorite: 'yes' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Task F7 – .wcadlib ZIP-Paket Export/Import (Backend).
|
||||||
|
*
|
||||||
|
* ZIP-Struktur: manifest.json (format/version/exported_at), blocks.json
|
||||||
|
* (Ordner + Blöcke), thumbs/ (SVG-Thumbnails je Block-ID). Export liefert
|
||||||
|
* application/zip; Import parst das ZIP und spielt Ordner-Hierarchie +
|
||||||
|
* Blöcke mit ID-Mapping ein. Invalides ZIP → 400.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
||||||
|
import { createServer } from '../src/server.js';
|
||||||
|
|
||||||
|
describe('Task F7: .wcadlib ZIP Export/Import', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: SqliteAdapter;
|
||||||
|
let token: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = new SqliteAdapter(':memory:');
|
||||||
|
await db.init();
|
||||||
|
app = await createServer({ db, port: 0 });
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const regRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/register',
|
||||||
|
payload: { email: 'f7-zip@example.com', password: 'Password123!', name: 'F7 Zip', role: 'admin' },
|
||||||
|
});
|
||||||
|
token = JSON.parse(regRes.body).session.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createFixture(): Promise<{ folderId: string; blockId: string }> {
|
||||||
|
const folderRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/global-folders',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { name: 'Event Set', parent_id: null },
|
||||||
|
});
|
||||||
|
const folderId = JSON.parse(folderRes.body).id;
|
||||||
|
const blockRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/global-blocks',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: {
|
||||||
|
name: 'Stuhl-Reihe',
|
||||||
|
folder_id: folderId,
|
||||||
|
block_data: JSON.stringify([{ id: 'e1', type: 'chair', x: 0, y: 0, width: 45, height: 45, properties: {} }]),
|
||||||
|
svg_data: '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const blockId = JSON.parse(blockRes.body).id;
|
||||||
|
return { folderId, blockId };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('GET /api/global-blocks/export-zip liefert ZIP mit manifest.json, blocks.json und thumbs/', async () => {
|
||||||
|
const { blockId } = await createFixture();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/global-blocks/export-zip',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('application/zip');
|
||||||
|
|
||||||
|
const zip = await JSZip.loadAsync(res.rawPayload ?? res.body);
|
||||||
|
expect(zip.file('manifest.json')).toBeDefined();
|
||||||
|
expect(zip.file('blocks.json')).toBeDefined();
|
||||||
|
|
||||||
|
const manifest = JSON.parse(await zip.file('manifest.json')!.async('string'));
|
||||||
|
expect(manifest.format).toBe('wcadlib-zip');
|
||||||
|
expect(manifest.version).toBe(1);
|
||||||
|
expect(typeof manifest.exported_at).toBe('string');
|
||||||
|
|
||||||
|
const blocks = JSON.parse(await zip.file('blocks.json')!.async('string'));
|
||||||
|
expect(blocks.folders.length).toBe(1);
|
||||||
|
expect(blocks.folders[0].name).toBe('Event Set');
|
||||||
|
expect(blocks.blocks.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const chairBlock = blocks.blocks.find((b: { name: string }) => b.name === 'Stuhl-Reihe');
|
||||||
|
expect(chairBlock).toBeDefined();
|
||||||
|
expect(chairBlock.thumbnail).toBe(`thumbs/${blockId}.svg`);
|
||||||
|
|
||||||
|
const thumb = zip.file(`thumbs/${blockId}.svg`);
|
||||||
|
expect(thumb).toBeDefined();
|
||||||
|
expect(await thumb!.async('string')).toContain('<svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Export ohne Auth → 401', async () => {
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/global-blocks/export-zip' });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/global-blocks/import-zip spielt ZIP ein (Ordner-Mapping + Blöcke + Thumb-Referenz erhalten)', async () => {
|
||||||
|
const { blockId } = await createFixture();
|
||||||
|
const exportRes = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/global-blocks/export-zip',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(exportRes.statusCode).toBe(200);
|
||||||
|
const zipBuffer = exportRes.rawPayload ?? exportRes.body;
|
||||||
|
|
||||||
|
const importRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/global-blocks/import-zip',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/zip',
|
||||||
|
},
|
||||||
|
payload: zipBuffer,
|
||||||
|
});
|
||||||
|
expect(importRes.statusCode).toBe(201);
|
||||||
|
const body = JSON.parse(importRes.body);
|
||||||
|
expect(body.imported_blocks).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(body.imported_folders).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Verifizieren: importierter Block existiert mit Thumb-Daten
|
||||||
|
const blocksRes = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/global-blocks',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
const allBlocks = JSON.parse(blocksRes.body);
|
||||||
|
const imported = allBlocks.filter((b: { name: string }) => b.name === 'Stuhl-Reihe');
|
||||||
|
expect(imported.length).toBeGreaterThanOrEqual(2); // Original + Import
|
||||||
|
expect(imported.every((b: { svg_data: string | null }) => b.svg_data !== null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Import mit Müll-Payload → 400 (kein valides ZIP)', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/global-blocks/import-zip',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/zip',
|
||||||
|
},
|
||||||
|
payload: Buffer.from('dies ist kein zip'),
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(JSON.parse(res.body).error).toContain('ZIP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Import-ZIP ohne blocks.json → 400', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({ format: 'wcadlib-zip', version: 1 }));
|
||||||
|
const buf = await zip.generateAsync({ type: 'nodebuffer' });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/global-blocks/import-zip',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/zip',
|
||||||
|
},
|
||||||
|
payload: buf,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Task H3 – Rollen: library-admin (global write) vs. user (read-only).
|
||||||
|
*
|
||||||
|
* requireLibraryAdmin schützt alle schreibenden Global-Library-Routen:
|
||||||
|
* POST/PATCH/DELETE auf folders+blocks, import, import-zip. Lesen
|
||||||
|
* (GET, export) bleibt für alle authentifizierten Nutzer offen.
|
||||||
|
* library-admin ist an die bestehende admin-Rolle angebunden.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
||||||
|
import { createServer } from '../src/server.js';
|
||||||
|
|
||||||
|
describe('Task H3: library-admin Guard', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: SqliteAdapter;
|
||||||
|
let adminToken: string;
|
||||||
|
let planerToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = new SqliteAdapter(':memory:');
|
||||||
|
await db.init();
|
||||||
|
app = await createServer({ db, port: 0 });
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const adminReg = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/register',
|
||||||
|
payload: { email: 'h3-admin@example.com', password: 'Password123!', name: 'H3 Admin', role: 'admin' },
|
||||||
|
});
|
||||||
|
if (adminReg.statusCode !== 201) console.error('ADMIN REG FAILED:', adminReg.statusCode, adminReg.body.slice(0, 200));
|
||||||
|
adminToken = JSON.parse(adminReg.body).session?.token ?? '';
|
||||||
|
|
||||||
|
const planerReg = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/register',
|
||||||
|
payload: { email: 'h3-planer@example.com', password: 'Password123!', name: 'H3 Planer', role: 'planer' },
|
||||||
|
});
|
||||||
|
if (planerReg.statusCode !== 201) console.error('PLANER REG FAILED:', planerReg.statusCode, planerReg.body.slice(0, 200));
|
||||||
|
planerToken = JSON.parse(planerReg.body).session?.token ?? '';
|
||||||
|
admin = { authorization: `Bearer ${adminToken}` };
|
||||||
|
planer = { authorization: `Bearer ${planerToken}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// WICHTIG: Header-Objekte werden in beforeAll gebaut — der describe-Body
|
||||||
|
// läuft VOR den Hooks, Template-Literale würden undefined capturen.
|
||||||
|
let planer: Record<string, string>;
|
||||||
|
let admin: Record<string, string>;
|
||||||
|
|
||||||
|
// ── Lesen bleibt offen (beide Rollen) ──
|
||||||
|
|
||||||
|
it('planer kann globale Ordner LESEN (200)', async () => {
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/global-folders', headers: planer });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('planer kann globale Blöcke LESEN (200)', async () => {
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/global-blocks', headers: planer });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('planer kann Library EXPORTIEREN (GET export, 200)', async () => {
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/global-blocks/export', headers: planer });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Schreiben nur für library-admin (admin-Rolle) ──
|
||||||
|
|
||||||
|
it('planer POST /api/global-folders → 403', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST', url: '/api/global-folders', headers: planer,
|
||||||
|
payload: { name: 'Verboten', parent_id: null },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(JSON.parse(res.body).error).toContain('Library');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('planer POST /api/global-blocks → 403', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST', url: '/api/global-blocks', headers: planer,
|
||||||
|
payload: { name: 'Verboten', block_data: '[]' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('planer PATCH /api/global-folders/:id → 403', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH', url: '/api/global-folders/irgendeine-id', headers: planer,
|
||||||
|
payload: { name: 'Hack' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('planer PATCH /api/global-blocks/:id → 403', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH', url: '/api/global-blocks/irgendeine-id', headers: planer,
|
||||||
|
payload: { name: 'Hack' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('planer DELETE /api/global-folders/:id → 403', async () => {
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/global-folders/irgendeine-id', headers: planer });
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('planer DELETE /api/global-blocks/:id → 403', async () => {
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/global-blocks/irgendeine-id', headers: planer });
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('planer POST /api/global-blocks/import → 403', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST', url: '/api/global-blocks/import', headers: planer,
|
||||||
|
payload: { format: 'wcadlib', blocks: [] },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('planer POST /api/global-blocks/import-zip → 403', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST', url: '/api/global-blocks/import-zip', headers: { ...planer, 'content-type': 'application/zip' },
|
||||||
|
payload: Buffer.from('PK'),
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Admin (library-admin) darf schreiben ──
|
||||||
|
|
||||||
|
it('admin POST /api/global-folders → 201 (write erlaubt)', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST', url: '/api/global-folders', headers: admin,
|
||||||
|
payload: { name: 'Admin Set', parent_id: null },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin POST /api/global-blocks → 201 (write erlaubt)', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST', url: '/api/global-blocks', headers: admin,
|
||||||
|
payload: { name: 'Admin Block', block_data: '[]', folder_id: null },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Unauthentifiziert bleibt 401 ──
|
||||||
|
|
||||||
|
it('ohne Token POST /api/global-folders → 401 (nicht 403)', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST', url: '/api/global-folders',
|
||||||
|
payload: { name: 'Anon', parent_id: null },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -15,10 +15,12 @@ import CanvasArea from './components/CanvasArea';
|
|||||||
import RightSidebar from './components/RightSidebar';
|
import RightSidebar from './components/RightSidebar';
|
||||||
import CommandLine from './components/CommandLine';
|
import CommandLine from './components/CommandLine';
|
||||||
import StatusBar from './components/StatusBar';
|
import StatusBar from './components/StatusBar';
|
||||||
|
import LayoutBar from './components/LayoutBar';
|
||||||
import MobileDrawers from './components/MobileDrawers';
|
import MobileDrawers from './components/MobileDrawers';
|
||||||
import BackgroundImport from './components/BackgroundImport';
|
import BackgroundImport from './components/BackgroundImport';
|
||||||
import HistoryPanel from './components/HistoryPanel';
|
import HistoryPanel from './components/HistoryPanel';
|
||||||
import V2HistoryPanel from './components/V2HistoryPanel';
|
import V2HistoryPanel from './components/V2HistoryPanel';
|
||||||
|
import GuestPanel from './components/GuestPanel';
|
||||||
import { V2_TOOLS_ENABLED } from './kernel/featureFlags';
|
import { V2_TOOLS_ENABLED } from './kernel/featureFlags';
|
||||||
import SettingsModal from './components/SettingsModal';
|
import SettingsModal from './components/SettingsModal';
|
||||||
import InlineTextEditor from './components/InlineTextEditor';
|
import InlineTextEditor from './components/InlineTextEditor';
|
||||||
@@ -176,6 +178,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
|
|
||||||
// History panel
|
// History panel
|
||||||
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
||||||
|
const [guestPanelOpen, setGuestPanelOpen] = useState(false);
|
||||||
|
|
||||||
// Export format selector (cross-browser replacement for nwsave)
|
// Export format selector (cross-browser replacement for nwsave)
|
||||||
const [exportFormatOpen, setExportFormatOpen] = useState(false);
|
const [exportFormatOpen, setExportFormatOpen] = useState(false);
|
||||||
@@ -1148,6 +1151,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
// ─── Tools actions ───
|
// ─── Tools actions ───
|
||||||
if (action === 'measure') { setActiveTool('dimension'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Messwerkzeug aktiv', type: 'info' }]); return; }
|
if (action === 'measure') { setActiveTool('dimension'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Messwerkzeug aktiv', type: 'info' }]); return; }
|
||||||
if (action === 'history') { setHistoryPanelOpen((prev) => !prev); return; }
|
if (action === 'history') { setHistoryPanelOpen((prev) => !prev); return; }
|
||||||
|
if (action === 'guests') { setGuestPanelOpen((prev) => !prev); return; }
|
||||||
|
|
||||||
// ─── V2 tools (Task A4.13): 'v2tool:<id>' → aktives Tool umschalten. Der
|
// ─── V2 tools (Task A4.13): 'v2tool:<id>' → aktives Tool umschalten. Der
|
||||||
// bestehende CanvasArea-Sync aus A4.1 routet es an den Dispatcher (Flag aktiv).
|
// bestehende CanvasArea-Sync aus A4.1 routet es an den Dispatcher (Flag aktiv).
|
||||||
@@ -1701,6 +1705,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
history={commandHistory}
|
history={commandHistory}
|
||||||
onCommand={handleCommand}
|
onCommand={handleCommand}
|
||||||
/>
|
/>
|
||||||
|
<LayoutBar />
|
||||||
<StatusBar
|
<StatusBar
|
||||||
snapEnabled={snapEnabled}
|
snapEnabled={snapEnabled}
|
||||||
orthoEnabled={orthoEnabled}
|
orthoEnabled={orthoEnabled}
|
||||||
@@ -1762,6 +1767,15 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
onClose={() => setHistoryPanelOpen(false)}
|
onClose={() => setHistoryPanelOpen(false)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{guestPanelOpen && drawingId && (
|
||||||
|
<div className="guest-panel-overlay" style={{ position: 'fixed', top: 60, right: 12, width: 300, maxHeight: '70vh', background: 'var(--bg-secondary, #1e1e2e)', color: 'var(--fg-primary, #e0e0e0)', border: '1px solid rgba(128,128,128,0.3)', borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.4)', zIndex: 1000, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<GuestPanel
|
||||||
|
token={token}
|
||||||
|
drawingId={drawingId}
|
||||||
|
onClose={() => setGuestPanelOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{exportFormatOpen && (
|
{exportFormatOpen && (
|
||||||
<div className="export-format-overlay" onClick={() => setExportFormatOpen(false)}>
|
<div className="export-format-overlay" onClick={() => setExportFormatOpen(false)}>
|
||||||
<div className="export-format-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="export-format-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -104,6 +104,16 @@ export class SnapEngine {
|
|||||||
if (this.config.modes.has('nearest')) {
|
if (this.config.modes.has('nearest')) {
|
||||||
this.collectNearest(el, worldX, worldY, tol, candidates);
|
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)
|
// Intersection snap (between pairs)
|
||||||
@@ -124,7 +134,8 @@ export class SnapEngine {
|
|||||||
|
|
||||||
// Priority: endpoint > intersection > center > midpoint > nearest > grid
|
// Priority: endpoint > intersection > center > midpoint > nearest > grid
|
||||||
const priority: Record<string, number> = {
|
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
|
// 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 {
|
private collectNearest(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
||||||
const p = el.properties;
|
const p = el.properties;
|
||||||
const check = (x: number, y: number) => {
|
const check = (x: number, y: number) => {
|
||||||
@@ -400,3 +484,13 @@ export class SnapEngine {
|
|||||||
return { x: snapX, y: snapY, type: 'nearest' };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ export class ZoomPanController {
|
|||||||
this.offsetY = -minY * this.scale + padding;
|
this.offsetY = -minY * this.scale + padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Absolute Ansicht: Welt-Punkt `center` landet exakt im
|
||||||
|
* Canvas-Zentrum bei `scale` (Task G2 — Layout zoom-to).
|
||||||
|
*/
|
||||||
|
setView(center: { x: number; y: number }, scale: number): void {
|
||||||
|
const s = Math.max(0.01, Math.min(100, scale));
|
||||||
|
this.scale = s;
|
||||||
|
this.offsetX = this.canvas.width / 2 - center.x * s;
|
||||||
|
this.offsetY = this.canvas.height / 2 - center.y * s;
|
||||||
|
}
|
||||||
|
|
||||||
zoomToRect(rect: { minX: number; minY: number; maxX: number; maxY: number }): void {
|
zoomToRect(rect: { minX: number; minY: number; maxX: number; maxY: number }): void {
|
||||||
const padding = 20;
|
const padding = 20;
|
||||||
const w = rect.maxX - rect.minX;
|
const w = rect.maxX - rect.minX;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import type { GlobalBlockFolder, GlobalBlock } from '../services/api';
|
import type { GlobalBlockFolder, GlobalBlock } from '../services/api';
|
||||||
|
import { importSVGAsBlockPayload } from '../services/importService';
|
||||||
import {
|
import {
|
||||||
getGlobalFolders,
|
getGlobalFolders,
|
||||||
createGlobalFolder,
|
createGlobalFolder,
|
||||||
@@ -9,7 +10,15 @@ import {
|
|||||||
createGlobalBlock,
|
createGlobalBlock,
|
||||||
renameGlobalBlock,
|
renameGlobalBlock,
|
||||||
deleteGlobalBlock,
|
deleteGlobalBlock,
|
||||||
|
setGlobalBlockFavorite,
|
||||||
|
exportLibrary,
|
||||||
|
importLibrary,
|
||||||
|
exportLibraryZip,
|
||||||
|
importLibraryZip,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
import { getActiveDocument } from '../kernel/document/documentService';
|
||||||
|
import { generateBlockSvg } from '../utils/blockThumbnail';
|
||||||
|
import type { CADElement } from '../types/cad.types';
|
||||||
|
|
||||||
interface BlockLibraryTreeProps {
|
interface BlockLibraryTreeProps {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -38,8 +47,23 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const wcadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const zipInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
|
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ─── Task F2: Suche, Favoriten, Thumbnail-Grid ───────────
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [viewMode, setViewMode] = useState<'tree' | 'grid'>('tree');
|
||||||
|
|
||||||
|
const handleToggleFavorite = useCallback(async (blockId: string, next: boolean) => {
|
||||||
|
try {
|
||||||
|
const updated = await setGlobalBlockFavorite(token, blockId, next);
|
||||||
|
setBlocks(prev => prev.map(b => (b.id === blockId ? { ...b, is_favorite: updated.is_favorite } : b)));
|
||||||
|
} catch {
|
||||||
|
// silently ignore — favorite stays unchanged on failure
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [allFolders, allBlocks] = await Promise.all([
|
const [allFolders, allBlocks] = await Promise.all([
|
||||||
@@ -59,6 +83,99 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
|||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
|
// ─── Task F3: Auswahl als Block speichern ─────────────────
|
||||||
|
const handleSaveSelectionAsBlock = useCallback(async () => {
|
||||||
|
const bridge = (window as unknown as { __v2GetSelection?: () => { ids: string[] } }).__v2GetSelection;
|
||||||
|
const ids = bridge?.().ids ?? [];
|
||||||
|
if (ids.length === 0) {
|
||||||
|
window.alert('Keine Auswahl im Canvas — erst Elemente wählen (V2-Tools).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const doc = getActiveDocument();
|
||||||
|
const elements: CADElement[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const el = doc?.getElement(id);
|
||||||
|
if (el) elements.push(el);
|
||||||
|
}
|
||||||
|
if (elements.length === 0) {
|
||||||
|
window.alert('Auswahl enthält keine Elemente.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = window.prompt('Blockname:', `Block-${new Date().toISOString().slice(0, 10)}`);
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
try {
|
||||||
|
await createGlobalBlock(token, {
|
||||||
|
name: name.trim(),
|
||||||
|
block_data: JSON.stringify(elements),
|
||||||
|
svg_data: generateBlockSvg(elements),
|
||||||
|
});
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(`Speichern fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}, [token, loadData]);
|
||||||
|
|
||||||
|
// ─── Task F1: Library-Paket Export/Import ────────────────
|
||||||
|
|
||||||
|
const handleZipExport = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const blob = await exportLibraryZip(token);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `web-cad-library-${new Date().toISOString().slice(0, 10)}.wcadlib.zip`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`ZIP-Export fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const handleZipImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const result = await importLibraryZip(token, file);
|
||||||
|
alert(`Bibliothek importiert: ${result.imported_blocks} Blöcke, ${result.imported_folders} Ordner`);
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`ZIP-Import fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
}, [token, loadData]);
|
||||||
|
|
||||||
|
const handleLibraryExport = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const pkg = await exportLibrary(token);
|
||||||
|
const blob = new Blob([JSON.stringify(pkg, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `web-cad-library-${new Date().toISOString().slice(0, 10)}.wcadlib`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Library-Export fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const handleWcadFile = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const pkg = JSON.parse(text);
|
||||||
|
const result = await importLibrary(token, pkg);
|
||||||
|
alert(`Bibliothek importiert: ${result.imported_blocks} Blöcke, ${result.imported_folders} Ordner`);
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Library-Import fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
}, [token, loadData]);
|
||||||
|
|
||||||
// Build tree structure from flat lists
|
// Build tree structure from flat lists
|
||||||
const buildTree = useCallback((): FolderNode[] => {
|
const buildTree = useCallback((): FolderNode[] => {
|
||||||
const folderMap = new Map<string, FolderNode>();
|
const folderMap = new Map<string, FolderNode>();
|
||||||
@@ -92,7 +209,10 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
|||||||
return roots;
|
return roots;
|
||||||
}, [folders, blocks, expandedIds]);
|
}, [folders, blocks, expandedIds]);
|
||||||
|
|
||||||
const rootBlocks = blocks.filter(b => !b.folder_id).sort((a, b) => a.name.localeCompare(b.name));
|
const q = searchQuery.toLowerCase().trim();
|
||||||
|
const matchesSearch = useCallback((b: GlobalBlock) => !q || b.name.toLowerCase().includes(q), [q]);
|
||||||
|
const rootBlocks = blocks.filter(b => !b.folder_id && matchesSearch(b)).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const favoriteBlocks = blocks.filter(b => b.is_favorite && matchesSearch(b)).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
const tree = buildTree();
|
const tree = buildTree();
|
||||||
|
|
||||||
const toggleExpand = (folderId: string) => {
|
const toggleExpand = (folderId: string) => {
|
||||||
@@ -186,10 +306,11 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
|||||||
reader.onload = async (ev) => {
|
reader.onload = async (ev) => {
|
||||||
const svgContent = ev.target?.result as string;
|
const svgContent = ev.target?.result as string;
|
||||||
try {
|
try {
|
||||||
|
const svgPayload = importSVGAsBlockPayload(svgContent);
|
||||||
const created = await createGlobalBlock(token, {
|
const created = await createGlobalBlock(token, {
|
||||||
name: file.name.replace(/\.svg$/i, ''),
|
name: file.name.replace(/\.svg$/i, ''),
|
||||||
folder_id: folderId,
|
folder_id: folderId,
|
||||||
block_data: JSON.stringify({ type: 'svg', svg: svgContent }),
|
block_data: svgPayload.blockData ?? JSON.stringify({ type: 'svg', svg: svgContent }),
|
||||||
svg_data: svgContent,
|
svg_data: svgContent,
|
||||||
});
|
});
|
||||||
setBlocks(prev => [...prev, created]);
|
setBlocks(prev => [...prev, created]);
|
||||||
@@ -224,9 +345,10 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
|||||||
reader.onload = async (ev) => {
|
reader.onload = async (ev) => {
|
||||||
const svgContent = ev.target?.result as string;
|
const svgContent = ev.target?.result as string;
|
||||||
try {
|
try {
|
||||||
|
const svgPayload = importSVGAsBlockPayload(svgContent);
|
||||||
const created = await createGlobalBlock(token, {
|
const created = await createGlobalBlock(token, {
|
||||||
name: file.name.replace(/\.svg$/i, ''),
|
name: file.name.replace(/\.svg$/i, ''),
|
||||||
block_data: JSON.stringify({ type: 'svg', svg: svgContent }),
|
block_data: svgPayload.blockData ?? JSON.stringify({ type: 'svg', svg: svgContent }),
|
||||||
svg_data: svgContent,
|
svg_data: svgContent,
|
||||||
});
|
});
|
||||||
setBlocks(prev => [...prev, created]);
|
setBlocks(prev => [...prev, created]);
|
||||||
@@ -271,6 +393,83 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
|||||||
setRenameValue('');
|
setRenameValue('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Task F2: Block-Leaf mit Thumbnail + Favorite-Stern ──
|
||||||
|
const renderBlockLeaf = (block: GlobalBlock, depth: number): React.ReactNode => {
|
||||||
|
const isBlockRenaming = renamingId === block.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={block.id}
|
||||||
|
className="tree-item tree-item-leaf draggable"
|
||||||
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleBlockDragStart(e, block)}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, 'block', block.id, block.folder_id)}
|
||||||
|
title={block.name}
|
||||||
|
>
|
||||||
|
{block.svg_data ? (
|
||||||
|
<span className="tree-thumb" dangerouslySetInnerHTML={{ __html: block.svg_data }} />
|
||||||
|
) : (
|
||||||
|
<span className="tree-icon">📦</span>
|
||||||
|
)}
|
||||||
|
{isBlockRenaming ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="tree-rename-input"
|
||||||
|
value={renameValue}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onBlur={confirmRename}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') confirmRename();
|
||||||
|
if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); }
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="tree-label"
|
||||||
|
onDoubleClick={(e) => { e.stopPropagation(); startRename(block.id, block.name); }}
|
||||||
|
>
|
||||||
|
{block.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`tree-fav-btn${block.is_favorite ? ' active' : ''}`}
|
||||||
|
title={block.is_favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
|
onClick={(e) => { e.stopPropagation(); void handleToggleFavorite(block.id, !block.is_favorite); }}
|
||||||
|
>
|
||||||
|
{block.is_favorite ? '★' : '☆'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Task F2: Grid-Zelle mit Thumbnail
|
||||||
|
const renderGridCell = (block: GlobalBlock): React.ReactNode => (
|
||||||
|
<div
|
||||||
|
key={block.id}
|
||||||
|
className="lib-grid-cell"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleBlockDragStart(e, block)}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, 'block', block.id, block.folder_id)}
|
||||||
|
title={`${block.name}${block.is_favorite ? ' ★' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="lib-grid-thumb">
|
||||||
|
{block.svg_data
|
||||||
|
? <span className="tree-thumb" dangerouslySetInnerHTML={{ __html: block.svg_data }} />
|
||||||
|
: <span className="lib-grid-placeholder">📦</span>}
|
||||||
|
</div>
|
||||||
|
<div className="lib-grid-name">{block.name}</div>
|
||||||
|
<button
|
||||||
|
className={`tree-fav-btn${block.is_favorite ? ' active' : ''}`}
|
||||||
|
title={block.is_favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
|
onClick={(e) => { e.stopPropagation(); void handleToggleFavorite(block.id, !block.is_favorite); }}
|
||||||
|
>
|
||||||
|
{block.is_favorite ? '★' : '☆'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const renderFolderNode = (node: FolderNode, depth: number): React.ReactNode => {
|
const renderFolderNode = (node: FolderNode, depth: number): React.ReactNode => {
|
||||||
const isRenaming = renamingId === node.folder.id;
|
const isRenaming = renamingId === node.folder.id;
|
||||||
const isDragOver = dragOverFolderId === node.folder.id;
|
const isDragOver = dragOverFolderId === node.folder.id;
|
||||||
@@ -314,44 +513,7 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
|||||||
{node.expanded && (
|
{node.expanded && (
|
||||||
<div className="tree-children">
|
<div className="tree-children">
|
||||||
{node.children.map(child => renderFolderNode(child, depth + 1))}
|
{node.children.map(child => renderFolderNode(child, depth + 1))}
|
||||||
{node.blocks.map(block => {
|
{node.blocks.filter(matchesSearch).map(block => renderBlockLeaf(block, depth + 1))}
|
||||||
const isBlockRenaming = renamingId === block.id;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={block.id}
|
|
||||||
className="tree-item tree-item-leaf draggable"
|
|
||||||
style={{ paddingLeft: `${(depth + 1) * 16 + 8}px` }}
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => handleBlockDragStart(e, block)}
|
|
||||||
onContextMenu={(e) => handleContextMenu(e, 'block', block.id, block.folder_id)}
|
|
||||||
title={block.name}
|
|
||||||
>
|
|
||||||
<span className="tree-icon">📦</span>
|
|
||||||
{isBlockRenaming ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="tree-rename-input"
|
|
||||||
value={renameValue}
|
|
||||||
autoFocus
|
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
|
||||||
onBlur={confirmRename}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') confirmRename();
|
|
||||||
if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); }
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="tree-label"
|
|
||||||
onDoubleClick={(e) => { e.stopPropagation(); startRename(block.id, block.name); }}
|
|
||||||
>
|
|
||||||
{block.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -366,6 +528,41 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
|||||||
<div className="global-lib-tree" onContextMenu={(e) => handleContextMenu(e, 'root', null, null)}>
|
<div className="global-lib-tree" onContextMenu={(e) => handleContextMenu(e, 'root', null, null)}>
|
||||||
<div className="global-lib-header">
|
<div className="global-lib-header">
|
||||||
<span className="global-lib-title">🌐 Globale Bibliothek</span>
|
<span className="global-lib-title">🌐 Globale Bibliothek</span>
|
||||||
|
<button
|
||||||
|
className="global-lib-add-btn"
|
||||||
|
title="Canvas-Auswahl als Block speichern (F3)"
|
||||||
|
onClick={() => void handleSaveSelectionAsBlock()}
|
||||||
|
>
|
||||||
|
💾
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="global-lib-add-btn"
|
||||||
|
title="Bibliothek als .wcadlib exportieren"
|
||||||
|
onClick={() => void handleLibraryExport()}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 17 17 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="global-lib-add-btn"
|
||||||
|
title=".wcadlib-Bibliothek importieren"
|
||||||
|
onClick={() => wcadInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="global-lib-add-btn"
|
||||||
|
title="Bibliothek als ZIP-Paket exportieren (Task F7)"
|
||||||
|
onClick={handleZipExport}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 8v13H3V8"/><rect x="1" y="3" width="22" height="5"/><path d="M10 12h4"/></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="global-lib-add-btn"
|
||||||
|
title=".wcadlib-ZIP-Paket importieren (Task F7)"
|
||||||
|
onClick={() => zipInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 8v13H3V8"/><rect x="1" y="3" width="22" height="5"/><path d="M15 12l-3-3-3 3"/><path d="M12 9v8"/></svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="global-lib-add-btn"
|
className="global-lib-add-btn"
|
||||||
title="SVG in globale Bibliothek importieren"
|
title="SVG in globale Bibliothek importieren"
|
||||||
@@ -374,45 +571,48 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
|||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<input ref={fileInputRef} type="file" accept=".svg,image/svg+xml" style={{ display: 'none' }} onChange={handleSvgImport} />
|
<input ref={fileInputRef} type="file" accept=".svg,image/svg+xml" style={{ display: 'none' }} onChange={handleSvgImport} />
|
||||||
|
<input ref={wcadInputRef} type="file" accept=".wcadlib,application/json" style={{ display: 'none' }} onChange={handleWcadFile} />
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip,.wcadlib.zip,application/zip"
|
||||||
|
ref={zipInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleZipImport}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="tree tree-global" role="tree" aria-label="Globale Block-Bibliothek">
|
<div className="global-lib-toolbar">
|
||||||
{tree.map(node => renderFolderNode(node, 0))}
|
|
||||||
{rootBlocks.map(block => {
|
|
||||||
const isBlockRenaming = renamingId === block.id;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={block.id}
|
|
||||||
className="tree-item tree-item-leaf draggable"
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => handleBlockDragStart(e, block)}
|
|
||||||
onContextMenu={(e) => handleContextMenu(e, 'block', block.id, null)}
|
|
||||||
title={block.name}
|
|
||||||
>
|
|
||||||
<span className="tree-icon">📦</span>
|
|
||||||
{isBlockRenaming ? (
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="tree-rename-input"
|
className="global-lib-search"
|
||||||
value={renameValue}
|
placeholder="Blöcke suchen…"
|
||||||
autoFocus
|
value={searchQuery}
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onBlur={confirmRename}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') confirmRename();
|
|
||||||
if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); }
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
<button
|
||||||
<span
|
className="global-lib-add-btn"
|
||||||
className="tree-label"
|
title={viewMode === 'grid' ? 'Baumansicht' : 'Rasteransicht'}
|
||||||
onDoubleClick={(e) => { e.stopPropagation(); startRename(block.id, block.name); }}
|
onClick={() => setViewMode(m => (m === 'grid' ? 'tree' : 'grid'))}
|
||||||
>
|
>
|
||||||
{block.name}
|
{viewMode === 'grid' ? '☰' : '▦'}
|
||||||
</span>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="tree tree-global" role="tree" aria-label="Globale Block-Bibliothek">
|
||||||
})}
|
{favoriteBlocks.length > 0 && (
|
||||||
|
<div className="lib-fav-section">
|
||||||
|
<div className="lib-fav-title">★ Favoriten</div>
|
||||||
|
{favoriteBlocks.map(block => renderBlockLeaf(block, 0))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{viewMode === 'grid' ? (
|
||||||
|
<div className="lib-grid">
|
||||||
|
{[...favoriteBlocks, ...blocks.filter(b => !b.is_favorite && matchesSearch(b)).sort((a, b) => a.name.localeCompare(b.name))].map(renderGridCell)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{tree.map(node => renderFolderNode(node, 0))}
|
||||||
|
{rootBlocks.map(block => renderBlockLeaf(block, 0))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{folders.length === 0 && blocks.length === 0 && (
|
{folders.length === 0 && blocks.length === 0 && (
|
||||||
<div className="global-lib-empty">Globale Bibliothek ist leer — Rechtsklick für Ordner</div>
|
<div className="global-lib-empty">Globale Bibliothek ist leer — Rechtsklick für Ordner</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -155,6 +155,27 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Task G2: Layout-Bridge für LayoutBar (zoom-to + Viewport-Save) ──
|
||||||
|
(window as unknown as { __v2LayoutBridge: unknown }).__v2LayoutBridge = {
|
||||||
|
zoomTo: (center: { x: number; y: number }, scale: number) => {
|
||||||
|
zoomPan.setView(center, scale);
|
||||||
|
renderEngineRef.current?.render();
|
||||||
|
},
|
||||||
|
getView: () => {
|
||||||
|
const vp = zoomPan.getViewport();
|
||||||
|
return {
|
||||||
|
center: { x: (vp.minX + vp.maxX) / 2, y: (vp.minY + vp.maxY) / 2 },
|
||||||
|
scale: zoomPan.getScale(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Task E6b: Selection-Bridge für GuestPanel (Stuhl-Zuweisung) ──
|
||||||
|
(window as unknown as { __v2GetSelection: unknown }).__v2GetSelection = () => {
|
||||||
|
const ids = Array.from(selectionEngine.getSelectedIds());
|
||||||
|
return { ids };
|
||||||
|
};
|
||||||
|
|
||||||
// ── Task C3fin: Snap-Marks + Selection-Handles im Pixi-Overlay ──
|
// ── Task C3fin: Snap-Marks + Selection-Handles im Pixi-Overlay ──
|
||||||
if (pixi && USE_PIXI_RENDERER) {
|
if (pixi && USE_PIXI_RENDERER) {
|
||||||
// Snap-Marks bei Mausbewegung (wenn ein V2-Tool aktiv ist):
|
// Snap-Marks bei Mausbewegung (wenn ein V2-Tool aktiv ist):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||||
import type { CommandLineProps } from '../types/ui.types';
|
import type { CommandLineProps } from '../types/ui.types';
|
||||||
import { getCommandRegistry, type CommandDefinition } from '../services/commandRegistry';
|
import { getCommandRegistry, type CommandDefinition } from '../services/commandRegistry';
|
||||||
|
import { useUIMode } from '../services/uiModeService';
|
||||||
|
|
||||||
const defaultHistory = [
|
const defaultHistory = [
|
||||||
{ prefix: '·' as const, text: 'Bereit · Werkzeug: Auswahl · 110 Objekte · 5 Ebenen', type: 'info' as const },
|
{ prefix: '·' as const, text: 'Bereit · Werkzeug: Auswahl · 110 Objekte · 5 Ebenen', type: 'info' as const },
|
||||||
@@ -20,6 +21,9 @@ const categoryColors: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CommandLine: React.FC<CommandLineProps> = ({ history, onCommand }) => {
|
const CommandLine: React.FC<CommandLineProps> = ({ history, onCommand }) => {
|
||||||
|
const uiMode = useUIMode();
|
||||||
|
if (uiMode === 'simple') return null;
|
||||||
|
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
|
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
|
||||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* GuestPanel (Task E6) — Gäste-Verwaltung für Event-Bestuhlung.
|
||||||
|
* Liste + Suche + Hinzufügen + Löschen. Zuweisung: seat_element_id
|
||||||
|
* wird per API gesetzt (Klick auf Stuhl im Canvas folgt in E6b).
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { listGuests, createGuest, deleteGuest, updateGuest, type Guest } from '../services/api';
|
||||||
|
|
||||||
|
/** E6b: Liest die erste Canvas-Auswahl (via CanvasArea-Bridge). */
|
||||||
|
function getFirstSelectedId(): string | null {
|
||||||
|
const bridge = (window as unknown as { __v2GetSelection?: () => { ids: string[] } }).__v2GetSelection;
|
||||||
|
const ids = bridge?.().ids ?? [];
|
||||||
|
return ids[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GuestPanelProps {
|
||||||
|
token: string;
|
||||||
|
drawingId: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GuestPanel: React.FC<GuestPanelProps> = ({ token, drawingId, onClose }) => {
|
||||||
|
const [guests, setGuests] = useState<Guest[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [newEmail, setNewEmail] = useState('');
|
||||||
|
const [newCategory, setNewCategory] = useState('standard');
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setGuests(await listGuests(token, drawingId));
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Fehler beim Laden');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, drawingId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (newName.trim() === '') return;
|
||||||
|
try {
|
||||||
|
await createGuest(token, drawingId, { name: newName.trim(), email: newEmail.trim() || undefined, category: newCategory });
|
||||||
|
setNewName('');
|
||||||
|
setNewEmail('');
|
||||||
|
setNewCategory('standard');
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Fehler beim Hinzufügen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteGuest(token, id);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Fehler beim Löschen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssign = async (g: Guest) => {
|
||||||
|
try {
|
||||||
|
if (g.seat_element_id) {
|
||||||
|
await updateGuest(token, g.id, { seat_element_id: '' });
|
||||||
|
} else {
|
||||||
|
const seatId = getFirstSelectedId();
|
||||||
|
if (!seatId) {
|
||||||
|
setError('Erst einen Stuhl im Canvas auswählen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updateGuest(token, g.id, { seat_element_id: seatId });
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Fehler bei Zuweisung');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = guests.filter((g) =>
|
||||||
|
g.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(g.email ?? '').toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
const assigned = guests.filter((g) => g.seat_element_id).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="guest-panel" style={{ padding: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<strong>Gäste ({guests.length})</strong>
|
||||||
|
<span style={{ fontSize: 12, opacity: 0.7 }}>{assigned} zugewiesen</span>
|
||||||
|
{onClose && <button onClick={onClose} aria-label="Schließen">×</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Suchen…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{ padding: '4px 8px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<input
|
||||||
|
placeholder="Name"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') void handleAdd(); }}
|
||||||
|
style={{ flex: 1, padding: '4px 8px' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="E-Mail (optional)"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
|
style={{ flex: 1, padding: '4px 8px' }}
|
||||||
|
/>
|
||||||
|
<select value={newCategory} onChange={(e) => setNewCategory(e.target.value)} style={{ width: 90 }}>
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
<option value="vip">VIP</option>
|
||||||
|
<option value="staff">Staff</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={() => void handleAdd()} disabled={newName.trim() === ''}>+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={{ color: '#e5484d', fontSize: 12 }}>{error}</div>}
|
||||||
|
{loading && <div style={{ opacity: 0.6 }}>Lädt…</div>}
|
||||||
|
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, overflowY: 'auto', flex: 1 }}>
|
||||||
|
{filtered.map((g) => (
|
||||||
|
<li
|
||||||
|
key={g.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: g.category === 'vip' ? 700 : 400 }}>
|
||||||
|
{g.category === 'vip' ? '★ ' : ''}{g.name}
|
||||||
|
</div>
|
||||||
|
{g.email && <div style={{ fontSize: 11, opacity: 0.6 }}>{g.email}</div>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleAssign(g)}
|
||||||
|
title={g.seat_element_id ? 'Zuweisung aufheben' : 'Erste Canvas-Auswahl als Sitzplatz zuweisen'}
|
||||||
|
style={{ opacity: g.seat_element_id ? 1 : 0.6 }}
|
||||||
|
>💺</button>
|
||||||
|
{g.seat_element_id && (
|
||||||
|
<span style={{ fontSize: 11, opacity: 0.7 }} title="Sitzplatz zugewiesen">✓</span>
|
||||||
|
)}
|
||||||
|
<button onClick={() => void handleDelete(g.id)} aria-label={`Lösche ${g.name}`}>🗑</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<li style={{ opacity: 0.5, padding: 8 }}>Keine Gäste gefunden.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GuestPanel;
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Task G2 – LayoutBar: Layout-Verwaltung + Viewport-Minimap-Widget.
|
||||||
|
*
|
||||||
|
* - Listet Layouts des aktiven Dokuments (documentService-Fallback)
|
||||||
|
* - Klick auf Eintrag = zoom-to gespeichertem Viewport (Bridge)
|
||||||
|
* - „+“ speichert aktuellen Viewport als neues Layout
|
||||||
|
* - „🗑“ löscht Layout; je Eintrag Minimap (Modellbereich + Viewport-Rechteck)
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { LayoutDefinition } from '../types/cad.types';
|
||||||
|
import { getActiveDocument, onActiveDocumentChanged } from '../kernel/document/documentService';
|
||||||
|
import { insertTitleBlockForLayout } from '../services/titleBlockService';
|
||||||
|
import { printLayout } from '../services/printService';
|
||||||
|
import { useT } from '../i18n/strings';
|
||||||
|
|
||||||
|
export interface LayoutView {
|
||||||
|
center: { x: number; y: number };
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutBridge {
|
||||||
|
zoomTo(center: { x: number; y: number }, scale: number): void;
|
||||||
|
getView(): LayoutView;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutBarProps {
|
||||||
|
/** Aktives Dokument (Fallback: documentService, Task G2 UI-Unabhängigkeit). */
|
||||||
|
doc?: {
|
||||||
|
getAllLayouts(): LayoutDefinition[];
|
||||||
|
addLayout(l: LayoutDefinition): void;
|
||||||
|
deleteLayout(id: string): void;
|
||||||
|
getAllElements?(): Array<{ x: number; y: number; width: number; height: number }>;
|
||||||
|
onChanged?(cb: () => void): () => void;
|
||||||
|
} | null;
|
||||||
|
/** Canvas-Bridge (Fallback: window.__v2LayoutBridge aus CanvasArea). */
|
||||||
|
bridge?: LayoutBridge | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Rect { x: number; y: number; w: number; h: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimap-Geometrie (Task G2, rein & getestet):
|
||||||
|
* Modell-BBox füllt das Widget (Stretch mit 6px Padding); ohne Elemente
|
||||||
|
* Volles Widget. Viewport-Rechteck = nominale Ansicht 800×600/scale Welt.
|
||||||
|
*/
|
||||||
|
export function computeMinimapRects(
|
||||||
|
elements: Array<{ x: number; y: number; width: number; height: number }>,
|
||||||
|
view: LayoutView,
|
||||||
|
widgetW: number,
|
||||||
|
widgetH: number,
|
||||||
|
): { model: Rect; view: Rect } {
|
||||||
|
if (!Array.isArray(elements) || elements.length === 0) {
|
||||||
|
return { model: { x: 0, y: 0, w: widgetW, h: widgetH }, view: { x: 0, y: 0, w: widgetW, h: widgetH } };
|
||||||
|
}
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (const el of elements) {
|
||||||
|
minX = Math.min(minX, el.x - el.width / 2);
|
||||||
|
minY = Math.min(minY, el.y - el.height / 2);
|
||||||
|
maxX = Math.max(maxX, el.x + el.width / 2);
|
||||||
|
maxY = Math.max(maxY, el.y + el.height / 2);
|
||||||
|
}
|
||||||
|
if (!isFinite(minX) || maxX - minX <= 0 || maxY - minY <= 0) {
|
||||||
|
return { model: { x: 0, y: 0, w: widgetW, h: widgetH }, view: { x: 0, y: 0, w: widgetW, h: widgetH } };
|
||||||
|
}
|
||||||
|
const PAD = 6;
|
||||||
|
const model: Rect = { x: PAD, y: PAD, w: widgetW - 2 * PAD, h: widgetH - 2 * PAD };
|
||||||
|
const wx = (w: number) => model.x + ((w - minX) / (maxX - minX)) * model.w;
|
||||||
|
const wy = (w: number) => model.y + ((w - minY) / (maxY - minY)) * model.h;
|
||||||
|
const viewW = 800 / view.scale;
|
||||||
|
const viewH = 600 / view.scale;
|
||||||
|
const viewRect: Rect = {
|
||||||
|
x: wx(view.center.x - viewW / 2),
|
||||||
|
y: wy(view.center.y - viewH / 2),
|
||||||
|
w: (viewW / (maxX - minX)) * model.w,
|
||||||
|
h: (viewH / (maxY - minY)) * model.h,
|
||||||
|
};
|
||||||
|
return { model, view: viewRect };
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIDGET_W = 100;
|
||||||
|
const WIDGET_H = 80;
|
||||||
|
|
||||||
|
const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp }) => {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
const [doc, setDoc] = useState(docProp ?? getActiveDocument());
|
||||||
|
const [layouts, setLayouts] = useState<LayoutDefinition[]>([]);
|
||||||
|
|
||||||
|
const bridge: LayoutBridge | null = bridgeProp ??
|
||||||
|
((typeof window !== 'undefined' ? (window as unknown as { __v2LayoutBridge?: LayoutBridge }).__v2LayoutBridge : undefined) ?? null);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
setLayouts(doc ? doc.getAllLayouts() : []);
|
||||||
|
}, [doc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
// Live-Updates (auch Remote via CRDT), falls das Doc es anbietet
|
||||||
|
const off = doc?.onChanged?.(reload);
|
||||||
|
return () => { if (off) off(); };
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
// Fallback-Verdrahtung: aktives Dokument aus dem documentService
|
||||||
|
useEffect(() => {
|
||||||
|
if (docProp) return;
|
||||||
|
setDoc(getActiveDocument());
|
||||||
|
const off = onActiveDocumentChanged(() => setDoc(getActiveDocument()));
|
||||||
|
return off;
|
||||||
|
}, [docProp]);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!bridge || !doc) return;
|
||||||
|
const view = bridge.getView();
|
||||||
|
doc.addLayout({
|
||||||
|
id: `layout-${Date.now()}`,
|
||||||
|
name: `Layout ${layouts.length + 1}`,
|
||||||
|
viewport: { center: { ...view.center }, scale: view.scale },
|
||||||
|
});
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
doc?.deleteLayout(id);
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
void printLayout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomTo = (l: LayoutDefinition) => {
|
||||||
|
bridge?.zoomTo(l.viewport.center, l.viewport.scale);
|
||||||
|
};
|
||||||
|
|
||||||
|
const elements = doc?.getAllElements?.() ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layout-bar" data-testid="layout-bar">
|
||||||
|
<div className="layout-bar-header">
|
||||||
|
<span className="layout-bar-title">{t('layouts.title')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="layout-bar-add"
|
||||||
|
title={t('layouts.add')}
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!bridge || !doc}
|
||||||
|
>+</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="layout-bar-print screen-only"
|
||||||
|
title={t('layouts.print')}
|
||||||
|
onClick={handlePrint}
|
||||||
|
>🖨</button>
|
||||||
|
</div>
|
||||||
|
{layouts.length === 0 ? (
|
||||||
|
<div className="layout-bar-empty">{t('layouts.empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="layout-bar-list">
|
||||||
|
{layouts.map((l) => {
|
||||||
|
const rects = computeMinimapRects(elements, l.viewport, WIDGET_W, WIDGET_H);
|
||||||
|
return (
|
||||||
|
<div key={l.id} className="layout-bar-item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="layout-bar-entry"
|
||||||
|
data-testid={`layout-entry-${l.id}`}
|
||||||
|
title={t('layouts.zoomto', l.name)}
|
||||||
|
onClick={() => handleZoomTo(l)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
data-testid={`layout-minimap-${l.id}`}
|
||||||
|
width={WIDGET_W}
|
||||||
|
height={WIDGET_H}
|
||||||
|
viewBox={`0 0 ${WIDGET_W} ${WIDGET_H}`}
|
||||||
|
className="layout-minimap"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={rects.model.x} y={rects.model.y}
|
||||||
|
width={rects.model.w} height={rects.model.h}
|
||||||
|
fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.25)" strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={rects.view.x} y={rects.view.y}
|
||||||
|
width={Math.max(2, rects.view.w)} height={Math.max(2, rects.view.h)}
|
||||||
|
fill="rgba(74,144,217,0.25)" stroke="#4a90d9" strokeWidth="1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="layout-bar-name">{l.name}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="layout-bar-tb"
|
||||||
|
title={t('layouts.titleblock', l.name)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!doc) return;
|
||||||
|
insertTitleBlockForLayout(doc as never, l.id, {
|
||||||
|
project: l.name,
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
scale: `1:${Math.max(1, Math.round(1 / (l.viewport.scale || 1)))}`,
|
||||||
|
author: 'WebCAD',
|
||||||
|
});
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
>🏷</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="layout-bar-del"
|
||||||
|
title={t('layouts.delete', l.name)}
|
||||||
|
onClick={() => handleDelete(l.id)}
|
||||||
|
>🗑</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayoutBar;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Task F4 – UI für LibraryProviderExtensions.
|
||||||
|
*
|
||||||
|
* Lädt alle übergebenen Provider (builtin/user/global) und rendert je Ordner
|
||||||
|
* eine Sektion mit Thumbnail-Blöcken. Jeder Block ist als DragSource an den
|
||||||
|
* bestehenden 'text/global-block-data'-Kanal angebunden (DragDrop → Canvas).
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import type { LibraryProviderExtension, LibBlock } from '../plugins/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
providers: LibraryProviderExtension[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Section { key: string; label: string; blocks: LibBlock[]; }
|
||||||
|
|
||||||
|
const LibraryProviderPanel: React.FC<Props> = ({ providers }) => {
|
||||||
|
const [sections, setSections] = useState<Section[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const out: Section[] = [];
|
||||||
|
for (const provider of providers) {
|
||||||
|
try {
|
||||||
|
const folders = await provider.listFolders();
|
||||||
|
for (const folder of folders) {
|
||||||
|
try {
|
||||||
|
const blocks = await provider.listBlocks(folder.id);
|
||||||
|
if (blocks.length > 0) {
|
||||||
|
out.push({ key: `${provider.id}/${folder.id}`, label: folder.label, blocks });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ordner bei Fehler überspringen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Provider bei Fehler überspringen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled) setSections(out);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [providers]);
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent, block: LibBlock) => {
|
||||||
|
const payload = typeof block.payload === 'string' ? block.payload : JSON.stringify(block.payload ?? []);
|
||||||
|
e.dataTransfer.setData('text/global-block-data', payload);
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sections.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lib-provider-panel">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<div className="lprov-section" key={section.key}>
|
||||||
|
<div className="lprov-title">{section.label}</div>
|
||||||
|
<div className="lprov-grid">
|
||||||
|
{section.blocks.map((block) => (
|
||||||
|
<button
|
||||||
|
key={block.id}
|
||||||
|
type="button"
|
||||||
|
className="lprov-block"
|
||||||
|
aria-label={block.name}
|
||||||
|
title={block.name}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, block)}
|
||||||
|
>
|
||||||
|
{block.thumbnail
|
||||||
|
? <span className="tree-thumb" dangerouslySetInnerHTML={{ __html: block.thumbnail }} />
|
||||||
|
: <span className="lib-grid-placeholder">📦</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LibraryProviderPanel;
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { polygonArea } from '../tools/modification/geometry';
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -69,7 +72,43 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||||||
<>
|
<>
|
||||||
<div className="panel-section">
|
<div className="panel-section">
|
||||||
<div className="panel-section-title">
|
<div className="panel-section-title">
|
||||||
<span>Auswahl {el ? `· ${el.type}` : ''}</span>
|
<span>Auswahl {el ? `· ${el.type}
|
||||||
|
{((el.type === 'polygon' || el.type === 'polyline') && ((el.properties.points as Array<{ x: number; y: number }> | undefined)?.length ?? 0) >= 3) && (
|
||||||
|
<div className="prop-row">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── 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
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { getUIMode, useUIMode } from '../services/uiModeService';
|
||||||
import type { RibbonBarProps, RibbonTab } from '../types/ui.types';
|
import type { RibbonBarProps, RibbonTab } from '../types/ui.types';
|
||||||
import { pluginRegistry } from '../plugins';
|
import { pluginRegistry } from '../plugins';
|
||||||
import { V2_TOOLS_ENABLED } from '../kernel/featureFlags';
|
import { V2_TOOLS_ENABLED } from '../kernel/featureFlags';
|
||||||
@@ -19,6 +20,8 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
|||||||
onCanvasBgColorChange, onGridColorChange, onToggleRuler,
|
onCanvasBgColorChange, onGridColorChange, onToggleRuler,
|
||||||
onIsV2ToolActive,
|
onIsV2ToolActive,
|
||||||
}) => {
|
}) => {
|
||||||
|
const uiMode = useUIMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
|
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
|
||||||
<div className="ribbon-tabs" role="tablist">
|
<div className="ribbon-tabs" role="tablist">
|
||||||
@@ -47,7 +50,9 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
|||||||
borderBottom: '1px solid var(--color-border, #333)', marginBottom: '8px',
|
borderBottom: '1px solid var(--color-border, #333)', marginBottom: '8px',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: '11px', color: 'var(--color-ki, #8a8aff)', marginRight: '4px' }}>V2:</span>
|
<span style={{ fontSize: '11px', color: 'var(--color-ki, #8a8aff)', marginRight: '4px' }}>V2:</span>
|
||||||
{pluginRegistry.getToolsV2().map((tool) => {
|
{pluginRegistry.getToolsV2()
|
||||||
|
.filter((tool) => uiMode === 'pro' || (tool.manifest.tags ?? []).includes('basic'))
|
||||||
|
.map((tool) => {
|
||||||
const isActive = onIsV2ToolActive?.(tool.manifest.id);
|
const isActive = onIsV2ToolActive?.(tool.manifest.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -324,6 +329,10 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l4 2"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l4 2"/></svg>
|
||||||
<span>Historie</span>
|
<span>Historie</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button className="ribbon-btn" title="Gäste-Verwaltung" onClick={() => onAction('guests')}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
<span>Gäste</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="ribbon-group-label">Extras</div>
|
<div className="ribbon-group-label">Extras</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import type { RightSidebarProps, RightPanel } from '../types/ui.types';
|
import type { RightSidebarProps, RightPanel } from '../types/ui.types';
|
||||||
|
import type { LibraryProviderExtension } from '../plugins/types';
|
||||||
import PropertiesPanel from './PropertiesPanel';
|
import PropertiesPanel from './PropertiesPanel';
|
||||||
import LayerPanel from './LayerPanel';
|
import LayerPanel from './LayerPanel';
|
||||||
import BlockLibrary from './BlockLibrary';
|
import BlockLibrary from './BlockLibrary';
|
||||||
import BlockLibraryTree from './BlockLibraryTree';
|
import BlockLibraryTree from './BlockLibraryTree';
|
||||||
|
import LibraryProviderPanel from './LibraryProviderPanel';
|
||||||
|
import { pluginRegistry, builtinCatalogProvider } from '../plugins';
|
||||||
|
import { makeUserLibraryProvider, makeGlobalLibraryProvider } from '../plugins/builtin/library/providers';
|
||||||
|
import { useMemo } from 'react';
|
||||||
import KICopilot from './KICopilot';
|
import KICopilot from './KICopilot';
|
||||||
|
|
||||||
const MIN_RIGHTBAR_WIDTH = 200;
|
const MIN_RIGHTBAR_WIDTH = 200;
|
||||||
@@ -39,6 +44,23 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
|
|||||||
rightbarWidth = 300,
|
rightbarWidth = 300,
|
||||||
onWidthChange,
|
onWidthChange,
|
||||||
}) => {
|
}) => {
|
||||||
|
const libraryProviders = useMemo(() => {
|
||||||
|
const list: LibraryProviderExtension[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const push = (p: LibraryProviderExtension) => {
|
||||||
|
if (p && !seen.has(p.id)) {
|
||||||
|
seen.add(p.id);
|
||||||
|
list.push(p);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const p of pluginRegistry.getLibraryProviders()) push(p);
|
||||||
|
push(builtinCatalogProvider);
|
||||||
|
if (blocks) push(makeUserLibraryProvider(blocks));
|
||||||
|
if (token) push(makeGlobalLibraryProvider(token));
|
||||||
|
return list;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [blocks, token]);
|
||||||
|
|
||||||
const handleResizeMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleResizeMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -138,6 +160,7 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<LibraryProviderPanel providers={libraryProviders} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { TopbarProps } from '../types/ui.types';
|
import type { TopbarProps } from '../types/ui.types';
|
||||||
import type { UnitType } from '../utils/format';
|
import type { UnitType } from '../utils/format';
|
||||||
|
import { useUIMode, toggleUIMode, getUIMode } from '../services/uiModeService';
|
||||||
|
import { useT } from '../i18n/strings';
|
||||||
|
|
||||||
const Topbar: React.FC<TopbarProps> = ({
|
const Topbar: React.FC<TopbarProps> = ({
|
||||||
projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme,
|
projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme,
|
||||||
onOpenSettings, onOpenLeftDrawer, onOpenRightDrawer,
|
onOpenSettings, onOpenLeftDrawer, onOpenRightDrawer,
|
||||||
unit = 'mm', onUnitChange, onNavigateBack,
|
unit = 'mm', onUnitChange, onNavigateBack,
|
||||||
}) => {
|
}) => {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
const uiMode = useUIMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="topbar" role="banner">
|
<header className="topbar" role="banner">
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
@@ -33,6 +39,14 @@ const Topbar: React.FC<TopbarProps> = ({
|
|||||||
<span className="saved-badge" aria-label="Status: Alle Änderungen gespeichert">{savedStatus}</span>
|
<span className="saved-badge" aria-label="Status: Alle Änderungen gespeichert">{savedStatus}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
|
<button
|
||||||
|
className="icon-btn-top"
|
||||||
|
title={uiMode === 'pro' ? t('mode.toSimple') : t('mode.toPro')}
|
||||||
|
aria-label={uiMode === 'pro' ? t('mode.toSimple') : t('mode.toPro')}
|
||||||
|
onClick={toggleUIMode}
|
||||||
|
>
|
||||||
|
{uiMode === 'pro' ? '😊' : '🛠'}
|
||||||
|
</button>
|
||||||
<button className="icon-btn-top" aria-label="Rückgängig (Strg+Z)" title="Rückgängig (Strg+Z)" onClick={onUndo}>
|
<button className="icon-btn-top" aria-label="Rückgängig (Strg+Z)" title="Rückgängig (Strg+Z)" onClick={onUndo}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as Y from 'yjs';
|
|||||||
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
|
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
|
||||||
import type { ElementGroup } from '../tools/modification/GroupTool';
|
import type { ElementGroup } from '../tools/modification/GroupTool';
|
||||||
import type { BackgroundConfig } from '../services/backgroundService';
|
import type { BackgroundConfig } from '../services/backgroundService';
|
||||||
|
import type { LayoutDefinition } from '../types/cad.types';
|
||||||
|
|
||||||
export interface YjsDocumentData {
|
export interface YjsDocumentData {
|
||||||
elements: Y.Map<CADElement>;
|
elements: Y.Map<CADElement>;
|
||||||
@@ -14,6 +15,7 @@ export interface YjsDocumentData {
|
|||||||
meta: Y.Map<unknown>;
|
meta: Y.Map<unknown>;
|
||||||
groups: Y.Map<ElementGroup>;
|
groups: Y.Map<ElementGroup>;
|
||||||
bgConfig: Y.Map<BackgroundConfig>;
|
bgConfig: Y.Map<BackgroundConfig>;
|
||||||
|
layouts: Y.Map<LayoutDefinition>; // Task G1
|
||||||
}
|
}
|
||||||
|
|
||||||
export class YjsDocument {
|
export class YjsDocument {
|
||||||
@@ -29,6 +31,7 @@ export class YjsDocument {
|
|||||||
meta: this.doc.getMap<unknown>('meta'),
|
meta: this.doc.getMap<unknown>('meta'),
|
||||||
groups: this.doc.getMap<ElementGroup>('groups'),
|
groups: this.doc.getMap<ElementGroup>('groups'),
|
||||||
bgConfig: this.doc.getMap<BackgroundConfig>('bgConfig'),
|
bgConfig: this.doc.getMap<BackgroundConfig>('bgConfig'),
|
||||||
|
layouts: this.doc.getMap<LayoutDefinition>('layouts'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +60,11 @@ export class YjsDocument {
|
|||||||
return Array.from(this.data.bgConfig.values());
|
return Array.from(this.data.bgConfig.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get all layouts as a plain array (Task G1) */
|
||||||
|
getLayouts(): LayoutDefinition[] {
|
||||||
|
return Array.from(this.data.layouts.values());
|
||||||
|
}
|
||||||
|
|
||||||
/** Add or update a single element */
|
/** Add or update a single element */
|
||||||
setElement(el: CADElement): void {
|
setElement(el: CADElement): void {
|
||||||
this.doc.transact(() => {
|
this.doc.transact(() => {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Task H4 – i18n-Grundlage: zentrale Strings-Maps de/en.
|
||||||
|
*
|
||||||
|
* t(key) liefert den Text der aktuellen Sprache; unbekannte Keys
|
||||||
|
* fallen auf den Key zurück (im Code sichtbar, lint-bar über Tests).
|
||||||
|
* Sprache persistiert in localStorage ('webcad.language', Default de).
|
||||||
|
* Komponenten nutzen useT() für Live-Updates bei Sprachwechsel.
|
||||||
|
*
|
||||||
|
* Vollständige mechanische Ersetzung ALLER Komponenten-Texte ist
|
||||||
|
* teilautomatisierbares Follow-up; hier liegt die Infrastruktur und
|
||||||
|
* die Kern-UI-Texte (LayoutBar, Modus).
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export const LANGUAGES = ['de', 'en'] as const;
|
||||||
|
export type Language = (typeof LANGUAGES)[number];
|
||||||
|
|
||||||
|
type StringEntry = string | ((arg?: string) => string);
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'webcad.language';
|
||||||
|
|
||||||
|
/** Zentrale Text-Maps (Key-Pfad → Text oder parametrisierte Funktion). */
|
||||||
|
const DE: Record<string, StringEntry> = {
|
||||||
|
'layouts.title': 'Layouts',
|
||||||
|
'layouts.add': 'Layout hinzufügen',
|
||||||
|
'layouts.empty': 'Keine Layouts',
|
||||||
|
'layouts.delete': (n?: string) => 'Layout ' + (n ?? '') + ' löschen',
|
||||||
|
'layouts.print': 'Layout drucken (A4 landscape)',
|
||||||
|
'layouts.titleblock': (n?: string) => 'Titelblock für ' + (n ?? '') + ' einfügen/aktualisieren',
|
||||||
|
'layouts.zoomto': (n?: string) => 'Zoom zu ' + (n ?? ''),
|
||||||
|
'mode.toSimple': 'Zum einfachen Modus wechseln',
|
||||||
|
'mode.toPro': 'Zum Profi-Modus wechseln',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EN: Record<string, StringEntry> = {
|
||||||
|
'layouts.title': 'Layouts',
|
||||||
|
'layouts.add': 'Add layout',
|
||||||
|
'layouts.empty': 'No layouts',
|
||||||
|
'layouts.delete': (n?: string) => 'Delete layout ' + (n ?? ''),
|
||||||
|
'layouts.print': 'Print layout (A4 landscape)',
|
||||||
|
'layouts.titleblock': (n?: string) => 'Insert/update title block for ' + (n ?? ''),
|
||||||
|
'layouts.zoomto': (n?: string) => 'Zoom to ' + (n ?? ''),
|
||||||
|
'mode.toSimple': 'Switch to simple mode',
|
||||||
|
'mode.toPro': 'Switch to pro mode',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapFor(lang: Language): Record<string, StringEntry> {
|
||||||
|
return lang === 'en' ? EN : DE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStored(): Language {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === 'de' || raw === 'en') return raw;
|
||||||
|
} catch {
|
||||||
|
// localStorage nicht verfügbar
|
||||||
|
}
|
||||||
|
return 'de';
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: Language = readStored();
|
||||||
|
const listeners = new Set<(lang: Language) => void>();
|
||||||
|
|
||||||
|
/** Aktuelle Sprache. */
|
||||||
|
export function getLanguage(): Language {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sprache wechseln und persistieren. */
|
||||||
|
export function setLanguage(lang: Language): void {
|
||||||
|
current = lang;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, lang);
|
||||||
|
} catch {
|
||||||
|
// Persistenz optional
|
||||||
|
}
|
||||||
|
for (const cb of listeners) cb(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe auf Sprachwechsel; gibt Unsubscribe zurück. */
|
||||||
|
export function subscribeLanguage(cb: (lang: Language) => void): () => void {
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => { listeners.delete(cb); };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übersetzung: t('key') bzw. t('key', arg) für parametrisierte Texte.
|
||||||
|
* Unbekannter Key → Key als Fallback (offensichtlich sichtbar).
|
||||||
|
*/
|
||||||
|
export function t(key: string, arg?: string): string {
|
||||||
|
const entry = mapFor(current)[key];
|
||||||
|
if (typeof entry === 'function') return entry(arg);
|
||||||
|
if (typeof entry === 'string') return entry;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** React-Hook: erzwingt Re-Render bei Sprachwechsel. */
|
||||||
|
export function useT(): typeof t {
|
||||||
|
const [, force] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const off = subscribeLanguage(() => force((n) => n + 1));
|
||||||
|
return off;
|
||||||
|
}, []);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||||
import type { CADElement, CADLayer } from '../../types/cad.types';
|
import type { CADElement, CADLayer, LayoutDefinition, BlockDefinition } from '../../types/cad.types';
|
||||||
|
|
||||||
export class CADDocument {
|
export class CADDocument {
|
||||||
private readonly ydoc: YjsDocument;
|
private readonly ydoc: YjsDocument;
|
||||||
@@ -22,10 +22,10 @@ export class CADDocument {
|
|||||||
constructor(ydoc: YjsDocument) {
|
constructor(ydoc: YjsDocument) {
|
||||||
this.ydoc = ydoc;
|
this.ydoc = ydoc;
|
||||||
// Trackt alle Operationen auf Elements + Layers → gemeinsame Undo-Historie.
|
// Trackt alle Operationen auf Elements + Layers → gemeinsame Undo-Historie.
|
||||||
// Blocks/Groups/Configs bewusst NICHT tracken: Werkzeuge ändern primär Elemente/Layer.
|
// Blocks/Groups/Configs bewusst NICHT tracken; Layouts (G1) dazu seit Task G1.
|
||||||
// captureTimeout 0: KEIN automatisches Mergen zeitnaher Transaktionen —
|
// captureTimeout 0: KEIN automatisches Mergen zeitnaher Transaktionen —
|
||||||
// jede explizite transact() bleibt EIGENER Undo-Schritt (deterministisch für Tools).
|
// jede explizite transact() bleibt EIGENER Undo-Schritt (deterministisch für Tools).
|
||||||
this.undoManager = new Y.UndoManager([this.ydoc.data.elements, this.ydoc.data.layers], {
|
this.undoManager = new Y.UndoManager([this.ydoc.data.elements, this.ydoc.data.layers, this.ydoc.data.layouts], {
|
||||||
captureTimeout: 0,
|
captureTimeout: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,86 @@ export class CADDocument {
|
|||||||
return Array.from(this.ydoc.data.layers.values());
|
return Array.from(this.ydoc.data.layers.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Layouts (Task G1) ────────────────────────────────────
|
||||||
|
|
||||||
|
/** Ein Layout per ID (oder undefined). */
|
||||||
|
getLayout(id: string): LayoutDefinition | undefined {
|
||||||
|
return this.ydoc.data.layouts.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Layouts als Array. */
|
||||||
|
getAllLayouts(): LayoutDefinition[] {
|
||||||
|
return Array.from(this.ydoc.data.layouts.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fügt ein Layout hinzu (eigener Undo-Schritt). */
|
||||||
|
addLayout(layout: LayoutDefinition): void {
|
||||||
|
this.transact(() => {
|
||||||
|
this.ydoc.data.layouts.set(layout.id, layout);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert ein Layout: Top-Level-Felder ersetzt, viewport SHALLOW gemergt
|
||||||
|
* (center/scale einzeln änderbar). @throws wenn Layout fehlt.
|
||||||
|
*/
|
||||||
|
updateLayout(id: string, patch: Partial<Omit<LayoutDefinition, 'id'>>): void {
|
||||||
|
const existing = this.ydoc.data.layouts.get(id);
|
||||||
|
if (!existing) throw new Error(`updateLayout: Layout '${id}' nicht gefunden`);
|
||||||
|
const merged: LayoutDefinition = {
|
||||||
|
...existing,
|
||||||
|
...patch,
|
||||||
|
viewport: patch.viewport
|
||||||
|
? { ...existing.viewport, ...patch.viewport, center: { ...existing.viewport.center, ...patch.viewport.center } }
|
||||||
|
: existing.viewport,
|
||||||
|
};
|
||||||
|
this.transact(() => {
|
||||||
|
this.ydoc.data.layouts.set(id, merged);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt ein Layout (still bei unbekannter ID, eigener Undo-Schritt). */
|
||||||
|
deleteLayout(id: string): void {
|
||||||
|
this.transact(() => {
|
||||||
|
if (this.ydoc.data.layouts.has(id)) {
|
||||||
|
this.ydoc.data.layouts.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Blöcke (Task G3 — bewusst NICHT undo-getrackt, siehe UndoManager-Kommentar) ──
|
||||||
|
|
||||||
|
/** Eine Blockdefinition per ID (oder undefined). */
|
||||||
|
getBlock(id: string): BlockDefinition | undefined {
|
||||||
|
return this.ydoc.data.blocks.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fügt eine Blockdefinition hinzu (überschreibt gleiche ID). */
|
||||||
|
addBlock(block: BlockDefinition): void {
|
||||||
|
this.ydoc.data.blocks.set(block.id, block);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Fügt einen Layer hinzu (überschreibt gleiche ID, kein Undo-Schritt). */
|
||||||
|
addLayer(layer: CADLayer): void {
|
||||||
|
this.ydoc.data.layers.set(layer.id, layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ersetzt das komplette Layer-Set (Task H2: Projekt-Templates).
|
||||||
|
* Bestehende Layer werden entfernt, Template-Layer übernommen.
|
||||||
|
*/
|
||||||
|
replaceLayers(layers: CADLayer[]): void {
|
||||||
|
this.transact(() => {
|
||||||
|
for (const id of Array.from(this.ydoc.data.layers.keys())) {
|
||||||
|
this.ydoc.data.layers.delete(id);
|
||||||
|
}
|
||||||
|
for (const l of layers) {
|
||||||
|
this.ydoc.data.layers.set(l.id, l);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Schreiben ────────────────────────────────────────────
|
// ── Schreiben ────────────────────────────────────────────
|
||||||
|
|
||||||
/** Fügt ein Element hinzu (einzelner Aufruf = eigener Undo-Schritt). */
|
/** Fügt ein Element hinzu (einzelner Aufruf = eigener Undo-Schritt). */
|
||||||
|
|||||||
@@ -127,15 +127,81 @@ export const dimensionTool: ToolExtensionV2 = make2PointAnnotate('dimension', 'B
|
|||||||
export const leaderTool: ToolExtensionV2 = make2PointAnnotate('leader', 'Leader', '\u21b1', buildLeader);
|
export const leaderTool: ToolExtensionV2 = make2PointAnnotate('leader', 'Leader', '\u21b1', buildLeader);
|
||||||
|
|
||||||
/** V2-Plugin: Beschriftung/Bemaßung (A4.4). */
|
/** V2-Plugin: Beschriftung/Bemaßung (A4.4). */
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task D4: MTEXT — Mehrzeiliger Text mit optionalem Rahmen.
|
||||||
|
// Klick platziert Text (Inhalt aus Options, Zeilenumbrüche erhalten),
|
||||||
|
// frame-Option erzeugt ein rect in derselben Transaktion (1 Undo).
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const mtextTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'mtext',
|
||||||
|
label: 'Mehrzeilentext',
|
||||||
|
icon: '¶',
|
||||||
|
ribbonTab: 'format',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Mehrzeiligen Text platzieren (Rahmen optional, Task D4)',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'text', label: 'Text', type: 'text' },
|
||||||
|
{ key: 'fontSize', label: 'Größe', type: 'number', min: 6, max: 72 },
|
||||||
|
{ key: 'frame', label: 'Rahmen', type: 'checkbox' },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) return;
|
||||||
|
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
|
||||||
|
const text = (c.options.text as string | undefined) ?? '';
|
||||||
|
const fontSize = (c.options.fontSize as number | undefined) ?? 12;
|
||||||
|
const frame = (c.options.frame as boolean | undefined) ?? false;
|
||||||
|
// Newline ohne Escape-Probleme: charCode 10
|
||||||
|
const NL = String.fromCharCode(10);
|
||||||
|
const lines = text.split(NL);
|
||||||
|
const lineCount = Math.max(1, lines.length);
|
||||||
|
const maxLen = lines.reduce((m, l) => Math.max(m, l.length), 0);
|
||||||
|
const textW = maxLen * fontSize * 0.6;
|
||||||
|
const textH = lineCount * fontSize * 1.4;
|
||||||
|
const els: CADElement[] = [{
|
||||||
|
id: uid('mtext'),
|
||||||
|
type: 'text',
|
||||||
|
layerId,
|
||||||
|
x: e.world.x,
|
||||||
|
y: e.world.y,
|
||||||
|
width: textW,
|
||||||
|
height: textH,
|
||||||
|
properties: { text, fontSize, mtext: true },
|
||||||
|
} as unknown as CADElement];
|
||||||
|
if (frame) {
|
||||||
|
const pad = fontSize * 0.4;
|
||||||
|
els.push({
|
||||||
|
id: uid('mtextframe'),
|
||||||
|
type: 'rect',
|
||||||
|
layerId,
|
||||||
|
x: e.world.x,
|
||||||
|
y: e.world.y,
|
||||||
|
width: textW + pad * 2,
|
||||||
|
height: textH + pad * 2,
|
||||||
|
properties: { stroke: '#9aa0a6', strokeWidth: 1, fill: 'none' },
|
||||||
|
} as unknown as CADElement);
|
||||||
|
}
|
||||||
|
c.doc.transact(() => {
|
||||||
|
for (const el of els) c.doc!.addElement(el);
|
||||||
|
});
|
||||||
|
ctx.setStatus(`Mehrzeilentext platziert (${lineCount} Zeile(n)${frame ? ', mit Rahmen' : ''})`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const coreAnnotatePlugin: PluginV2 = {
|
export const coreAnnotatePlugin: PluginV2 = {
|
||||||
manifest: {
|
manifest: {
|
||||||
id: 'core-annotate',
|
id: 'core-annotate',
|
||||||
name: 'Beschriftung (Kern)',
|
name: 'Beschriftung (Kern)',
|
||||||
version: '1.0.0',
|
version: '1.1.0',
|
||||||
author: 'web-cad team',
|
author: 'web-cad team',
|
||||||
description: 'Text, Bemaßung, Leader (V2).',
|
description: 'Text, Bemaßung, Leader (V2).',
|
||||||
category: 'tools',
|
category: 'tools',
|
||||||
enabledByDefault: true,
|
enabledByDefault: true,
|
||||||
},
|
},
|
||||||
tools: [textTool, dimensionTool, leaderTool],
|
tools: [textTool, dimensionTool, leaderTool, mtextTool],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -414,15 +414,492 @@ function buildRevCloud(pts: Pt[], ctx: FullToolContext): CADElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** V2-Plugin: Kern-Zeichenwerkzeuge (A4.x schrittweise migriert). */
|
/** V2-Plugin: Kern-Zeichenwerkzeuge (A4.x schrittweise migriert). */
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task D1: Ellipse (3-Klick: Zentrum→RadiusX→RadiusY) +
|
||||||
|
// Ellipsenbogen via startAngle/endAngle-Options.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Ellipsen-Sitzung: Phase 0=nichts, 1=Zentrum, 2=RadiusX fixiert. */
|
||||||
|
let ellipsePhase = 0;
|
||||||
|
let ellipseCenter: Pt | null = null;
|
||||||
|
let ellipseRadiusX = 0;
|
||||||
|
/** Rotation aus der Klick2-Zugrichtung (Grad, 0°=+x, CW-positiv). */
|
||||||
|
let ellipseRotationDeg = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ellipse als Polyline-Approximation (F5-Parser-Konvention):
|
||||||
|
* Voll-Ellipse → 64 Punkte geschlossen (polygon), Bogen → proportional
|
||||||
|
* offene Polyline. rotation aus der Klick2-Zugrichtung (Grad, 0°=+x).
|
||||||
|
*/
|
||||||
|
function buildEllipse(
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
rx: number,
|
||||||
|
ry: number,
|
||||||
|
rotationDeg: number,
|
||||||
|
ctx: FullToolContext,
|
||||||
|
startAngle = 0,
|
||||||
|
endAngle = 360,
|
||||||
|
): CADElement {
|
||||||
|
const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0';
|
||||||
|
const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0';
|
||||||
|
const phi = (rotationDeg * Math.PI) / 180;
|
||||||
|
const cosP = Math.cos(phi), sinP = Math.sin(phi);
|
||||||
|
const FULL = 360;
|
||||||
|
const span = Math.min(endAngle, FULL) - Math.min(startAngle, FULL);
|
||||||
|
const isFull = span >= FULL - 0.001 || span <= 0.001;
|
||||||
|
const n = Math.max(2, Math.round(64 * (Math.max(span, 1) / FULL)));
|
||||||
|
const pts: Pt[] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const t = ((startAngle + (span * i) / (isFull ? n : (n - 1))) * Math.PI) / 180;
|
||||||
|
const x = rx * Math.cos(t);
|
||||||
|
const y = ry * Math.sin(t);
|
||||||
|
pts.push({
|
||||||
|
x: cx + x * cosP - y * sinP,
|
||||||
|
y: cy + x * sinP + y * cosP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
|
||||||
|
const minX = Math.min(...xs), minY = Math.min(...ys);
|
||||||
|
const type = isFull ? 'polygon' : 'polyline';
|
||||||
|
return {
|
||||||
|
id: uid('ellipse'),
|
||||||
|
type,
|
||||||
|
layerId,
|
||||||
|
x: minX + (Math.max(...xs) - minX) / 2,
|
||||||
|
y: minY + (Math.max(...ys) - minY) / 2,
|
||||||
|
width: Math.max(...xs) - minX,
|
||||||
|
height: Math.max(...ys) - minY,
|
||||||
|
properties: {
|
||||||
|
points: pts,
|
||||||
|
stroke,
|
||||||
|
strokeWidth: 1,
|
||||||
|
cx, cy, rx, ry,
|
||||||
|
rotation: rotationDeg,
|
||||||
|
...(isFull ? {} : { startAngle, endAngle }),
|
||||||
|
},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ellipseTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'ellipse',
|
||||||
|
label: 'Ellipse',
|
||||||
|
icon: '\u2b2d',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Ellipse zeichnen: Zentrum klicken, RadiusX, RadiusY (Bogen via Optionen)',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
|
||||||
|
{ key: 'startAngle', label: 'Bogen-Start (°)', type: 'number', min: 0, max: 360 },
|
||||||
|
{ key: 'endAngle', label: 'Bogen-Ende (°)', type: 'number', min: 0, max: 360 },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (ellipsePhase === 0) {
|
||||||
|
ellipseCenter = e.world;
|
||||||
|
ellipsePhase = 1;
|
||||||
|
ctx.setStatus('Ellipse: Zentrum gesetzt – RadiusX klicken (Richtung = Rotation)');
|
||||||
|
} else if (ellipsePhase === 1) {
|
||||||
|
if (!ellipseCenter) { ellipsePhase = 0; return; }
|
||||||
|
const dx = e.world.x - ellipseCenter.x;
|
||||||
|
const dy = e.world.y - ellipseCenter.y;
|
||||||
|
ellipseRadiusX = Math.hypot(dx, dy);
|
||||||
|
ellipseRotationDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
|
||||||
|
ellipsePhase = 2;
|
||||||
|
ctx.setStatus('Ellipse: RadiusX gesetzt – RadiusY klicken');
|
||||||
|
} else {
|
||||||
|
// Phase 2: Commit
|
||||||
|
if (!ellipseCenter || !c.doc) { ellipsePhase = 0; ellipseCenter = null; return; }
|
||||||
|
const dx = e.world.x - ellipseCenter.x;
|
||||||
|
const dy = e.world.y - ellipseCenter.y;
|
||||||
|
const ry = Math.hypot(dx, dy);
|
||||||
|
// Rotation: Winkel des Klick2-Vektors gegenüber der +x-Achse
|
||||||
|
const startAngle = (ctx.options.startAngle as number | undefined) ?? 0;
|
||||||
|
const endAngle = (ctx.options.endAngle as number | undefined) ?? 360;
|
||||||
|
const el = buildEllipse(
|
||||||
|
ellipseCenter.x, ellipseCenter.y,
|
||||||
|
ellipseRadiusX, ry,
|
||||||
|
ellipseRotationDeg,
|
||||||
|
c, startAngle, endAngle,
|
||||||
|
);
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
ellipsePhase = 0;
|
||||||
|
ellipseCenter = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`Ellipse erstellt (${el.id})`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (ellipsePhase === 1 && ellipseCenter) {
|
||||||
|
// RadiusX-Linie als Preview
|
||||||
|
const r = Math.hypot(e.world.x - ellipseCenter.x, e.world.y - ellipseCenter.y);
|
||||||
|
const rotDeg = ((Math.atan2(e.world.y - ellipseCenter.y, e.world.x - ellipseCenter.x) * 180) / Math.PI + 360) % 360;
|
||||||
|
const startAngle = (ctx.options.startAngle as number | undefined) ?? 0;
|
||||||
|
const endAngle = (ctx.options.endAngle as number | undefined) ?? 360;
|
||||||
|
ctx.setPreview?.(buildEllipse(ellipseCenter.x, ellipseCenter.y, r, r, rotDeg, c, startAngle, endAngle));
|
||||||
|
} else if (ellipsePhase === 2 && ellipseCenter) {
|
||||||
|
const ry = Math.hypot(e.world.x - ellipseCenter.x, e.world.y - ellipseCenter.y);
|
||||||
|
const rotDeg = ellipseRotationDeg;
|
||||||
|
const startAngle = (ctx.options.startAngle as number | undefined) ?? 0;
|
||||||
|
const endAngle = (ctx.options.endAngle as number | undefined) ?? 360;
|
||||||
|
ctx.setPreview?.(buildEllipse(ellipseCenter.x, ellipseCenter.y, ellipseRadiusX, ry, rotDeg, c, startAngle, endAngle));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
ellipsePhase = 0;
|
||||||
|
ellipseCenter = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Ellipse abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task D2: Spline (CV-basiert) — Kontrollpunkt-Klick-Sequenz,
|
||||||
|
// ENTER committet ab 3 CVs. Catmull-Rom-Interpolation (Kurve läuft
|
||||||
|
// DURCH alle CVs, schwingt an Ecken = echte Glättung).
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let splinePoints: Pt[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catmull-Rom-Spline-Interpolation: Für n CVs werden n-1 Segmente
|
||||||
|
* mit je SEG Samples erzeugt. Tangenten aus Nachbarn (Endpunkte
|
||||||
|
* geklemmt). Ergibt eine glatte Kurve durch alle CVs.
|
||||||
|
*/
|
||||||
|
function catmullRom(cvs: Pt[], seg = 16): Pt[] {
|
||||||
|
if (cvs.length < 2) return [...cvs];
|
||||||
|
const out: Pt[] = [];
|
||||||
|
const n = cvs.length;
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
const p0 = cvs[Math.max(0, i - 1)];
|
||||||
|
const p1 = cvs[i];
|
||||||
|
const p2 = cvs[i + 1];
|
||||||
|
const p3 = cvs[Math.min(n - 1, i + 2)];
|
||||||
|
for (let s = (i === 0 ? 0 : 1); s <= seg; s++) {
|
||||||
|
const t = s / seg;
|
||||||
|
const t2 = t * t;
|
||||||
|
const t3 = t2 * t;
|
||||||
|
const x = 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
|
||||||
|
const y = 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
|
||||||
|
out.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSpline(cvs: Pt[], ctx: FullToolContext): CADElement {
|
||||||
|
const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0';
|
||||||
|
const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0';
|
||||||
|
const pts = catmullRom(cvs);
|
||||||
|
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
|
||||||
|
const minX = Math.min(...xs), minY = Math.min(...ys);
|
||||||
|
const maxX = Math.max(...xs), maxY = Math.max(...ys);
|
||||||
|
return {
|
||||||
|
id: uid('spline'),
|
||||||
|
type: 'polyline',
|
||||||
|
layerId,
|
||||||
|
x: minX + (maxX - minX) / 2,
|
||||||
|
y: minY + (maxY - minY) / 2,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
properties: {
|
||||||
|
points: pts,
|
||||||
|
controlPoints: [...cvs],
|
||||||
|
stroke,
|
||||||
|
strokeWidth: 1,
|
||||||
|
},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const splineTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'spline',
|
||||||
|
label: 'Spline',
|
||||||
|
icon: '\u2248',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Spline zeichnen: Kontrollpunkte klicken, ENTER beendet',
|
||||||
|
},
|
||||||
|
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
splinePoints.push(e.world);
|
||||||
|
ctx.setStatus(`Spline: ${splinePoints.length} Kontrollpunkte – ENTER beendet`);
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (splinePoints.length === 0) return;
|
||||||
|
const pts = [...splinePoints, e.world];
|
||||||
|
ctx.setPreview?.(buildSpline(pts, c));
|
||||||
|
},
|
||||||
|
up() { /* commit nur via ENTER */ },
|
||||||
|
key(e, ctx) {
|
||||||
|
if (e.key !== 'Enter' && e.key !== 'ENTER') return false;
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (splinePoints.length < 3) {
|
||||||
|
ctx.setStatus(`Spline benötigt mindestens 3 Kontrollpunkte (aktuell ${splinePoints.length})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!c.doc) return false;
|
||||||
|
const el = buildSpline(splinePoints, c);
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
const count = splinePoints.length;
|
||||||
|
splinePoints = [];
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`Spline erstellt (${count} CVs, ${el.id})`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
splinePoints = [];
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Spline abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task D3: Point/XLine/Ray — Konstruktions-Elemente auf defpoints.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Auto-Anlegen des defpoints-Layers (Task D3) falls fehlt. */
|
||||||
|
function ensureDefpointsLayer(ctx: FullToolContext): void {
|
||||||
|
const doc = ctx.doc as unknown as {
|
||||||
|
getLayer(id: string): unknown;
|
||||||
|
addLayer(l: unknown): void;
|
||||||
|
} | undefined;
|
||||||
|
if (!doc || doc.getLayer('defpoints')) return;
|
||||||
|
doc.addLayer({
|
||||||
|
id: 'defpoints',
|
||||||
|
name: 'defpoints',
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
color: '#8a8f98',
|
||||||
|
lineType: 'dotted',
|
||||||
|
transparency: 0.5,
|
||||||
|
sortOrder: 999,
|
||||||
|
parentId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Konstruktions-Element-Basis-Props (construction-Tags, Task D3). */
|
||||||
|
function constructionProps(): Record<string, unknown> {
|
||||||
|
return { stroke: '#8a8f98', strokeWidth: 1, construction: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pointTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'point',
|
||||||
|
label: 'Punkt',
|
||||||
|
icon: '\u2022',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Konstruktionspunkt platzieren (defpoints-Layer)',
|
||||||
|
},
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) return;
|
||||||
|
ensureDefpointsLayer(c);
|
||||||
|
const r = 1.5;
|
||||||
|
const el: CADElement = {
|
||||||
|
id: uid('point'),
|
||||||
|
type: 'circle',
|
||||||
|
layerId: 'defpoints',
|
||||||
|
x: e.world.x,
|
||||||
|
y: e.world.y,
|
||||||
|
width: r * 2,
|
||||||
|
height: r * 2,
|
||||||
|
properties: { ...constructionProps(), radius: r, point: true },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
ctx.setStatus(`Punkt platziert (${el.id})`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** XLine: sehr lange Linie in BEIDE Richtungen um den Basispunkt. */
|
||||||
|
let xlineBase: Pt | null = null;
|
||||||
|
|
||||||
|
export const xlineTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'xline',
|
||||||
|
label: 'Konstruktionslinie',
|
||||||
|
icon: '\u2194',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Unendliche Konstruktionslinie: Basispunkt + Richtung klicken',
|
||||||
|
},
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) return;
|
||||||
|
if (!xlineBase) {
|
||||||
|
xlineBase = e.world;
|
||||||
|
ctx.setStatus('Konstruktionslinie: Basis gesetzt – Richtung klicken');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureDefpointsLayer(c);
|
||||||
|
const dx = e.world.x - xlineBase.x;
|
||||||
|
const dy = e.world.y - xlineBase.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < 0.001) { xlineBase = null; return; }
|
||||||
|
const ux = dx / len, uy = dy / len;
|
||||||
|
const L = 100000;
|
||||||
|
const el: CADElement = {
|
||||||
|
id: uid('xline'),
|
||||||
|
type: 'line',
|
||||||
|
layerId: 'defpoints',
|
||||||
|
x: xlineBase.x,
|
||||||
|
y: xlineBase.y,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
properties: {
|
||||||
|
...constructionProps(),
|
||||||
|
x1: xlineBase.x - ux * L,
|
||||||
|
y1: xlineBase.y - uy * L,
|
||||||
|
x2: xlineBase.x + ux * L,
|
||||||
|
y2: xlineBase.y + uy * L,
|
||||||
|
},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
xlineBase = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`Konstruktionslinie erstellt (${el.id})`);
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!xlineBase) return;
|
||||||
|
const dx = e.world.x - xlineBase.x;
|
||||||
|
const dy = e.world.y - xlineBase.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < 0.001) return;
|
||||||
|
const ux = dx / len, uy = dy / len;
|
||||||
|
const L = 100000;
|
||||||
|
ctx.setPreview?.({
|
||||||
|
id: '__preview_xline__',
|
||||||
|
type: 'line',
|
||||||
|
layerId: 'defpoints',
|
||||||
|
x: xlineBase.x,
|
||||||
|
y: xlineBase.y,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
properties: {
|
||||||
|
...constructionProps(),
|
||||||
|
x1: xlineBase.x - ux * L,
|
||||||
|
y1: xlineBase.y - uy * L,
|
||||||
|
x2: xlineBase.x + ux * L,
|
||||||
|
y2: xlineBase.y + uy * L,
|
||||||
|
},
|
||||||
|
} as unknown as CADElement);
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
xlineBase = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Konstruktionslinie abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ray: Linie ab Basispunkt NUR in Klickrichtung. */
|
||||||
|
let rayBase: Pt | null = null;
|
||||||
|
|
||||||
|
export const rayTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'ray',
|
||||||
|
label: 'Strahl',
|
||||||
|
icon: '\u2192',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Strahl ab Basispunkt in Klickrichtung',
|
||||||
|
},
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) return;
|
||||||
|
if (!rayBase) {
|
||||||
|
rayBase = e.world;
|
||||||
|
ctx.setStatus('Strahl: Basis gesetzt – Richtung klicken');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureDefpointsLayer(c);
|
||||||
|
const dx = e.world.x - rayBase.x;
|
||||||
|
const dy = e.world.y - rayBase.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < 0.001) { rayBase = null; return; }
|
||||||
|
const ux = dx / len, uy = dy / len;
|
||||||
|
const L = 100000;
|
||||||
|
const el: CADElement = {
|
||||||
|
id: uid('ray'),
|
||||||
|
type: 'line',
|
||||||
|
layerId: 'defpoints',
|
||||||
|
x: rayBase.x,
|
||||||
|
y: rayBase.y,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
properties: {
|
||||||
|
...constructionProps(),
|
||||||
|
x1: rayBase.x,
|
||||||
|
y1: rayBase.y,
|
||||||
|
x2: rayBase.x + ux * L,
|
||||||
|
y2: rayBase.y + uy * L,
|
||||||
|
},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
rayBase = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`Strahl erstellt (${el.id})`);
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!rayBase) return;
|
||||||
|
const dx = e.world.x - rayBase.x;
|
||||||
|
const dy = e.world.y - rayBase.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < 0.001) return;
|
||||||
|
const ux = dx / len, uy = dy / len;
|
||||||
|
const L = 100000;
|
||||||
|
ctx.setPreview?.({
|
||||||
|
id: '__preview_ray__',
|
||||||
|
type: 'line',
|
||||||
|
layerId: 'defpoints',
|
||||||
|
x: rayBase.x,
|
||||||
|
y: rayBase.y,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
properties: {
|
||||||
|
...constructionProps(),
|
||||||
|
x1: rayBase.x,
|
||||||
|
y1: rayBase.y,
|
||||||
|
x2: rayBase.x + ux * L,
|
||||||
|
y2: rayBase.y + uy * L,
|
||||||
|
},
|
||||||
|
} as unknown as CADElement);
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
rayBase = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Strahl abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const coreDrawingPlugin: PluginV2 = {
|
export const coreDrawingPlugin: PluginV2 = {
|
||||||
manifest: {
|
manifest: {
|
||||||
id: 'core-drawing',
|
id: 'core-drawing',
|
||||||
name: 'Zeichnen (Kern)',
|
name: 'Zeichnen (Kern)',
|
||||||
version: '1.3.0',
|
version: '1.5.0',
|
||||||
author: 'web-cad team',
|
author: 'web-cad team',
|
||||||
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.',
|
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.',
|
||||||
category: 'tools',
|
category: 'tools',
|
||||||
enabledByDefault: true,
|
enabledByDefault: true,
|
||||||
},
|
},
|
||||||
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool],
|
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* Distanzformatierung via bestehendem formatDistance(unit, scaleFactor).
|
* Distanzformatierung via bestehendem formatDistance(unit, scaleFactor).
|
||||||
*/
|
*/
|
||||||
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
|
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
|
||||||
import { formatDistance } from '../../../utils/format';
|
import { formatDistance, formatArea } from '../../../utils/format';
|
||||||
|
import { polygonArea } from '../../../tools/modification/geometry';
|
||||||
|
|
||||||
type MeasurePhase = 0 | 1; // 0=wartet auf Start, 1=wartet auf Ende
|
type MeasurePhase = 0 | 1; // 0=wartet auf Start, 1=wartet auf Ende
|
||||||
let measureStart: import('../../../tools/modification/geometry').Pt | null = null;
|
let measureStart: import('../../../tools/modification/geometry').Pt | null = null;
|
||||||
@@ -46,6 +47,70 @@ export const measureTool: ToolExtensionV2 = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** V2-Plugin: Messwerkzeuge. */
|
/** V2-Plugin: Messwerkzeuge. */
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task H5: measure-area — Polygon-Fläche messen (Status-only).
|
||||||
|
// Klick: Punkt hinzufügen; ENTER: committet Fläche ab 3 Punkten;
|
||||||
|
// ESC/Rechtsklick: Abbruch. Kein Element-Commit (wie measure).
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
let areaPts: Array<{ x: number; y: number }> = [];
|
||||||
|
|
||||||
|
export const measureAreaTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'measure-area',
|
||||||
|
label: 'Fläche messen',
|
||||||
|
icon: '\u25a9',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Polygon-Fläche messen: Punkte klicken, ENTER schließt ab',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'unit', label: 'Einheit', type: 'select', options: [
|
||||||
|
{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }, { value: 'm', label: 'm' },
|
||||||
|
] },
|
||||||
|
{ key: 'scaleFactor', label: 'Maßstab (Welt→mm)', type: 'number' },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
areaPts.push({ x: e.world.x, y: e.world.y });
|
||||||
|
if (areaPts.length === 1) {
|
||||||
|
ctx.setStatus(`Flächenmessung gestartet (1 Punkt) — weitere Punkte klicken, ENTER schließt ab`);
|
||||||
|
} else if (areaPts.length >= 3) {
|
||||||
|
const unit = (ctx.options.unit as string | undefined) ?? 'mm';
|
||||||
|
const scale = (ctx.options.scaleFactor as number | undefined) ?? 1;
|
||||||
|
ctx.setStatus(`Fläche: ${formatArea(polygonArea(areaPts), unit as never, scale)} — weiter klicken oder ENTER`);
|
||||||
|
} else {
|
||||||
|
ctx.setStatus(`${areaPts.length} Punkte — mindestens 3 benötigt, weitere Punkte klicken`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
if (areaPts.length < 2) return;
|
||||||
|
const probe = [...areaPts, { x: e.world.x, y: e.world.y }];
|
||||||
|
if (probe.length < 3) return;
|
||||||
|
const unit = (ctx.options.unit as string | undefined) ?? 'mm';
|
||||||
|
const scale = (ctx.options.scaleFactor as number | undefined) ?? 1;
|
||||||
|
ctx.setStatus(`Fläche: ${formatArea(polygonArea(probe), unit as never, scale)}`);
|
||||||
|
},
|
||||||
|
key(k, ctx) {
|
||||||
|
if (k.key === 'Enter' || k.key === 'ENTER') {
|
||||||
|
if (areaPts.length >= 3) {
|
||||||
|
const unit = (ctx.options.unit as string | undefined) ?? 'mm';
|
||||||
|
const scale = (ctx.options.scaleFactor as number | undefined) ?? 1;
|
||||||
|
ctx.setStatus(`Flächenmessung: ${formatArea(polygonArea(areaPts), unit as never, scale)} (${areaPts.length} Punkte)`);
|
||||||
|
areaPts = [];
|
||||||
|
} else {
|
||||||
|
ctx.setStatus(`Flächenmessung benötigt mindestens 3 Punkte (aktuell ${areaPts.length})`);
|
||||||
|
}
|
||||||
|
return true; // ENTER konsumiert
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
areaPts = [];
|
||||||
|
ctx.setStatus('Flächenmessung abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const coreMeasurePlugin: PluginV2 = {
|
export const coreMeasurePlugin: PluginV2 = {
|
||||||
manifest: {
|
manifest: {
|
||||||
id: 'core-measure',
|
id: 'core-measure',
|
||||||
@@ -56,5 +121,5 @@ export const coreMeasurePlugin: PluginV2 = {
|
|||||||
category: 'tools',
|
category: 'tools',
|
||||||
enabledByDefault: true,
|
enabledByDefault: true,
|
||||||
},
|
},
|
||||||
tools: [measureTool],
|
tools: [measureTool, measureAreaTool],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -82,6 +82,109 @@ function hitHandle(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task D9: GripEditing — Vertex-/Center-Grips (reine Funktion).
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GripPoint {
|
||||||
|
kind: 'vertex' | 'center' | 'corner';
|
||||||
|
point: Pt;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element-spezifische Griff-Punkte (Task D9):
|
||||||
|
* line = beide Endpunkte, polyline/polygon = alle Vertices,
|
||||||
|
* circle/arc = Zentrum, rect/default = 4 BBox-Ecken (C3fin-Skalierung).
|
||||||
|
*/
|
||||||
|
export function getGripPoints(el: CADElement): GripPoint[] {
|
||||||
|
const props = el.properties as Record<string, unknown>;
|
||||||
|
const type = String(el.type);
|
||||||
|
if (
|
||||||
|
type === 'line' &&
|
||||||
|
props.x1 !== undefined && props.y1 !== undefined &&
|
||||||
|
props.x2 !== undefined && props.y2 !== undefined
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
{ kind: 'vertex', point: { x: props.x1 as number, y: props.y1 as number }, index: 0 },
|
||||||
|
{ kind: 'vertex', point: { x: props.x2 as number, y: props.y2 as number }, index: 1 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (type === 'polyline' || type === 'polygon') {
|
||||||
|
const pts = (props.points as Pt[] | undefined) ?? [];
|
||||||
|
return pts.map((p, i) => ({ kind: 'vertex' as const, point: { x: p.x, y: p.y }, index: i }));
|
||||||
|
}
|
||||||
|
if (type === 'circle' || type === 'arc') {
|
||||||
|
return [{ kind: 'center', point: { x: el.x, y: el.y }, index: 0 }];
|
||||||
|
}
|
||||||
|
// rect und alle anderen: BBox-Ecken (Corner-Drag/Skalierung, C3fin)
|
||||||
|
const minX = el.x - el.width / 2;
|
||||||
|
const minY = el.y - el.height / 2;
|
||||||
|
const maxX = el.x + el.width / 2;
|
||||||
|
const maxY = el.y + el.height / 2;
|
||||||
|
return [
|
||||||
|
{ kind: 'corner', point: { x: minX, y: minY }, index: 0 },
|
||||||
|
{ kind: 'corner', point: { x: maxX, y: minY }, index: 1 },
|
||||||
|
{ kind: 'corner', point: { x: maxX, y: maxY }, index: 2 },
|
||||||
|
{ kind: 'corner', point: { x: minX, y: maxY }, index: 3 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet eine Vertex-/Center-Drag-Position an (Task D9) und liefert
|
||||||
|
* das aktualisierte Element (reine Funktion — für Preview UND Commit).
|
||||||
|
*/
|
||||||
|
function applyVertexDrag(
|
||||||
|
el: CADElement,
|
||||||
|
drag: { el: CADElement; gripKind: 'vertex' | 'center'; index: number },
|
||||||
|
wx: number,
|
||||||
|
wy: number,
|
||||||
|
): CADElement {
|
||||||
|
const props = { ...(el.properties as Record<string, unknown>) };
|
||||||
|
const type = String(el.type);
|
||||||
|
if (drag.gripKind === 'center') {
|
||||||
|
return { ...el, x: wx, y: wy, properties: props as CADElement['properties'] } as CADElement;
|
||||||
|
}
|
||||||
|
if (type === 'line') {
|
||||||
|
if (drag.index === 0) { props.x1 = wx; props.y1 = wy; }
|
||||||
|
else { props.x2 = wx; props.y2 = wy; }
|
||||||
|
// BBox aktualisieren
|
||||||
|
const x1 = props.x1 as number, y1 = props.y1 as number, x2 = props.x2 as number, y2 = props.y2 as number;
|
||||||
|
return {
|
||||||
|
...el,
|
||||||
|
x: (x1 + x2) / 2,
|
||||||
|
y: (y1 + y2) / 2,
|
||||||
|
width: Math.abs(x2 - x1),
|
||||||
|
height: Math.abs(y2 - y1),
|
||||||
|
properties: props as CADElement['properties'],
|
||||||
|
} as CADElement;
|
||||||
|
}
|
||||||
|
if (type === 'polyline' || type === 'polygon') {
|
||||||
|
const pts = [...((props.points as Pt[] | undefined) ?? [])];
|
||||||
|
if (drag.index < pts.length) pts[drag.index] = { x: wx, y: wy };
|
||||||
|
props.points = pts;
|
||||||
|
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
|
||||||
|
const minX = Math.min(...xs), minY = Math.min(...ys);
|
||||||
|
const maxX = Math.max(...xs), maxY = Math.max(...ys);
|
||||||
|
return {
|
||||||
|
...el,
|
||||||
|
x: minX + (maxX - minX) / 2,
|
||||||
|
y: minY + (maxY - minY) / 2,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
properties: props as CADElement['properties'],
|
||||||
|
} as CADElement;
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vertex-/Center-Drag-Sitzung (Task D9). */
|
||||||
|
let vertexDrag: {
|
||||||
|
el: CADElement;
|
||||||
|
gripKind: 'vertex' | 'center';
|
||||||
|
index: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
export const selectTool: ToolExtensionV2 = {
|
export const selectTool: ToolExtensionV2 = {
|
||||||
manifest: {
|
manifest: {
|
||||||
id: 'select',
|
id: 'select',
|
||||||
@@ -101,6 +204,28 @@ export const selectTool: ToolExtensionV2 = {
|
|||||||
const subtractive = e.ctrl;
|
const subtractive = e.ctrl;
|
||||||
const hit = bridge.hitTest(e.world.x, e.world.y, 5);
|
const hit = bridge.hitTest(e.world.x, e.world.y, 5);
|
||||||
|
|
||||||
|
// ── Task D9: Vertex-/Center-Grip-Prüfung VOR BBox-Corners ──
|
||||||
|
// (spezifischere Grips gewinnen gegenüber Corner-Skalierung)
|
||||||
|
if (!additive && !subtractive && c.doc) {
|
||||||
|
const GRIP_TOL = 8;
|
||||||
|
for (const id of bridge.getIds()) {
|
||||||
|
const el = c.doc.getElement(id);
|
||||||
|
if (!el) continue;
|
||||||
|
for (const g of getGripPoints(el)) {
|
||||||
|
if (g.kind === 'corner') continue; // Corner unten (C3fin)
|
||||||
|
if (
|
||||||
|
Math.abs(e.world.x - g.point.x) <= GRIP_TOL &&
|
||||||
|
Math.abs(e.world.y - g.point.y) <= GRIP_TOL
|
||||||
|
) {
|
||||||
|
vertexDrag = { el, gripKind: g.kind, index: g.index };
|
||||||
|
const what = g.kind === 'center' ? 'Zentrum' : `Punkt ${g.index + 1}`;
|
||||||
|
ctx.setStatus(`${what} gegriffen — ziehen zum Verschieben`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Handle-Drag-Start (Task C3fin interaktiv) ──
|
// ── Handle-Drag-Start (Task C3fin interaktiv) ──
|
||||||
// Wenn bereits Elemente selektiert sind und der Klick auf eine Ecke
|
// Wenn bereits Elemente selektiert sind und der Klick auf eine Ecke
|
||||||
// eines davon trifft → Handle-Drag starten statt neu zu wählen.
|
// eines davon trifft → Handle-Drag starten statt neu zu wählen.
|
||||||
@@ -133,6 +258,15 @@ export const selectTool: ToolExtensionV2 = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
move(e, ctx) {
|
move(e, ctx) {
|
||||||
|
// ── Task D9: Vertex-Drag-Preview (nur der Punkt aktualisiert) ──
|
||||||
|
if (vertexDrag) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
const el = c.doc?.getElement(vertexDrag.el.id);
|
||||||
|
if (!el) return;
|
||||||
|
const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y);
|
||||||
|
c.setPreview?.(updated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Live-Skalierung während Handle-Drag:
|
// Live-Skalierung während Handle-Drag:
|
||||||
if (!handleDrag) return;
|
if (!handleDrag) return;
|
||||||
const c = ctx as FullToolContext;
|
const c = ctx as FullToolContext;
|
||||||
@@ -174,6 +308,26 @@ export const selectTool: ToolExtensionV2 = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
up(e, ctx) {
|
up(e, ctx) {
|
||||||
|
// ── Task D9: Vertex-Commit (nur der Punkt, 1 Undo-Schritt) ──
|
||||||
|
if (vertexDrag) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
const el = c.doc?.getElement(vertexDrag.el.id);
|
||||||
|
if (el && c.doc) {
|
||||||
|
const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y);
|
||||||
|
c.doc.transact(() => {
|
||||||
|
c.doc!.updateElement(el.id, {
|
||||||
|
x: updated.x,
|
||||||
|
y: updated.y,
|
||||||
|
properties: updated.properties as CADElement['properties'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const what = vertexDrag.gripKind === 'center' ? 'Zentrum' : `Punkt ${vertexDrag.index + 1}`;
|
||||||
|
ctx.setStatus(`${what} verschoben`);
|
||||||
|
}
|
||||||
|
vertexDrag = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!handleDrag) return;
|
if (!handleDrag) return;
|
||||||
const c = ctx as FullToolContext;
|
const c = ctx as FullToolContext;
|
||||||
if (!c.doc) { handleDrag = null; return; }
|
if (!c.doc) { handleDrag = null; return; }
|
||||||
@@ -224,6 +378,7 @@ export const selectTool: ToolExtensionV2 = {
|
|||||||
|
|
||||||
cancel(ctx) {
|
cancel(ctx) {
|
||||||
handleDrag = null;
|
handleDrag = null;
|
||||||
|
vertexDrag = null;
|
||||||
ctx.setPreview?.(null);
|
ctx.setPreview?.(null);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -572,6 +727,245 @@ export const joinTool: ToolExtensionV2 = makeJoinExplodeAlignTool('join');
|
|||||||
export const explodeTool: ToolExtensionV2 = makeJoinExplodeAlignTool('explode');
|
export const explodeTool: ToolExtensionV2 = makeJoinExplodeAlignTool('explode');
|
||||||
export const alignTool: ToolExtensionV2 = makeJoinExplodeAlignTool('align');
|
export const alignTool: ToolExtensionV2 = makeJoinExplodeAlignTool('align');
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Polyline-Editor (Task D-Finish): Vertices einer Polyline verschieben.
|
||||||
|
// Klick 1: Polyline wählen. Klick 2+: Vertex wählen (Toleranz) und ziehen.
|
||||||
|
// ESC beendet.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
let editPoly: CADElement | null = null;
|
||||||
|
let editVertexIdx = -1;
|
||||||
|
|
||||||
|
export const polylineEditTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'polyline-edit',
|
||||||
|
label: 'Polyline-Editor',
|
||||||
|
icon: '\u2b1a',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['pro'],
|
||||||
|
description: 'Polyline wählen, dann Vertex ziehen',
|
||||||
|
},
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc || !c.selection) return;
|
||||||
|
if (!editPoly) {
|
||||||
|
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
|
||||||
|
if (!hit || hit.type !== 'polyline') {
|
||||||
|
ctx.setStatus('Polyline-Editor: erst eine Polyline anklicken');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editPoly = hit;
|
||||||
|
ctx.setStatus('Polyline-Editor: Vertex ziehen, ESC beendet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Vertex finden in der Nähe:
|
||||||
|
const props = editPoly.properties as Record<string, unknown>;
|
||||||
|
const pts = Array.isArray(props.points)
|
||||||
|
? (props.points as unknown[]).map((p) => p as Pt)
|
||||||
|
: [];
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
if (Math.hypot(pts[i].x - e.world.x, pts[i].y - e.world.y) < 8) {
|
||||||
|
editVertexIdx = i;
|
||||||
|
ctx.setStatus(`Vertex ${i + 1} — ziehen`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.setStatus('Kein Vertex in der Nähe');
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
if (!editPoly || editVertexIdx < 0) return;
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
const props = editPoly.properties as Record<string, unknown>;
|
||||||
|
const pts = Array.isArray(props.points)
|
||||||
|
? (props.points as unknown[]).map((p) => p as Pt)
|
||||||
|
: [];
|
||||||
|
pts[editVertexIdx] = e.world;
|
||||||
|
const preview: CADElement = {
|
||||||
|
...editPoly,
|
||||||
|
properties: { ...props, points: pts },
|
||||||
|
};
|
||||||
|
c.setPreview?.(preview);
|
||||||
|
},
|
||||||
|
up(e, ctx) {
|
||||||
|
if (!editPoly || editVertexIdx < 0) return;
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) { editVertexIdx = -1; return; }
|
||||||
|
const current = c.doc.getElement(editPoly.id);
|
||||||
|
if (!current) { editVertexIdx = -1; return; }
|
||||||
|
const props = current.properties as Record<string, unknown>;
|
||||||
|
const pts = Array.isArray(props.points)
|
||||||
|
? (props.points as unknown[]).map((p) => ({ ...(p as Pt) }))
|
||||||
|
: [];
|
||||||
|
if (editVertexIdx < pts.length) {
|
||||||
|
pts[editVertexIdx] = e.world;
|
||||||
|
const xs = pts.map((p) => p.x);
|
||||||
|
const ys = pts.map((p) => p.y);
|
||||||
|
c.doc.transact(() => {
|
||||||
|
c.doc!.updateElement(editPoly!.id, {
|
||||||
|
x: (Math.min(...xs) + Math.max(...xs)) / 2,
|
||||||
|
y: (Math.min(...ys) + Math.max(...ys)) / 2,
|
||||||
|
width: Math.max(...xs) - Math.min(...xs),
|
||||||
|
height: Math.max(...ys) - Math.min(...ys),
|
||||||
|
properties: { ...props, points: pts },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ctx.setStatus(`Vertex ${editVertexIdx + 1} verschoben`);
|
||||||
|
}
|
||||||
|
editVertexIdx = -1;
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
editPoly = null;
|
||||||
|
editVertexIdx = -1;
|
||||||
|
ctx.setStatus('Polyline-Editor beendet');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Stretch (Task D-Finish): Endpunkte von Linien in einem Auswahlbereich
|
||||||
|
// verschieben. Klick 1: Basis, Klick 2: Ziel (Delta auf alle selektierten
|
||||||
|
// Endpunkte, die in der Nähe des Basispunkts liegen).
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
let stretchBase: Pt | null = null;
|
||||||
|
|
||||||
|
export const stretchTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'stretch',
|
||||||
|
label: 'Stretch',
|
||||||
|
icon: '\u2197',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['pro'],
|
||||||
|
description: 'Linien auswählen, dann Basis→Ziel (Endpunkte in Basisnähe werden gestreckt)',
|
||||||
|
},
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc || !c.selection) return;
|
||||||
|
if (!stretchBase) {
|
||||||
|
if (c.selection.getIds().length === 0) {
|
||||||
|
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
|
||||||
|
if (!hit) {
|
||||||
|
ctx.setStatus('Stretch: zuerst Linien auswählen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c.selection.setIds([hit.id]);
|
||||||
|
}
|
||||||
|
stretchBase = e.world;
|
||||||
|
ctx.setStatus(`Stretch: Zielklick (${c.selection.getIds().length} Linien)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Commit: alle Endpunkte, die dem Basispunkt am nächsten sind, verschieben:
|
||||||
|
const dx = e.world.x - stretchBase.x;
|
||||||
|
const dy = e.world.y - stretchBase.y;
|
||||||
|
const ids = c.selection.getIds();
|
||||||
|
c.doc.transact(() => {
|
||||||
|
for (const id of ids) {
|
||||||
|
const el = c.doc!.getElement(id);
|
||||||
|
if (!el || el.type !== 'line') continue;
|
||||||
|
const props = el.properties as { x1?: number; y1?: number; x2?: number; y2?: number };
|
||||||
|
if (props.x1 === undefined || props.y1 === undefined || props.x2 === undefined || props.y2 === undefined) continue;
|
||||||
|
// Endpunkte die dem Basispunkt am nächsten sind verschieben:
|
||||||
|
const d1 = Math.hypot(props.x1! - stretchBase!.x, props.y1! - stretchBase!.y);
|
||||||
|
const d2 = Math.hypot(props.x2! - stretchBase!.x, props.y2! - stretchBase!.y);
|
||||||
|
if (d1 < d2) {
|
||||||
|
props.x1 = props.x1! + dx;
|
||||||
|
props.y1 = props.y1! + dy;
|
||||||
|
} else {
|
||||||
|
props.x2 = props.x2! + dx;
|
||||||
|
props.y2 = props.y2! + dy;
|
||||||
|
}
|
||||||
|
const nx1 = props.x1!;
|
||||||
|
const ny1 = props.y1!;
|
||||||
|
const nx2 = props.x2!;
|
||||||
|
const ny2 = props.y2!;
|
||||||
|
c.doc!.updateElement(id, {
|
||||||
|
x: (nx1 + nx2) / 2,
|
||||||
|
y: (ny1 + ny2) / 2,
|
||||||
|
width: Math.abs(nx2 - nx1),
|
||||||
|
height: Math.abs(ny2 - ny1),
|
||||||
|
properties: props,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.setStatus(`Stretch: ${ids.length} Linie(n) gestreckt \u0394(${dx.toFixed(0)}, ${dy.toFixed(0)})`);
|
||||||
|
stretchBase = null;
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
stretchBase = null;
|
||||||
|
ctx.setStatus('Stretch abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Break (Task D-Finish): Linie an einem Punkt in zwei Teile splitten.
|
||||||
|
// Klick auf Linie → zwei neue Linien, Original wird entfernt.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
export const breakTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'break',
|
||||||
|
label: 'Break',
|
||||||
|
icon: '\u2702',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['pro'],
|
||||||
|
description: 'Linie anklicken — wird am Klickpunkt geteilt',
|
||||||
|
},
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc || !c.selection) return;
|
||||||
|
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
|
||||||
|
if (!hit || hit.type !== 'line') {
|
||||||
|
ctx.setStatus('Break: Linie anklicken');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const props = hit.properties as { x1?: number; y1?: number; x2?: number; y2?: number };
|
||||||
|
if (props.x1 === undefined || props.y1 === undefined || props.x2 === undefined || props.y2 === undefined) return;
|
||||||
|
|
||||||
|
// Klick auf die Linie projizieren (nächster Punkt auf der Linie):
|
||||||
|
const x1 = props.x1 as number;
|
||||||
|
const y1 = props.y1 as number;
|
||||||
|
const x2 = props.x2 as number;
|
||||||
|
const y2 = props.y2 as number;
|
||||||
|
const dx = x2 - x1;
|
||||||
|
const dy = y2 - y1;
|
||||||
|
const lenSq = dx * dx + dy * dy;
|
||||||
|
if (lenSq === 0) return;
|
||||||
|
const t = Math.max(0, Math.min(1, ((e.world.x - x1) * dx + (e.world.y - y1) * dy) / lenSq));
|
||||||
|
const breakX = x1 + t * dx;
|
||||||
|
const breakY = y1 + t * dy;
|
||||||
|
|
||||||
|
c.doc.transact(() => {
|
||||||
|
// Original entfernen:
|
||||||
|
c.doc!.deleteElements([hit.id]);
|
||||||
|
// Zwei neue Linien hinzufügen:
|
||||||
|
c.doc!.addElement({
|
||||||
|
...hit,
|
||||||
|
id: uid('brk'),
|
||||||
|
x: (x1 + breakX) / 2,
|
||||||
|
y: (y1 + breakY) / 2,
|
||||||
|
width: Math.abs(breakX - x1),
|
||||||
|
height: Math.abs(breakY - y1),
|
||||||
|
properties: { ...props, x2: breakX, y2: breakY },
|
||||||
|
});
|
||||||
|
c.doc!.addElement({
|
||||||
|
...hit,
|
||||||
|
id: uid('brk'),
|
||||||
|
x: (breakX + x2) / 2,
|
||||||
|
y: (breakY + y2) / 2,
|
||||||
|
width: Math.abs(x2 - breakX),
|
||||||
|
height: Math.abs(y2 - breakY),
|
||||||
|
properties: { ...props, x1: breakX, y1: breakY },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ctx.setStatus(`Break: Linie bei (${breakX.toFixed(0)}, ${breakY.toFixed(0)}) geteilt`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// Array rect / polar (Task D-Extensions):
|
// Array rect / polar (Task D-Extensions):
|
||||||
// Vervielfältigt die aktuelle Selektion in einem Raster bzw. im Kreis.
|
// Vervielfältigt die aktuelle Selektion in einem Raster bzw. im Kreis.
|
||||||
@@ -683,6 +1077,110 @@ function makeArrayTool(mode: 'array-rect' | 'array-polar'): ToolExtensionV2 {
|
|||||||
export const arrayRectTool: ToolExtensionV2 = makeArrayTool('array-rect');
|
export const arrayRectTool: ToolExtensionV2 = makeArrayTool('array-rect');
|
||||||
export const arrayPolarTool: ToolExtensionV2 = makeArrayTool('array-polar');
|
export const arrayPolarTool: ToolExtensionV2 = makeArrayTool('array-polar');
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task D10-Rest: array-path — Elemente entlang einer Polyline
|
||||||
|
// (Pfad) verteilen. Klick1: Quelle (Auto-HitSelect), Klick2: auf
|
||||||
|
// die Pfad-Polyline klicken. count Kopien gleichmäßig über die
|
||||||
|
// Gesamtlänge (s = i/(count-1)·total), ALLES in 1 Transaktion.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let arrayPathBase: Pt | null = null;
|
||||||
|
|
||||||
|
/** Punkt auf einer Polyline bei kumulierter Länge s (0..total). */
|
||||||
|
function pointAlongPolyline(pts: Pt[], s: number): Pt {
|
||||||
|
if (pts.length === 0) return { x: 0, y: 0 };
|
||||||
|
if (pts.length === 1 || s <= 0) return { ...pts[0] };
|
||||||
|
let remaining = s;
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
const dx = pts[i + 1].x - pts[i].x;
|
||||||
|
const dy = pts[i + 1].y - pts[i].y;
|
||||||
|
const segLen = Math.hypot(dx, dy);
|
||||||
|
if (segLen <= 0) continue;
|
||||||
|
if (remaining <= segLen) {
|
||||||
|
const t = remaining / segLen;
|
||||||
|
return { x: pts[i].x + dx * t, y: pts[i].y + dy * t };
|
||||||
|
}
|
||||||
|
remaining -= segLen;
|
||||||
|
}
|
||||||
|
return { ...pts[pts.length - 1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const arrayPathTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'array-path',
|
||||||
|
label: 'Array Pfad',
|
||||||
|
icon: '∿',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['pro'],
|
||||||
|
description: 'Array entlang Pfad: Auswahl treffen, dann auf Pfad-Polyline klicken (Task D10)',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'count', label: 'Anzahl', type: 'number', min: 2, max: 100 },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc || !c.selection) return;
|
||||||
|
if (!arrayPathBase) {
|
||||||
|
if (c.selection.getIds().length === 0) {
|
||||||
|
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
|
||||||
|
if (!hit) {
|
||||||
|
ctx.setStatus('Array Pfad: zuerst Element(e) auswählen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c.selection.setIds([hit.id]);
|
||||||
|
}
|
||||||
|
arrayPathBase = e.world;
|
||||||
|
ctx.setStatus(`Array Pfad: auf Pfad-Polyline klicken (${c.selection.getIds().length} Element(e))`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Zweiter Klick: muss eine Polyline als Pfad treffen
|
||||||
|
const pathHit = c.selection.hitTest(e.world.x, e.world.y, 8);
|
||||||
|
if (!pathHit || (pathHit.type !== 'polyline' && pathHit.type !== 'polygon')) {
|
||||||
|
ctx.setStatus('Array Pfad: zweiter Klick muss eine Polyline als Pfad treffen');
|
||||||
|
arrayPathBase = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pathPts = (pathHit.properties as Record<string, unknown>).points as Pt[] | undefined;
|
||||||
|
if (!pathPts || pathPts.length < 2) {
|
||||||
|
ctx.setStatus('Array Pfad: Pfad-Polyline hat zu wenige Punkte');
|
||||||
|
arrayPathBase = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const count = Math.max(2, (c.options.count as number | undefined) ?? 5);
|
||||||
|
const ids = c.selection.getIds().filter((id) => id !== pathHit.id);
|
||||||
|
// Gesamtlänge des Pfads
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < pathPts.length - 1; i++) {
|
||||||
|
total += Math.hypot(pathPts[i + 1].x - pathPts[i].x, pathPts[i + 1].y - pathPts[i].y);
|
||||||
|
}
|
||||||
|
c.doc.transact(() => {
|
||||||
|
for (const id of ids) {
|
||||||
|
const el = c.doc!.getElement(id);
|
||||||
|
if (!el) continue;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const s = total * (i / Math.max(1, count - 1));
|
||||||
|
const pos = pointAlongPolyline(pathPts, s);
|
||||||
|
c.doc!.addElement({
|
||||||
|
...el,
|
||||||
|
id: uid('arrp'),
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
} as CADElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
arrayPathBase = null;
|
||||||
|
ctx.setStatus(`Array Pfad: ${ids.length * count} Kopien entlang Pfad verteilt`);
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
arrayPathBase = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Array Pfad abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// Chamfer (Task D-Ext): schneidet zwei sich schneidende Linien an einer
|
// Chamfer (Task D-Ext): schneidet zwei sich schneidende Linien an einer
|
||||||
// festen Distanz vom Schnittpunkt und verbindet die Enden mit einer
|
// festen Distanz vom Schnittpunkt und verbindet die Enden mit einer
|
||||||
@@ -1083,11 +1581,11 @@ export const coreModifyPlugin: PluginV2 = {
|
|||||||
manifest: {
|
manifest: {
|
||||||
id: 'core-modify',
|
id: 'core-modify',
|
||||||
name: 'Bearbeiten (Kern)',
|
name: 'Bearbeiten (Kern)',
|
||||||
version: '1.8.0',
|
version: '1.9.0',
|
||||||
author: 'web-cad team',
|
author: 'web-cad team',
|
||||||
description: 'Auswahl, Schraffur, Move/Copy/Rotate/Scale/Mirror/Delete/Offset/Trim/Extend/Fillet/Arrays/Chamfer/Lengthen/Join/Explode/Align (V2).',
|
description: 'Auswahl, Schraffur, Move/Copy/Rotate/Scale/Mirror/Delete/Offset/Trim/Extend/Fillet/Arrays/Chamfer/Lengthen/Join/Explode/Align/Polyline-Edit/Stretch/Break (V2).',
|
||||||
category: 'tools',
|
category: 'tools',
|
||||||
enabledByDefault: true,
|
enabledByDefault: true,
|
||||||
},
|
},
|
||||||
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool],
|
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool, arrayPathTool],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* event-tools – Built-in V2-Plugin (Task A4.9).
|
* event-tools – Built-in V2-Plugin (Tasks A4.9 + E1).
|
||||||
*
|
*
|
||||||
* Fachwerkzeuge für Veranstaltungsplanung: chair, seating-row,
|
* Fachwerkzeuge für Veranstaltungsplanung: chair (Einzelnklick),
|
||||||
* seating-block, table, stage — alle Einzelklick-Platzierer über
|
* seating-row + seating-block (Drag-Interaktion mit Live-Preview
|
||||||
* den (durch BUG-1-Fix korrigierten) SeatingService.
|
* und Options-Schema), table + stage (Einzelnklick).
|
||||||
*
|
*
|
||||||
* Jede Platzierung = doc.transact → 1 Undo-Schritt (Multi-Element-Sets
|
* Jede Platzierung = doc.transact → 1 Undo-Schritt (Multi-Element-Sets
|
||||||
* wie seating-block erscheinen/verschwinden gemeinsam).
|
* wie seating-block erscheinen/verschwinden gemeinsam).
|
||||||
|
*
|
||||||
|
* SeatingService ist durch BUG-1-Fix deterministisch (rowIds unique).
|
||||||
*/
|
*/
|
||||||
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
|
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
|
||||||
import type { CADElement } from '../../../types/cad.types';
|
import type { CADElement } from '../../../types/cad.types';
|
||||||
|
import type { Pt } from '../../../tools/modification/geometry';
|
||||||
import { SeatingService } from '../../../services/seatingService';
|
import { SeatingService } from '../../../services/seatingService';
|
||||||
|
|
||||||
const seating = new SeatingService();
|
const seating = new SeatingService();
|
||||||
|
|
||||||
|
// ─── Drag-Sitzungs-State für row/block ─────────────────────
|
||||||
|
let dragStart: Pt | null = null;
|
||||||
|
|
||||||
|
function optsOf(ctx: FullToolContext): { layerId: string } {
|
||||||
|
return {
|
||||||
|
layerId: (ctx.options.layerId as string | undefined) ?? 'layer-0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Erzeugt ein Einzelklick-Platzierer-Werkzeug über einen placer. */
|
/** Erzeugt ein Einzelklick-Platzierer-Werkzeug über einen placer. */
|
||||||
function makePlaceTool(
|
function makePlaceTool(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -36,7 +48,7 @@ function makePlaceTool(
|
|||||||
down(e, ctx) {
|
down(e, ctx) {
|
||||||
const c = ctx as FullToolContext;
|
const c = ctx as FullToolContext;
|
||||||
if (!c.doc) return;
|
if (!c.doc) return;
|
||||||
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
|
const { layerId } = optsOf(ctx);
|
||||||
const placed = placer(e.world.x, e.world.y, layerId);
|
const placed = placer(e.world.x, e.world.y, layerId);
|
||||||
const els = Array.isArray(placed) ? placed : [placed];
|
const els = Array.isArray(placed) ? placed : [placed];
|
||||||
c.doc.transact(() => {
|
c.doc.transact(() => {
|
||||||
@@ -48,31 +60,376 @@ function makePlaceTool(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* seating-row (Task E1): Klick1 = Start, Drag = Länge/Vorschau,
|
||||||
|
* up = Commit. Anzahl Stühle wird aus Distanz berechnet (spacing aus Options).
|
||||||
|
*/
|
||||||
|
export const seatingRowToolDrag: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'seating-row',
|
||||||
|
label: 'Stuhldreihe',
|
||||||
|
icon: '\u2500\u2570\u2500',
|
||||||
|
ribbonTab: 'insert',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Reihe: Start klicken, Ende ziehen. Stuhlzahl aus Länge.',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'spacing', label: 'Abstand (cm)', type: 'number', min: 20, max: 200 },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
dragStart = e.world;
|
||||||
|
ctx.setStatus('Reihe: Endpunkt ziehen');
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
if (!dragStart) return;
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
const { layerId } = optsOf(c);
|
||||||
|
const spacing = (c.options.spacing as number | undefined) ?? 50;
|
||||||
|
const length = Math.hypot(e.world.x - dragStart.x, e.world.y - dragStart.y);
|
||||||
|
const count = Math.max(2, Math.floor(length / spacing));
|
||||||
|
const preview = seating.createSeatingRow(dragStart.x, dragStart.y, layerId, { count, spacing });
|
||||||
|
c.setPreview?.(preview[0] ?? null); // Legacy-Vorschau zeigt ersten Stuhl als Referenz
|
||||||
|
ctx.setStatus(`Reihe: ${count} Stühle (${length.toFixed(0)}mm)`);
|
||||||
|
},
|
||||||
|
up(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!dragStart || !c.doc) { dragStart = null; return; }
|
||||||
|
const { layerId } = optsOf(c);
|
||||||
|
const spacing = (c.options.spacing as number | undefined) ?? 50;
|
||||||
|
const length = Math.hypot(e.world.x - dragStart.x, e.world.y - dragStart.y);
|
||||||
|
const count = Math.max(2, Math.floor(length / spacing));
|
||||||
|
const els = seating.createSeatingRow(dragStart.x, dragStart.y, layerId, { count, spacing });
|
||||||
|
c.doc.transact(() => {
|
||||||
|
for (const el of els) c.doc!.addElement(el);
|
||||||
|
});
|
||||||
|
ctx.setStatus(`Reihe: ${count} Stühle platziert`);
|
||||||
|
dragStart = null;
|
||||||
|
c.setPreview?.(null);
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
dragStart = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Reihe abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* seating-block (Task E1): Klick1 = Start-Ecke, Drag = Block-Dimension,
|
||||||
|
* up = Commit. rows/cols aus Distanz + spacing berechnet.
|
||||||
|
*/
|
||||||
|
export const seatingBlockToolDrag: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'seating-block',
|
||||||
|
label: 'Stuhlblock',
|
||||||
|
icon: '\u2565',
|
||||||
|
ribbonTab: 'insert',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Block: Start-Ecke klicken, Gegenecke ziehen.',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'colSpacing', label: 'Sitzabstand X (cm)', type: 'number', min: 30, max: 200 },
|
||||||
|
{ key: 'rowSpacing', label: 'Reihenabstand Y (cm)', type: 'number', min: 30, max: 200 },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
dragStart = e.world;
|
||||||
|
ctx.setStatus('Block: Gegenecke ziehen');
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
if (!dragStart) return;
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
const { layerId } = optsOf(c);
|
||||||
|
const colSpacing = (c.options.colSpacing as number | undefined) ?? 50;
|
||||||
|
const rowSpacing = (c.options.rowSpacing as number | undefined) ?? 50;
|
||||||
|
const w = Math.abs(e.world.x - dragStart.x);
|
||||||
|
const h = Math.abs(e.world.y - dragStart.y);
|
||||||
|
const cols = Math.max(2, Math.floor(w / colSpacing));
|
||||||
|
const rows = Math.max(2, Math.floor(h / rowSpacing));
|
||||||
|
ctx.setStatus(`Block: ${rows} Reihen × ${cols} Stühle = ${rows * cols} Sitzplätze`);
|
||||||
|
},
|
||||||
|
up(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!dragStart || !c.doc) { dragStart = null; return; }
|
||||||
|
const { layerId } = optsOf(c);
|
||||||
|
const colSpacing = (c.options.colSpacing as number | undefined) ?? 50;
|
||||||
|
const rowSpacing = (c.options.rowSpacing as number | undefined) ?? 50;
|
||||||
|
const w = Math.abs(e.world.x - dragStart.x);
|
||||||
|
const h = Math.abs(e.world.y - dragStart.y);
|
||||||
|
const cols = Math.max(2, Math.floor(w / colSpacing));
|
||||||
|
const rows = Math.max(2, Math.floor(h / rowSpacing));
|
||||||
|
const centerX = (dragStart.x + e.world.x) / 2;
|
||||||
|
const centerY = (dragStart.y + e.world.y) / 2;
|
||||||
|
const els = seating.createSeatingBlock(centerX, centerY, layerId, { rows, cols, colSpacing, rowSpacing });
|
||||||
|
c.doc.transact(() => {
|
||||||
|
for (const el of els) c.doc!.addElement(el);
|
||||||
|
});
|
||||||
|
ctx.setStatus(`Block: ${rows}×${cols} = ${els.length} Stühle platziert`);
|
||||||
|
dragStart = null;
|
||||||
|
c.setPreview?.(null);
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
dragStart = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Block abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const chairTool = makePlaceTool('chair', 'Stuhl', '\u2570',
|
export const chairTool = makePlaceTool('chair', 'Stuhl', '\u2570',
|
||||||
(x, y, layerId) => seating.createChair(x, y, layerId), 'Stuhl');
|
(x, y, layerId) => seating.createChair(x, y, layerId), 'Stuhl');
|
||||||
|
|
||||||
export const seatingRowTool = makePlaceTool('seating-row', 'Stuhldreihe', '\u2500\u2570\u2500',
|
|
||||||
(x, y, layerId) => seating.createSeatingRow(x, y, layerId), 'Reihe');
|
|
||||||
|
|
||||||
export const seatingBlockTool = makePlaceTool('seating-block', 'Stuhlblock', '\u2565',
|
|
||||||
(x, y, layerId) => seating.createSeatingBlock(x, y, layerId), 'Block');
|
|
||||||
|
|
||||||
export const tableTool = makePlaceTool('table', 'Tisch', '\u25cb',
|
export const tableTool = makePlaceTool('table', 'Tisch', '\u25cb',
|
||||||
(x, y, layerId) => seating.createTable(x, y, layerId), 'Tisch');
|
(x, y, layerId) => seating.createTable(x, y, layerId), 'Tisch');
|
||||||
|
|
||||||
export const stageTool = makePlaceTool('stage', 'Bühne', '\u25ad',
|
export const stageTool = makePlaceTool('stage', 'Bühne', '\u25ad',
|
||||||
(x, y, layerId) => seating.createStage(x, y, layerId), 'Bühne');
|
(x, y, layerId) => seating.createStage(x, y, layerId), 'Bühne');
|
||||||
|
|
||||||
/** V2-Plugin: Event-Domain (Task A4.9). */
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task E4: Vorlagenkatalog — vorkonfigurierte Event-Bestuhlungs-
|
||||||
|
// Sets als Klick-Platzierer mit Standard-Abmessungen.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
export const kinoTemplateTool = makePlaceTool('tpl-kino', 'Kino-Reihen', '\u2500\u2500',
|
||||||
|
(x, y, layerId) => {
|
||||||
|
const rows: CADElement[] = [];
|
||||||
|
for (let r = 0; r < 5; r++) {
|
||||||
|
rows.push(...seating.createSeatingRow(x, y + r * 120, layerId, { count: 15, spacing: 55 }));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, 'Kino-Bestuhlung (5×15)');
|
||||||
|
|
||||||
|
export const banquetTemplateTool = makePlaceTool('tpl-bankett', 'Bankett-Rundtisch', '\u25cb',
|
||||||
|
(x, y, layerId) => {
|
||||||
|
const els: CADElement[] = [];
|
||||||
|
els.push(seating.createTable(x, y, layerId, { width: 180, height: 180 }));
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const angle = (2 * Math.PI * i) / 8;
|
||||||
|
els.push(seating.createChair(x + 140 * Math.cos(angle), y + 140 * Math.sin(angle), layerId));
|
||||||
|
}
|
||||||
|
return els;
|
||||||
|
}, 'Bankett-Rundtisch (8 Stühle)');
|
||||||
|
|
||||||
|
export const standingTableTemplateTool = makePlaceTool('tpl-standing', 'Stehtisch', '\u25d1',
|
||||||
|
(x, y, layerId) => {
|
||||||
|
const els: CADElement[] = [];
|
||||||
|
els.push(seating.createTable(x, y, layerId, { width: 80, height: 80 }));
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const angle = (2 * Math.PI * i) / 5;
|
||||||
|
els.push(seating.createChair(x + 70 * Math.cos(angle), y + 70 * Math.sin(angle), layerId));
|
||||||
|
}
|
||||||
|
return els;
|
||||||
|
}, 'Stehtisch (5 Stühle)');
|
||||||
|
|
||||||
|
export const podiumTemplateTool = makePlaceTool('tpl-podium', 'Podest', '\u25ac',
|
||||||
|
(x, y, layerId) => seating.createStage(x, y, layerId, { width: 300, height: 200 }), 'Podest');
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Arc-Row (Task E2): Reihen entlang Kreisbogen um Bühnenpunkt.
|
||||||
|
// Klick 1: Zentrum (Bühne). Drag: Radius + Startwinkel.
|
||||||
|
// Klick 2: Endwinkel → Commit.
|
||||||
|
// Phasen: 0=Zentrum setzen, 1=Radius+Start, 2=Endwinkel → commit
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
let arcCenter: Pt | null = null;
|
||||||
|
let arcStartPt: Pt | null = null;
|
||||||
|
let arcPhase = 0;
|
||||||
|
|
||||||
|
export const arcRowTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'arc-row',
|
||||||
|
label: 'Arc-Row',
|
||||||
|
icon: '\u2312',
|
||||||
|
ribbonTab: 'insert',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Bogenreihe: Zentrum, Startpunkt, Endpunkt klicken',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'arcSpacing', label: 'Abstand (cm)', type: 'number', min: 20, max: 200 },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
const { layerId } = optsOf(c);
|
||||||
|
const spacing = (c.options.arcSpacing as number | undefined) ?? 50;
|
||||||
|
if (arcPhase === 0) {
|
||||||
|
arcCenter = e.world;
|
||||||
|
arcPhase = 1;
|
||||||
|
ctx.setStatus('Arc-Row: Radius ziehen (Startpunkt)');
|
||||||
|
} else if (arcPhase === 1 && arcCenter) {
|
||||||
|
arcStartPt = e.world;
|
||||||
|
arcPhase = 2;
|
||||||
|
ctx.setStatus('Arc-Row: Endwinkel klicken');
|
||||||
|
} else if (arcPhase === 2 && arcCenter && arcStartPt && c.doc) {
|
||||||
|
const els = seating.createArcRow(arcCenter.x, arcCenter.y, Math.hypot(arcStartPt.x - arcCenter.x, arcStartPt.y - arcCenter.y),
|
||||||
|
(Math.atan2(arcStartPt.y - arcCenter.y, arcStartPt.x - arcCenter.x) * 180) / Math.PI,
|
||||||
|
(Math.atan2(e.world.y - arcCenter.y, e.world.x - arcCenter.x) * 180) / Math.PI,
|
||||||
|
layerId, spacing);
|
||||||
|
c.doc.transact(() => {
|
||||||
|
for (const el of els) c.doc!.addElement(el);
|
||||||
|
});
|
||||||
|
ctx.setStatus(`Arc-Row: ${els.length} Stühle platziert`);
|
||||||
|
arcPhase = 0; arcCenter = null; arcStartPt = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
arcPhase = 0; arcCenter = null; arcStartPt = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Arc-Row abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** V2-Plugin: Event-Domain (Tasks A4.9 + E1). */
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task E7: Event-Elemente-Polish — curtain, spotlight, barrier
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Vorhang: wellige Polyline (Standardbreite 400) über der Bühne. */
|
||||||
|
export const curtainTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'curtain',
|
||||||
|
label: 'Vorhang',
|
||||||
|
icon: '╭',
|
||||||
|
ribbonTab: 'insert',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Vorhang entlang der Bühnen-Oberkante platzieren',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'stageHeight', label: 'Höhe (cm)', type: 'number' },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) return;
|
||||||
|
const { layerId } = optsOf(ctx);
|
||||||
|
const stageHeight = (ctx.options.stageHeight as number | undefined) ?? 120;
|
||||||
|
const width = 400;
|
||||||
|
// Wellenform: 24 Punkte, Sinus gedämpft an den Enden
|
||||||
|
const pts: Pt[] = [];
|
||||||
|
const SEG = 24;
|
||||||
|
for (let i = 0; i <= SEG; i++) {
|
||||||
|
const t = i / SEG;
|
||||||
|
const x = e.world.x - width / 2 + width * t;
|
||||||
|
const y = e.world.y + Math.sin(t * Math.PI) * (stageHeight / 4);
|
||||||
|
pts.push({ x, y });
|
||||||
|
}
|
||||||
|
const el: CADElement = {
|
||||||
|
id: `curtain-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
type: 'curtain',
|
||||||
|
layerId,
|
||||||
|
x: e.world.x,
|
||||||
|
y: e.world.y,
|
||||||
|
width,
|
||||||
|
height: stageHeight,
|
||||||
|
properties: { points: pts, stageHeight, stroke: '#7a4a8f', strokeWidth: 2 },
|
||||||
|
};
|
||||||
|
c.doc.transact(() => { c.doc!.addElement(el); });
|
||||||
|
ctx.setStatus(`Vorhang platziert (${width} breit, ${stageHeight} cm hoch)`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Scheinwerfer: Lichtkegel in angle-Richtung mit Farbe. */
|
||||||
|
export const spotlightTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'spotlight',
|
||||||
|
label: 'Scheinwerfer',
|
||||||
|
icon: '▶',
|
||||||
|
ribbonTab: 'insert',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Scheinwerfer-Kegel platzieren (Winkel + Farbe aus Optionen)',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'angle', label: 'Winkel (°)', type: 'number' },
|
||||||
|
{ key: 'color', label: 'Lichtfarbe', type: 'color' },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) return;
|
||||||
|
const { layerId } = optsOf(ctx);
|
||||||
|
const angle = (ctx.options.angle as number | undefined) ?? 0;
|
||||||
|
const color = (ctx.options.color as string | undefined) ?? '#ffcc00';
|
||||||
|
const length = 300;
|
||||||
|
const halfAngle = 20;
|
||||||
|
const rad = (angle * Math.PI) / 180;
|
||||||
|
const radHalf = (halfAngle * Math.PI) / 180;
|
||||||
|
const p1 = { x: e.world.x + length * Math.cos(rad - radHalf), y: e.world.y + length * Math.sin(rad - radHalf) };
|
||||||
|
const p2 = { x: e.world.x + length * Math.cos(rad + radHalf), y: e.world.y + length * Math.sin(rad + radHalf) };
|
||||||
|
const pts: Pt[] = [
|
||||||
|
{ x: e.world.x, y: e.world.y },
|
||||||
|
p1,
|
||||||
|
p2,
|
||||||
|
{ x: e.world.x, y: e.world.y },
|
||||||
|
];
|
||||||
|
const el: CADElement = {
|
||||||
|
id: `spotlight-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
type: 'spotlight',
|
||||||
|
layerId,
|
||||||
|
x: e.world.x,
|
||||||
|
y: e.world.y,
|
||||||
|
width: length,
|
||||||
|
height: length,
|
||||||
|
properties: { points: pts, angle, color },
|
||||||
|
};
|
||||||
|
c.doc.transact(() => { c.doc!.addElement(el); });
|
||||||
|
ctx.setStatus(`Scheinwerfer platziert (${angle}°, ${color})`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Absperrung: Kette aus Kreis-Paaren entlang Standardlänge 200. */
|
||||||
|
export const barrierTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'barrier',
|
||||||
|
label: 'Absperrung',
|
||||||
|
icon: '⛴',
|
||||||
|
ribbonTab: 'insert',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Absperr-Kette (Kreis-Paare) platzieren',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'length', label: 'Länge', type: 'number' },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) return;
|
||||||
|
const { layerId } = optsOf(ctx);
|
||||||
|
const length = (ctx.options.length as number | undefined) ?? 200;
|
||||||
|
const circles: Array<{ x: number; y: number; r: number }> = [];
|
||||||
|
const pairs = 6;
|
||||||
|
for (let i = 0; i < pairs; i++) {
|
||||||
|
const t = (i + 0.5) / pairs;
|
||||||
|
const cx = e.world.x - length / 2 + length * t;
|
||||||
|
circles.push({ x: cx, y: e.world.y - 10, r: 8 });
|
||||||
|
circles.push({ x: cx, y: e.world.y + 10, r: 8 });
|
||||||
|
}
|
||||||
|
const el: CADElement = {
|
||||||
|
id: `barrier-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
type: 'barrier',
|
||||||
|
layerId,
|
||||||
|
x: e.world.x,
|
||||||
|
y: e.world.y,
|
||||||
|
width: length,
|
||||||
|
height: 20,
|
||||||
|
properties: { circles, color: '#c0392b' },
|
||||||
|
};
|
||||||
|
c.doc.transact(() => { c.doc!.addElement(el); });
|
||||||
|
ctx.setStatus(`Absperrung platziert (${length} lang, ${pairs} Kettenglieder)`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const eventToolsV2Plugin: PluginV2 = {
|
export const eventToolsV2Plugin: PluginV2 = {
|
||||||
manifest: {
|
manifest: {
|
||||||
id: 'event-tools',
|
id: 'event-tools',
|
||||||
name: 'Event-Bestuhlung',
|
name: 'Event-Bestuhlung',
|
||||||
version: '2.0.0',
|
version: '2.2.0',
|
||||||
author: 'web-cad team',
|
author: 'web-cad team',
|
||||||
description: 'Stühle, Reihen, Blöcke, Tische, Bühne (V2).',
|
description: 'Stühle, Reihen (Drag), Blöcke (Drag), Tische, Bühne, Vorhang, Scheinwerfer, Absperrung (V2).',
|
||||||
category: 'elements',
|
category: 'elements',
|
||||||
enabledByDefault: true,
|
enabledByDefault: true,
|
||||||
},
|
},
|
||||||
tools: [chairTool, seatingRowTool, seatingBlockTool, tableTool, stageTool],
|
tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,587 @@
|
|||||||
|
{
|
||||||
|
"folderId": "cat-arch",
|
||||||
|
"label": "Architektur-Katalog",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "arch-1",
|
||||||
|
"name": "Tür 900",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a1a",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": 0,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": 900,
|
||||||
|
"y2": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a1b",
|
||||||
|
"type": "arc",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1800,
|
||||||
|
"height": 1800,
|
||||||
|
"properties": {
|
||||||
|
"radius": 900,
|
||||||
|
"startAngle": 0,
|
||||||
|
"endAngle": 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-2",
|
||||||
|
"name": "Doppel-Tür 1800",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a2a",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": -900,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": -450,
|
||||||
|
"y2": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a2b",
|
||||||
|
"type": "arc",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -900,
|
||||||
|
"y": 0,
|
||||||
|
"width": 900,
|
||||||
|
"height": 900,
|
||||||
|
"properties": {
|
||||||
|
"radius": 450,
|
||||||
|
"startAngle": 0,
|
||||||
|
"endAngle": 90
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a2c",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": 450,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": 900,
|
||||||
|
"y2": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a2d",
|
||||||
|
"type": "arc",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 900,
|
||||||
|
"y": 0,
|
||||||
|
"width": 900,
|
||||||
|
"height": 900,
|
||||||
|
"properties": {
|
||||||
|
"radius": 450,
|
||||||
|
"startAngle": 90,
|
||||||
|
"endAngle": 180
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-3",
|
||||||
|
"name": "Tür 1200",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a3a",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": 0,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": 1200,
|
||||||
|
"y2": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a3b",
|
||||||
|
"type": "arc",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 2400,
|
||||||
|
"height": 2400,
|
||||||
|
"properties": {
|
||||||
|
"radius": 1200,
|
||||||
|
"startAngle": 0,
|
||||||
|
"endAngle": 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-4",
|
||||||
|
"name": "Fenster 1200",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a4a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": -30,
|
||||||
|
"width": 1200,
|
||||||
|
"height": 20,
|
||||||
|
"properties": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a4b",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 30,
|
||||||
|
"width": 1200,
|
||||||
|
"height": 20,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-5",
|
||||||
|
"name": "Fenster 2400",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a5a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": -30,
|
||||||
|
"width": 2400,
|
||||||
|
"height": 20,
|
||||||
|
"properties": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a5b",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 30,
|
||||||
|
"width": 2400,
|
||||||
|
"height": 20,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-6",
|
||||||
|
"name": "Wandstück 2m",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a6a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 2000,
|
||||||
|
"height": 240,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#8a8f98"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-7",
|
||||||
|
"name": "Wandstück vertikal 2m",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a7a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 240,
|
||||||
|
"height": 2000,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#8a8f98"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-8",
|
||||||
|
"name": "Treppe (8 Stufen)",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a8s0",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": -350,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": -350,
|
||||||
|
"y2": 1200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a8s1",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": -250,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": -250,
|
||||||
|
"y2": 1200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a8s2",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": -150,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": -150,
|
||||||
|
"y2": 1200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a8s3",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": -50,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": -50,
|
||||||
|
"y2": 1200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a8s4",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": 50,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": 50,
|
||||||
|
"y2": 1200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a8s5",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": 150,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": 150,
|
||||||
|
"y2": 1200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a8s6",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": 250,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": 250,
|
||||||
|
"y2": 1200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a8s7",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": 350,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": 350,
|
||||||
|
"y2": 1200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a8la",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": -450,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": -450,
|
||||||
|
"y2": 1200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a8lb",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": 450,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": 450,
|
||||||
|
"y2": 1200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-9",
|
||||||
|
"name": "Säule Ø50",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a9a",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 500,
|
||||||
|
"height": 500,
|
||||||
|
"properties": {
|
||||||
|
"radius": 250,
|
||||||
|
"fill": "#9aa0a6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-10",
|
||||||
|
"name": "Schreibtisch",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a10a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1800,
|
||||||
|
"height": 800,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#6b7280"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-11",
|
||||||
|
"name": "Bürostuhl",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a11a",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 60,
|
||||||
|
"height": 60,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#5a7d9a",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-12",
|
||||||
|
"name": "Sofa 3-Sitzer",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a12a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 2200,
|
||||||
|
"height": 900,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#5a7d9a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-13",
|
||||||
|
"name": "Einzelbett",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a13a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 900,
|
||||||
|
"height": 2000,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#8fa3bf"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a13b",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": -750,
|
||||||
|
"width": 700,
|
||||||
|
"height": 300,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#d5dbdb"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-14",
|
||||||
|
"name": "Waschbecken",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a14a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 600,
|
||||||
|
"height": 450,
|
||||||
|
"properties": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a14b",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 340,
|
||||||
|
"height": 340,
|
||||||
|
"properties": {
|
||||||
|
"radius": 170,
|
||||||
|
"fill": "#7f9db9"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arch-15",
|
||||||
|
"name": "Küchenzeile 2.4m",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "a15a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 2400,
|
||||||
|
"height": 600,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#3c4043"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a15p0",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -1800,
|
||||||
|
"y": 0,
|
||||||
|
"width": 180,
|
||||||
|
"height": 180,
|
||||||
|
"properties": {
|
||||||
|
"radius": 90,
|
||||||
|
"fill": "#202124"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a15p1",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -1320,
|
||||||
|
"y": 0,
|
||||||
|
"width": 180,
|
||||||
|
"height": 180,
|
||||||
|
"properties": {
|
||||||
|
"radius": 90,
|
||||||
|
"fill": "#202124"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a15p2",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -840,
|
||||||
|
"y": 0,
|
||||||
|
"width": 180,
|
||||||
|
"height": 180,
|
||||||
|
"properties": {
|
||||||
|
"radius": 90,
|
||||||
|
"fill": "#202124"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a15p3",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -360,
|
||||||
|
"y": 0,
|
||||||
|
"width": 180,
|
||||||
|
"height": 180,
|
||||||
|
"properties": {
|
||||||
|
"radius": 90,
|
||||||
|
"fill": "#202124"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
{
|
||||||
|
"folderId": "cat-event",
|
||||||
|
"label": "Event-Katalog",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "evt-1",
|
||||||
|
"name": "Stuhl",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e1",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-2",
|
||||||
|
"name": "Tisch rund 180cm",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e2a",
|
||||||
|
"type": "table",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1800,
|
||||||
|
"height": 1800,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#8b7355"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-3",
|
||||||
|
"name": "Tisch rund 120cm",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e3a",
|
||||||
|
"type": "table",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1200,
|
||||||
|
"height": 1200,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#8b7355"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-4",
|
||||||
|
"name": "Stehtisch 80cm",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e4a",
|
||||||
|
"type": "table",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 800,
|
||||||
|
"height": 800,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#a08f6a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-5",
|
||||||
|
"name": "Bühne 3x2m",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e5a",
|
||||||
|
"type": "stage",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 3000,
|
||||||
|
"height": 2000,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-6",
|
||||||
|
"name": "Bühne 4x3m",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e6a",
|
||||||
|
"type": "stage",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 4000,
|
||||||
|
"height": 3000,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-7",
|
||||||
|
"name": "Podest 1x1m",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e7a",
|
||||||
|
"type": "stage",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1000,
|
||||||
|
"height": 1000,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-8",
|
||||||
|
"name": "Sitzreihe (10 Stühle)",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e2",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -405,
|
||||||
|
"y": 0,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e3",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -315,
|
||||||
|
"y": 0,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e4",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -225,
|
||||||
|
"y": 0,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e5",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -135,
|
||||||
|
"y": 0,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e6",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -45,
|
||||||
|
"y": 0,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e7",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 45,
|
||||||
|
"y": 0,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e8",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 135,
|
||||||
|
"y": 0,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e9",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 225,
|
||||||
|
"y": 0,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e10",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 315,
|
||||||
|
"y": 0,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e11",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 405,
|
||||||
|
"y": 0,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-9",
|
||||||
|
"name": "Bankett-Rundtisch (8 Stühle)",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e9a",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1800,
|
||||||
|
"height": 1800,
|
||||||
|
"properties": {
|
||||||
|
"radius": 900,
|
||||||
|
"fill": "#8b7355"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e12",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 1200,
|
||||||
|
"y": 0,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e13",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 848,
|
||||||
|
"y": 848,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e14",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 1200,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e15",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -848,
|
||||||
|
"y": 848,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e16",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -1200,
|
||||||
|
"y": 0,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e17",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -848,
|
||||||
|
"y": -848,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e18",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": -1200,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e19",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 848,
|
||||||
|
"y": -848,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-10",
|
||||||
|
"name": "Tischgruppe (4 Stühle)",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e10a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1600,
|
||||||
|
"height": 800,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#8b7355"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e20",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -400,
|
||||||
|
"y": -580,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e21",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 400,
|
||||||
|
"y": -580,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e22",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -400,
|
||||||
|
"y": 580,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e23",
|
||||||
|
"type": "chair",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 400,
|
||||||
|
"y": 580,
|
||||||
|
"width": 45,
|
||||||
|
"height": 45,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4a90d9",
|
||||||
|
"outlineColor": "#2a5a99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-11",
|
||||||
|
"name": "Flipchart",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e11a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 700,
|
||||||
|
"height": 500,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#546b7a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e11b",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": 0,
|
||||||
|
"y1": 250,
|
||||||
|
"x2": 0,
|
||||||
|
"y2": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-12",
|
||||||
|
"name": "Pinnwand 120cm",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e12a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1200,
|
||||||
|
"height": 150,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#7a6550"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-13",
|
||||||
|
"name": "Kaffeetafel 2m",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e13a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 2000,
|
||||||
|
"height": 600,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#6b5340"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-14",
|
||||||
|
"name": "Garderobe 1m",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e14a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1000,
|
||||||
|
"height": 300,
|
||||||
|
"properties": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e14h0",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -300,
|
||||||
|
"y": 0,
|
||||||
|
"width": 180,
|
||||||
|
"height": 180,
|
||||||
|
"properties": {
|
||||||
|
"radius": 90
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e14h1",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": -150,
|
||||||
|
"y": 0,
|
||||||
|
"width": 180,
|
||||||
|
"height": 180,
|
||||||
|
"properties": {
|
||||||
|
"radius": 90
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e14h2",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 180,
|
||||||
|
"height": 180,
|
||||||
|
"properties": {
|
||||||
|
"radius": 90
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e14h3",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 150,
|
||||||
|
"y": 0,
|
||||||
|
"width": 180,
|
||||||
|
"height": 180,
|
||||||
|
"properties": {
|
||||||
|
"radius": 90
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e14h4",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 300,
|
||||||
|
"y": 0,
|
||||||
|
"width": 180,
|
||||||
|
"height": 180,
|
||||||
|
"properties": {
|
||||||
|
"radius": 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-15",
|
||||||
|
"name": "Teppichläufer 2m",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "e15a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 2000,
|
||||||
|
"height": 200,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#7a5230"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
{
|
||||||
|
"folderId": "cat-land",
|
||||||
|
"label": "Landschafts-Katalog",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "land-1",
|
||||||
|
"name": "Baum (groß)",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l1a",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": -200,
|
||||||
|
"width": 2200,
|
||||||
|
"height": 2200,
|
||||||
|
"properties": {
|
||||||
|
"radius": 1100,
|
||||||
|
"fill": "#6aa84f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "l1b",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 600,
|
||||||
|
"width": 200,
|
||||||
|
"height": 800,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#8b5a2b"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-2",
|
||||||
|
"name": "Baum (mittel)",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l2a",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": -150,
|
||||||
|
"width": 1500,
|
||||||
|
"height": 1500,
|
||||||
|
"properties": {
|
||||||
|
"radius": 750,
|
||||||
|
"fill": "#6aa84f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "l2b",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 450,
|
||||||
|
"width": 150,
|
||||||
|
"height": 600,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#8b5a2b"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-3",
|
||||||
|
"name": "Strauch",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l3a",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1000,
|
||||||
|
"height": 1000,
|
||||||
|
"properties": {
|
||||||
|
"radius": 500,
|
||||||
|
"fill": "#85b75f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-4",
|
||||||
|
"name": "Hecke horizontal 4m",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l4a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 4000,
|
||||||
|
"height": 500,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4c7a3f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-5",
|
||||||
|
"name": "Hecke vertikal 4m",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l5a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 500,
|
||||||
|
"height": 4000,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#4c7a3f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-6",
|
||||||
|
"name": "Blumenbeet",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l6a",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1600,
|
||||||
|
"height": 1600,
|
||||||
|
"properties": {
|
||||||
|
"radius": 800,
|
||||||
|
"fill": "#c76fb6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "l6b",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 600,
|
||||||
|
"height": 600,
|
||||||
|
"properties": {
|
||||||
|
"radius": 300,
|
||||||
|
"fill": "#e6a23c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-7",
|
||||||
|
"name": "Teich",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l7a",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 3000,
|
||||||
|
"height": 3000,
|
||||||
|
"properties": {
|
||||||
|
"radius": 1500,
|
||||||
|
"fill": "#4a90d9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "l7b",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 100,
|
||||||
|
"width": 2700,
|
||||||
|
"height": 2700,
|
||||||
|
"properties": {
|
||||||
|
"radius": 1350,
|
||||||
|
"fill": "#5fa8e0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-8",
|
||||||
|
"name": "Rasenfläche 5x3m",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l8a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 5000,
|
||||||
|
"height": 3000,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#7cb342"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-9",
|
||||||
|
"name": "Bodenplatte",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l9a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1200,
|
||||||
|
"height": 1200,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#b8a888"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-10",
|
||||||
|
"name": "Weg (Gerade)",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l10a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 600,
|
||||||
|
"height": 4000,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#b8a888"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-11",
|
||||||
|
"name": "Sonnenliege",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l11a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 600,
|
||||||
|
"height": 2000,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#d2b48c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-12",
|
||||||
|
"name": "Feuerschale",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l12a",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1200,
|
||||||
|
"height": 1200,
|
||||||
|
"properties": {
|
||||||
|
"radius": 600,
|
||||||
|
"fill": "#5a5a5a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "l12b",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 600,
|
||||||
|
"height": 600,
|
||||||
|
"properties": {
|
||||||
|
"radius": 300,
|
||||||
|
"fill": "#cc4125"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-13",
|
||||||
|
"name": "Zaun 3m",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l13a",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": -1500,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": -1500,
|
||||||
|
"y2": 900
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "l13b",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": 1500,
|
||||||
|
"y1": 0,
|
||||||
|
"x2": 1500,
|
||||||
|
"y2": 900
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "l13c",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": -1500,
|
||||||
|
"y1": 450,
|
||||||
|
"x2": 1500,
|
||||||
|
"y2": 450
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-14",
|
||||||
|
"name": "Laterne",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l14a",
|
||||||
|
"type": "circle",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": -1000,
|
||||||
|
"width": 400,
|
||||||
|
"height": 400,
|
||||||
|
"properties": {
|
||||||
|
"radius": 200,
|
||||||
|
"fill": "#f5d76e"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "l14b",
|
||||||
|
"type": "line",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"properties": {
|
||||||
|
"x1": 0,
|
||||||
|
"y1": -800,
|
||||||
|
"x2": 0,
|
||||||
|
"y2": 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "l14c",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 1100,
|
||||||
|
"width": 300,
|
||||||
|
"height": 200,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#5a5a5a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land-15",
|
||||||
|
"name": "Gartenbank",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "l15a",
|
||||||
|
"type": "rect",
|
||||||
|
"layerId": "layer-0",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1500,
|
||||||
|
"height": 400,
|
||||||
|
"properties": {
|
||||||
|
"fill": "#8b5a2b"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Task F4 – Builtin-Bibliotheks-Plugin.
|
||||||
|
*
|
||||||
|
* Registriert den builtin-Katalog-Provider über die Plugin-API v2. Die Provider
|
||||||
|
* für user-library (zeichnungsscoped) und global-library (API/Token) sind als
|
||||||
|
* Fabriken (makeUserLibraryProvider / makeGlobalLibraryProvider) umgesetzt und
|
||||||
|
* werden vom UI je Token/Zeichnung instanziert.
|
||||||
|
*/
|
||||||
|
import type { PluginV2 } from '../../types';
|
||||||
|
import { builtinCatalogProvider } from './providers';
|
||||||
|
|
||||||
|
export const builtinLibraryPlugin: PluginV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'builtin-library',
|
||||||
|
name: 'Block-Bibliothek',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'WebCAD Project',
|
||||||
|
description: 'Bibliothek: builtin-Kataloge (Event/Architektur/Landschaft) als LibraryProvider (Task F4).',
|
||||||
|
category: 'library',
|
||||||
|
enabledByDefault: true,
|
||||||
|
permissions: ['storage'],
|
||||||
|
},
|
||||||
|
libraryProviders: [builtinCatalogProvider],
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Task F4 – Bibliotheksquellen als LibraryProviderExtension.
|
||||||
|
*
|
||||||
|
* Drei Quellen:
|
||||||
|
* - builtinCatalogProvider: statische JSON-Startersets (event/architecture/landscape)
|
||||||
|
* - makeUserLibraryProvider: zeichnungsscoped Blockdefinitionen (Drawing-Blöcke)
|
||||||
|
* - makeGlobalLibraryProvider: persistente globale Blockbibliothek (global-blocks API)
|
||||||
|
*
|
||||||
|
* Payload-Konvention: String-Payload = JSON-Array von CADElementen — identisch zum
|
||||||
|
* 'text/global-block-data' Drop-Kanal von BlockLibraryTree (F2/F3), damit der
|
||||||
|
* Canvas-Drop-Pfad (CanvasArea) ohne Änderung funktioniert.
|
||||||
|
*/
|
||||||
|
import catalogEvent from './catalogs/event.json';
|
||||||
|
import catalogArch from './catalogs/architecture.json';
|
||||||
|
import catalogLand from './catalogs/landscape.json';
|
||||||
|
import { generateBlockSvg } from '../../../utils/blockThumbnail';
|
||||||
|
import type { CADElement } from '../../../types/cad.types';
|
||||||
|
import type { LibraryProviderExtension, LibBlock, LibFolder } from '../../types';
|
||||||
|
import { getGlobalFolders, getGlobalBlocks, type GlobalBlock } from '../../../services/api';
|
||||||
|
|
||||||
|
interface CatalogBlock { id: string; name: string; elements: CADElement[]; }
|
||||||
|
interface CatalogFile { folderId: string; label: string; blocks: CatalogBlock[]; }
|
||||||
|
|
||||||
|
const CATALOGS: CatalogFile[] = [catalogEvent, catalogArch, catalogLand].map(
|
||||||
|
(c) => c as unknown as CatalogFile,
|
||||||
|
);
|
||||||
|
|
||||||
|
const thumbCache = new Map<string, string>();
|
||||||
|
|
||||||
|
function toLibBlock(folderId: string, block: CatalogBlock): LibBlock {
|
||||||
|
let thumbnail = thumbCache.get(block.id);
|
||||||
|
if (!thumbnail) {
|
||||||
|
thumbnail = generateBlockSvg(block.elements);
|
||||||
|
thumbCache.set(block.id, thumbnail);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: block.id,
|
||||||
|
folderId,
|
||||||
|
name: block.name,
|
||||||
|
thumbnail,
|
||||||
|
payload: JSON.stringify(block.elements),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Statischer builtin-Katalog: Event / Architektur / Landschaft Startersets. */
|
||||||
|
export const builtinCatalogProvider: LibraryProviderExtension = {
|
||||||
|
id: 'builtin-catalog',
|
||||||
|
label: 'Builtin-Kataloge',
|
||||||
|
async listFolders(): Promise<LibFolder[]> {
|
||||||
|
return CATALOGS.map((c) => ({ id: c.folderId, label: c.label, parentId: null }));
|
||||||
|
},
|
||||||
|
async listBlocks(folderId: string): Promise<LibBlock[]> {
|
||||||
|
const cat = CATALOGS.find((c) => c.folderId === folderId);
|
||||||
|
return cat ? cat.blocks.map((b) => toLibBlock(folderId, b)) : [];
|
||||||
|
},
|
||||||
|
async getBlock(id: string): Promise<LibBlock> {
|
||||||
|
for (const cat of CATALOGS) {
|
||||||
|
const found = cat.blocks.find((b) => b.id === id);
|
||||||
|
if (found) return toLibBlock(cat.folderId, found);
|
||||||
|
}
|
||||||
|
throw new Error(`Block not found: ${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UserBlockLike {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
elements?: unknown;
|
||||||
|
elements_json?: string;
|
||||||
|
thumbnail?: string | null;
|
||||||
|
drawing_id?: string;
|
||||||
|
description?: string | null;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractElements(block: UserBlockLike): CADElement[] {
|
||||||
|
try {
|
||||||
|
if (Array.isArray(block.elements)) return block.elements as CADElement[];
|
||||||
|
if (typeof block.elements_json === 'string') {
|
||||||
|
const parsed: unknown = JSON.parse(block.elements_json);
|
||||||
|
if (Array.isArray(parsed)) return parsed as CADElement[];
|
||||||
|
const wrapped = parsed as { elements?: unknown } | null;
|
||||||
|
if (wrapped && Array.isArray(wrapped.elements)) return wrapped.elements as CADElement[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// invalider Inhalt → leerer Block wird beim Auflisten gefiltert
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichnungsscoped Blöcke (Drawing-Blöcke) als Bibliothek anbinden. */
|
||||||
|
export function makeUserLibraryProvider(userBlocks: readonly UserBlockLike[]): LibraryProviderExtension {
|
||||||
|
const folderId = 'user-blocks';
|
||||||
|
const toBlock = (block: UserBlockLike): LibBlock => {
|
||||||
|
const elements = extractElements(block);
|
||||||
|
return {
|
||||||
|
id: `user:${block.id}`,
|
||||||
|
folderId,
|
||||||
|
name: block.name,
|
||||||
|
thumbnail: elements.length ? generateBlockSvg(elements) : undefined,
|
||||||
|
payload: elements.length ? JSON.stringify(elements) : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: 'user-library',
|
||||||
|
label: 'Benutzer-Bibliothek',
|
||||||
|
async listFolders(): Promise<LibFolder[]> {
|
||||||
|
return [{ id: folderId, label: 'Zeichnungs-Blöcke', parentId: null }];
|
||||||
|
},
|
||||||
|
async listBlocks(id: string): Promise<LibBlock[]> {
|
||||||
|
return id === folderId
|
||||||
|
? userBlocks.filter((b) => extractElements(b).length > 0).map(toBlock)
|
||||||
|
: [];
|
||||||
|
},
|
||||||
|
async getBlock(id: string): Promise<LibBlock> {
|
||||||
|
const found = userBlocks.find((b) => `user:${b.id}` === id && extractElements(b).length > 0);
|
||||||
|
if (found) return toBlock(found);
|
||||||
|
throw new Error(`Block not found: ${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persistente globale Blockbibliothek über die global-blocks API anbinden. */
|
||||||
|
export function makeGlobalLibraryProvider(token: string): LibraryProviderExtension {
|
||||||
|
const toBlock = (block: GlobalBlock): LibBlock => ({
|
||||||
|
id: `global:${block.id}`,
|
||||||
|
folderId: block.folder_id ?? 'global-root',
|
||||||
|
name: block.name,
|
||||||
|
thumbnail: block.svg_data ?? undefined,
|
||||||
|
payload: block.block_data,
|
||||||
|
});
|
||||||
|
const loadAll = async (): Promise<GlobalBlock[]> => {
|
||||||
|
try {
|
||||||
|
return await getGlobalBlocks(token);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: 'global-library',
|
||||||
|
label: 'Globale Bibliothek',
|
||||||
|
async listFolders(): Promise<LibFolder[]> {
|
||||||
|
try {
|
||||||
|
const folders = await getGlobalFolders(token);
|
||||||
|
return folders.map((f) => ({ id: f.id, label: f.name, parentId: f.parent_id }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async listBlocks(folderId: string): Promise<LibBlock[]> {
|
||||||
|
const blocks = await loadAll();
|
||||||
|
return blocks.filter((b) => (b.folder_id ?? 'global-root') === folderId).map(toBlock);
|
||||||
|
},
|
||||||
|
async getBlock(id: string): Promise<LibBlock> {
|
||||||
|
const blocks = await loadAll();
|
||||||
|
const found = blocks.find((b) => `global:${b.id}` === id);
|
||||||
|
if (found) return toBlock(found);
|
||||||
|
throw new Error(`Block not found: ${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ export { coreModifyPlugin } from './builtin/core-modify';
|
|||||||
export { coreAnnotatePlugin } from './builtin/core-annotate';
|
export { coreAnnotatePlugin } from './builtin/core-annotate';
|
||||||
export { coreMeasurePlugin } from './builtin/core-measure';
|
export { coreMeasurePlugin } from './builtin/core-measure';
|
||||||
export { eventToolsV2Plugin } from './builtin/event-tools';
|
export { eventToolsV2Plugin } from './builtin/event-tools';
|
||||||
|
export { builtinLibraryPlugin } from './builtin/library';
|
||||||
|
export { builtinCatalogProvider, makeUserLibraryProvider, makeGlobalLibraryProvider } from './builtin/library/providers';
|
||||||
|
|
||||||
import { pluginRegistry } from './PluginRegistry';
|
import { pluginRegistry } from './PluginRegistry';
|
||||||
import { eventToolsPlugin } from './builtin/eventTools';
|
import { eventToolsPlugin } from './builtin/eventTools';
|
||||||
@@ -40,6 +42,7 @@ import { coreModifyPlugin } from './builtin/core-modify';
|
|||||||
import { coreAnnotatePlugin } from './builtin/core-annotate';
|
import { coreAnnotatePlugin } from './builtin/core-annotate';
|
||||||
import { coreMeasurePlugin } from './builtin/core-measure';
|
import { coreMeasurePlugin } from './builtin/core-measure';
|
||||||
import { eventToolsV2Plugin } from './builtin/event-tools';
|
import { eventToolsV2Plugin } from './builtin/event-tools';
|
||||||
|
import { builtinLibraryPlugin } from './builtin/library';
|
||||||
|
|
||||||
/** Register all built-in plugins */
|
/** Register all built-in plugins */
|
||||||
export function registerBuiltinPlugins() {
|
export function registerBuiltinPlugins() {
|
||||||
@@ -50,4 +53,5 @@ export function registerBuiltinPlugins() {
|
|||||||
pluginRegistry.registerV2(coreAnnotatePlugin);
|
pluginRegistry.registerV2(coreAnnotatePlugin);
|
||||||
pluginRegistry.registerV2(coreMeasurePlugin);
|
pluginRegistry.registerV2(coreMeasurePlugin);
|
||||||
pluginRegistry.registerV2(eventToolsV2Plugin);
|
pluginRegistry.registerV2(eventToolsV2Plugin);
|
||||||
|
pluginRegistry.registerV2(builtinLibraryPlugin);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,9 @@ export interface LibBlock {
|
|||||||
folderId: string;
|
folderId: string;
|
||||||
name: string;
|
name: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
thumbnail?: string; // dataURL
|
thumbnail?: string; // svg/dataURL
|
||||||
|
/** Payload für Canvas-Drop: i.d.R. JSON-String eines CADElement-Arrays (Task F4). */
|
||||||
|
payload?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Liefert Kataloge — Kern der Plugin-erweiterbaren Library (Phase F). */
|
/** Liefert Kataloge — Kern der Plugin-erweiterbaren Library (Phase F). */
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -236,6 +236,88 @@ export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
|||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'curtain': {
|
||||||
|
// Vorhang (Task E7): wellige Polyline entlang der Breite.
|
||||||
|
const pts = Array.isArray(props.points)
|
||||||
|
? (props.points as unknown[]).map(asPoint)
|
||||||
|
: [];
|
||||||
|
if (pts.length >= 2) {
|
||||||
|
return [{ kind: 'polyline', points: pts, close: false, stroke, strokeWidth: sw }];
|
||||||
|
}
|
||||||
|
return [{
|
||||||
|
kind: 'line',
|
||||||
|
from: { x: el.x - el.width / 2, y: el.y },
|
||||||
|
to: { x: el.x + el.width / 2, y: el.y },
|
||||||
|
stroke,
|
||||||
|
strokeWidth: sw,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'spotlight': {
|
||||||
|
// Scheinwerfer (Task E7): Kegel-Polygon in properties.color.
|
||||||
|
const pts = Array.isArray(props.points)
|
||||||
|
? (props.points as unknown[]).map(asPoint)
|
||||||
|
: [];
|
||||||
|
if (pts.length >= 3) {
|
||||||
|
const col = (props.color as string | undefined) ?? '#ffcc00';
|
||||||
|
return [{ kind: 'polyline', points: pts, close: true, stroke: col, strokeWidth: sw }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'barrier': {
|
||||||
|
// Absperrung (Task E7): Kette aus Kreis-Paaren (properties.circles).
|
||||||
|
const circles = Array.isArray(props.circles)
|
||||||
|
? (props.circles as Array<{ x: number; y: number; r: number }>)
|
||||||
|
: [];
|
||||||
|
const col = (props.color as string | undefined) ?? '#c0392b';
|
||||||
|
if (circles.length > 0) {
|
||||||
|
return circles.map((c) => ({
|
||||||
|
kind: 'circle' as const,
|
||||||
|
center: { x: c.x, y: c.y },
|
||||||
|
radius: c.r,
|
||||||
|
fill: null,
|
||||||
|
stroke: col,
|
||||||
|
strokeWidth: 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const out: ShapePrimitive[] = [];
|
||||||
|
const N = 5;
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
out.push({
|
||||||
|
kind: 'circle',
|
||||||
|
center: { x: el.x - el.width / 2 + (el.width * (i + 0.5)) / N, y: el.y },
|
||||||
|
radius: Math.min(6, el.width / (N * 2.5)),
|
||||||
|
fill: null,
|
||||||
|
stroke: col,
|
||||||
|
strokeWidth: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -660,6 +660,7 @@ export interface GlobalBlock {
|
|||||||
name: string;
|
name: string;
|
||||||
block_data: string;
|
block_data: string;
|
||||||
svg_data: string | null;
|
svg_data: string | null;
|
||||||
|
is_favorite: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -698,6 +699,17 @@ export async function renameGlobalBlock(token: string, id: string, name: string)
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task F2: Favorite-Toggle für globale Blöcke
|
||||||
|
export async function setGlobalBlockFavorite(token: string, id: string, isFavorite: boolean): Promise<GlobalBlock> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/global-blocks/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: authHeaders(token),
|
||||||
|
body: JSON.stringify({ is_favorite: isFavorite }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to set favorite');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteGlobalBlock(token: string, id: string): Promise<void> {
|
export async function deleteGlobalBlock(token: string, id: string): Promise<void> {
|
||||||
const res = await fetch(`${API_BASE}/api/global-blocks/${id}`, {
|
const res = await fetch(`${API_BASE}/api/global-blocks/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -764,3 +776,106 @@ export async function aiChat(
|
|||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Guests (Task E6) ──────────────────────────────────
|
||||||
|
export interface Guest {
|
||||||
|
id: string;
|
||||||
|
drawing_id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string | null;
|
||||||
|
category: string;
|
||||||
|
seat_element_id?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGuests(token: string, drawingId: string): Promise<Guest[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/guests`, {
|
||||||
|
headers: authHeaders(token),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to list guests: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGuest(token: string, drawingId: string, data: { name: string; email?: string; category?: string; notes?: string }): Promise<Guest> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/guests`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(token),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to create guest: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGuest(token: string, id: string, data: Partial<{ name: string; email: string; category: string; seat_element_id: string; notes: string }>): Promise<Guest> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/guests/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: authHeaders(token),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to update guest: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGuest(token: string, id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/guests/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders(token),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to delete guest: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Library Paket Export/Import (Task F1) ─────────────
|
||||||
|
export interface WcadlibPackage {
|
||||||
|
format: 'wcadlib';
|
||||||
|
version: number;
|
||||||
|
exported_at?: string;
|
||||||
|
folders: Array<{ id: string; name: string; parent_id: string | null }>;
|
||||||
|
blocks: Array<{ name: string; folder_id: string | null; block_data: string; svg_data: string | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportLibrary(token: string): Promise<unknown> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/global-blocks/export`, {
|
||||||
|
headers: authHeaders(token),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to export library: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importLibrary(token: string, pkg: { format: string; version?: number; folders?: Array<{ id: string; name: string; parent_id: string | null }>; blocks: Array<{ name: string; folder_id: string | null; block_data: string; svg_data: string | null }> }): Promise<{ imported_blocks: number; imported_folders: number }> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/global-blocks/import`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(token),
|
||||||
|
body: JSON.stringify(pkg),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Failed to import library: ${res.status} ${err}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Task F7: .wcadlib als echtes ZIP-Paket ──────────────
|
||||||
|
|
||||||
|
/** Library als ZIP-Paket (manifest.json, blocks.json, thumbs/) herunterladen. */
|
||||||
|
export async function exportLibraryZip(token: string): Promise<Blob> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/global-blocks/export-zip`, {
|
||||||
|
headers: authHeaders(token),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to export library zip: ${res.status}`);
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** .wcadlib-ZIP einspielen (Ordner-Mapping, Blöcke, Thumbnails). */
|
||||||
|
export async function importLibraryZip(token: string, file: Blob): Promise<{ imported_blocks: number; imported_folders: number }> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/global-blocks/import-zip`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...authHeaders(token), 'content-type': 'application/zip' },
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Failed to import library zip: ${res.status} ${err}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,53 @@
|
|||||||
/**
|
/**
|
||||||
* DXF Parser – converts DXF entities to CADElement[]
|
* DXF Parser – converts DXF entities to CADElement[]
|
||||||
* Uses dxf-parser library
|
* Uses dxf-parser library.
|
||||||
|
*
|
||||||
|
* Task F5 erweitert die Entity-Abdeckung: ELLIPSE (Polyline-Approx), SPLINE
|
||||||
|
* (Grad<=3 → de-Boor-Approx), MTEXT (Formatcode-Cleanup), POINT, ATTDEF und
|
||||||
|
* INSERT mit MINSERT-Arrays. HATCH wird defensiv mapping-fähig gehalten
|
||||||
|
* (dxf-parser 1.1.x schickt echte HATCH-Entities noch nicht durch).
|
||||||
|
*
|
||||||
|
* Neben `warnings` liefert parseDXF einen strukturierten Fehlerreport
|
||||||
|
* (`skippedEntities`: type + reason je übersprungener Entity).
|
||||||
*/
|
*/
|
||||||
import DxfParser from 'dxf-parser';
|
import DxfParser from 'dxf-parser';
|
||||||
import type { CADElement, CADLayer, CADProperties, ElementType } from '../types/cad.types';
|
import type { CADElement, CADLayer, CADProperties, ElementType } from '../types/cad.types';
|
||||||
|
|
||||||
|
export interface SkippedEntity {
|
||||||
|
type: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DXFImportResult {
|
export interface DXFImportResult {
|
||||||
elements: CADElement[];
|
elements: CADElement[];
|
||||||
layers: CADLayer[];
|
layers: CADLayer[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
|
skippedEntities: SkippedEntity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let idCounter = 0;
|
let idCounter = 0;
|
||||||
const nextId = () => `dxf-${Date.now()}-${idCounter++}`;
|
const nextId = () => `dxf-${Date.now()}-${idCounter++}`;
|
||||||
|
|
||||||
|
interface Pt { x: number; y: number }
|
||||||
|
|
||||||
|
function bboxOf(pts: Pt[]): { x: number; y: number; width: number; height: number } {
|
||||||
|
const minX = Math.min(...pts.map((p) => p.x));
|
||||||
|
const minY = Math.min(...pts.map((p) => p.y));
|
||||||
|
const maxX = Math.max(...pts.map((p) => p.x));
|
||||||
|
const maxY = Math.max(...pts.map((p) => p.y));
|
||||||
|
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a DXF string into CAD elements and layers.
|
* Parse a DXF string into CAD elements and layers.
|
||||||
*/
|
*/
|
||||||
export function parseDXF(dxfString: string): DXFImportResult {
|
export function parseDXF(dxfString: string): DXFImportResult {
|
||||||
const parser = new DxfParser();
|
const parser = new DxfParser();
|
||||||
const dxf = parser.parseSync(dxfString) as any;
|
const dxf = parser.parseSync(dxfString) as any;
|
||||||
if (!dxf) return { elements: [], layers: [], warnings: ['DXF parse returned null'] };
|
const empty: DXFImportResult = { elements: [], layers: [], warnings: [], skippedEntities: [] };
|
||||||
|
if (!dxf) return { ...empty, warnings: ['DXF parse returned null'] };
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
const skippedEntities: SkippedEntity[] = [];
|
||||||
const layerMap = new Map<string, CADLayer>();
|
const layerMap = new Map<string, CADLayer>();
|
||||||
const elements: CADElement[] = [];
|
const elements: CADElement[] = [];
|
||||||
|
|
||||||
@@ -52,22 +78,180 @@ export function parseDXF(dxfString: string): DXFImportResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse entities
|
// Parse entities (INSERT expanded for MINSERT arrays)
|
||||||
if (dxf.entities) {
|
if (dxf.entities) {
|
||||||
for (const entity of dxf.entities) {
|
for (const entity of dxf.entities) {
|
||||||
|
if (entity?.type === 'INSERT') {
|
||||||
|
elements.push(...expandInsert(entity, layerMap));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const el = entityToCADElement(entity, layerMap);
|
const el = entityToCADElement(entity, layerMap);
|
||||||
if (el) {
|
if (el) {
|
||||||
elements.push(el);
|
elements.push(el);
|
||||||
} else {
|
} else {
|
||||||
warnings.push(`Unsupported entity type: ${entity.type}`);
|
const type = String(entity?.type ?? 'UNKNOWN');
|
||||||
|
const reason = skipReason(entity);
|
||||||
|
skippedEntities.push({ type, reason });
|
||||||
|
warnings.push(`Unsupported entity type: ${type} — ${reason}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { elements, layers: Array.from(layerMap.values()), warnings };
|
return { elements, layers: Array.from(layerMap.values()), warnings, skippedEntities };
|
||||||
}
|
}
|
||||||
|
|
||||||
function entityToCADElement(
|
/** Grund, warum eine Entity übersprungen wurde (für den Fehlerreport). */
|
||||||
|
export function skipReason(entity: any): string {
|
||||||
|
switch (entity?.type) {
|
||||||
|
case 'ELLIPSE':
|
||||||
|
return 'Ellipse ohne center/majorAxisEndPoint nicht darstellbar';
|
||||||
|
case 'SPLINE':
|
||||||
|
if ((entity?.degreeOfSplineCurve ?? 0) > 3) return 'Spline-Grad > 3 (Approx limitiert auf Grad <= 3)';
|
||||||
|
return 'Spline ohne verwertbare Kontrollpunkte';
|
||||||
|
case 'LINE':
|
||||||
|
return 'Line unvollständig (Start-/Endpunkt fehlt)';
|
||||||
|
case 'HATCH':
|
||||||
|
return 'HATCH-Grenzen nicht lesbar';
|
||||||
|
case 'ATTDEF':
|
||||||
|
return 'ATTDEF ohne tag';
|
||||||
|
default:
|
||||||
|
return 'Nicht unterstützter Entity-Typ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INSERT → block_instance(s). MINSERT-Arrays (columnCount/rowCount > 1)
|
||||||
|
* erzeugen n-fache Instanzen mit Spacing-Offset.
|
||||||
|
*/
|
||||||
|
export function expandInsert(entity: any, layerMap?: Map<string, CADLayer>): CADElement[] {
|
||||||
|
const layerName = entity.layer || '0';
|
||||||
|
const layer = layerMap?.get(layerName) ?? layerMap?.get('0');
|
||||||
|
const layerId = layer?.id ?? `dxf-layer-${layerName}`;
|
||||||
|
const pos = entity.position;
|
||||||
|
if (!pos) return [];
|
||||||
|
const baseProps: CADProperties = {
|
||||||
|
stroke: entity.color ? (dxfColorToHex(entity.color) ?? layer?.color) : (layer?.color ?? '#ffffff'),
|
||||||
|
strokeWidth: 1,
|
||||||
|
blockId: entity.name,
|
||||||
|
scale: entity.scale || entity.xScale || 1,
|
||||||
|
scaleX: entity.xScale ?? 1,
|
||||||
|
scaleY: entity.yScale ?? 1,
|
||||||
|
rotation: entity.rotation || 0,
|
||||||
|
};
|
||||||
|
const cols = Math.max(1, Math.floor(entity.columnCount || 1));
|
||||||
|
const rows = Math.max(1, Math.floor(entity.rowCount || 1));
|
||||||
|
const colSpacing = entity.columnSpacing || 0;
|
||||||
|
const rowSpacing = entity.rowSpacing || 0;
|
||||||
|
const out: CADElement[] = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
out.push({
|
||||||
|
id: nextId(), type: 'block_instance', layerId,
|
||||||
|
x: pos.x + c * colSpacing, y: pos.y + r * rowSpacing,
|
||||||
|
width: 0, height: 0,
|
||||||
|
properties: { ...baseProps },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ELLIPSE → Polyline-Approximation (64 Segmente bei Voll-Ellipse). */
|
||||||
|
function ellipseToPoints(entity: any): Pt[] | null {
|
||||||
|
const c = entity.center;
|
||||||
|
const major = entity.majorAxisEndPoint;
|
||||||
|
if (!c || !major) return null;
|
||||||
|
const a = Math.hypot(major.x ?? 0, major.y ?? 0);
|
||||||
|
if (!(a > 0)) return null;
|
||||||
|
const ratio = typeof entity.axisRatio === 'number' && entity.axisRatio > 0 ? entity.axisRatio : 1;
|
||||||
|
const b = a * ratio;
|
||||||
|
const phi = Math.atan2(major.y ?? 0, major.x ?? 0);
|
||||||
|
const cosP = Math.cos(phi), sinP = Math.sin(phi);
|
||||||
|
const FULL = Math.PI * 2;
|
||||||
|
let start = typeof entity.startAngle === 'number' ? entity.startAngle : 0;
|
||||||
|
let end = typeof entity.endAngle === 'number' ? entity.endAngle : start + FULL;
|
||||||
|
let span = end - start;
|
||||||
|
const closed = !(span > 0.000001) || Math.abs(span - FULL) < 1e-6;
|
||||||
|
if (span <= 0) { start = 0; span = FULL; }
|
||||||
|
const n = Math.max(2, Math.round(64 * (span / FULL)));
|
||||||
|
const pts: Pt[] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const t = closed ? start + (i * span) / n : start + (i * span) / (n - 1);
|
||||||
|
const ct = Math.cos(t), st = Math.sin(t);
|
||||||
|
pts.push({
|
||||||
|
x: c.x + a * ct * cosP - b * st * sinP,
|
||||||
|
y: c.y + a * ct * sinP + b * st * cosP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cox-de-Boor: Punkt auf B-Spline (Grad <= 3). */
|
||||||
|
function bsplinePoint(degree: number, knots: number[], ctrl: Pt[], t: number): Pt {
|
||||||
|
const n = ctrl.length - 1;
|
||||||
|
let k = degree;
|
||||||
|
for (let i = degree; i <= n; i++) {
|
||||||
|
if (t >= knots[i] && t <= knots[i + 1]) { k = i; break; }
|
||||||
|
}
|
||||||
|
const d: Pt[] = [];
|
||||||
|
for (let j = 0; j <= degree; j++) d.push({ ...ctrl[k - degree + j] });
|
||||||
|
for (let r = 1; r <= degree; r++) {
|
||||||
|
for (let j = degree; j >= r; j--) {
|
||||||
|
const i = k - degree + j;
|
||||||
|
const denom = knots[i + degree - r + 1] - knots[i];
|
||||||
|
const alpha = denom === 0 ? 0 : (t - knots[i]) / denom;
|
||||||
|
d[j] = {
|
||||||
|
x: (1 - alpha) * d[j - 1].x + alpha * d[j].x,
|
||||||
|
y: (1 - alpha) * d[j - 1].y + alpha * d[j].y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d[degree];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SPLINE → Polyline-Approximation via de Boor (Grad <= 3). */
|
||||||
|
function splineToPoints(entity: any): Pt[] | null {
|
||||||
|
const degree = entity.degreeOfSplineCurve ?? 3;
|
||||||
|
const ctrl = (entity.controlPoints || []) as Pt[];
|
||||||
|
if (!Array.isArray(ctrl) || ctrl.length < degree + 1) return null;
|
||||||
|
const m = ctrl.length;
|
||||||
|
const total = m + degree + 1;
|
||||||
|
const knots: number[] = entity.knotValues && entity.knotValues.length === total
|
||||||
|
? entity.knotValues.slice()
|
||||||
|
: (() => {
|
||||||
|
const ks: number[] = [];
|
||||||
|
for (let j = 0; j < total; j++) {
|
||||||
|
if (j <= degree) ks.push(0);
|
||||||
|
else if (j >= total - 1 - degree) ks.push(1);
|
||||||
|
else ks.push((j - degree) / (total - 1 - 2 * degree));
|
||||||
|
}
|
||||||
|
return ks;
|
||||||
|
})();
|
||||||
|
const N = 40;
|
||||||
|
const t0 = knots[degree];
|
||||||
|
const t1 = knots[m];
|
||||||
|
const span = t1 - t0;
|
||||||
|
const pts: Pt[] = [];
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const t = t0 + (i * span) / (N - 1);
|
||||||
|
pts.push(bsplinePoint(degree, knots, ctrl, t));
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MTEXT-Formatcodes zerlegen: \P → Umbruch, Inline-Codes entfernen. */
|
||||||
|
function cleanMText(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/\\P/gi, '\n')
|
||||||
|
.replace(/\\~/g, ' ')
|
||||||
|
.replace(/\\[A-Za-z][^;{}\\]{0,12};/g, '')
|
||||||
|
.replace(/[{}]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a single DXF entity into a CADElement (exported for unit tests).
|
||||||
|
*/
|
||||||
|
export function entityToCADElement(
|
||||||
entity: any,
|
entity: any,
|
||||||
layerMap: Map<string, CADLayer>,
|
layerMap: Map<string, CADLayer>,
|
||||||
): CADElement | null {
|
): CADElement | null {
|
||||||
@@ -84,13 +268,10 @@ function entityToCADElement(
|
|||||||
const s = entity.vertices?.[0];
|
const s = entity.vertices?.[0];
|
||||||
const e = entity.vertices?.[1];
|
const e = entity.vertices?.[1];
|
||||||
if (!s || !e) return null;
|
if (!s || !e) return null;
|
||||||
const minX = Math.min(s.x, e.x);
|
|
||||||
const minY = Math.min(s.y, e.y);
|
|
||||||
const maxX = Math.max(s.x, e.x);
|
|
||||||
const maxY = Math.max(s.y, e.y);
|
|
||||||
return {
|
return {
|
||||||
id: nextId(), type: 'line', layerId: layer.id,
|
id: nextId(), type: 'line', layerId: layer.id,
|
||||||
x: minX, y: minY, width: maxX - minX, height: maxY - minY,
|
x: Math.min(s.x, e.x), y: Math.min(s.y, e.y),
|
||||||
|
width: Math.abs(e.x - s.x), height: Math.abs(e.y - s.y),
|
||||||
properties: { ...baseProps, x1: s.x, y1: s.y, x2: e.x, y2: e.y },
|
properties: { ...baseProps, x1: s.x, y1: s.y, x2: e.x, y2: e.y },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -98,9 +279,10 @@ function entityToCADElement(
|
|||||||
const c = entity.center;
|
const c = entity.center;
|
||||||
const r = entity.radius;
|
const r = entity.radius;
|
||||||
if (!c || r == null) return null;
|
if (!c || r == null) return null;
|
||||||
|
// Center-Konvention wie beide Renderer: el.x/el.y IST das Zentrum
|
||||||
return {
|
return {
|
||||||
id: nextId(), type: 'circle', layerId: layer.id,
|
id: nextId(), type: 'circle', layerId: layer.id,
|
||||||
x: c.x - r, y: c.y - r, width: r * 2, height: r * 2,
|
x: c.x, y: c.y, width: r * 2, height: r * 2,
|
||||||
properties: { ...baseProps, radius: r },
|
properties: { ...baseProps, radius: r },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -108,25 +290,49 @@ function entityToCADElement(
|
|||||||
const c = entity.center;
|
const c = entity.center;
|
||||||
const r = entity.radius;
|
const r = entity.radius;
|
||||||
if (!c || r == null) return null;
|
if (!c || r == null) return null;
|
||||||
|
// dxf-parser konvertiert ARC-Winkel intern nach Radiant (getestet);
|
||||||
|
// App-Konvention ist Grad (elementDrawers, Arc-Tool, angleBetween).
|
||||||
|
const toDeg = (rad: number | undefined): number => ((rad ?? 0) * 180) / Math.PI;
|
||||||
return {
|
return {
|
||||||
id: nextId(), type: 'arc', layerId: layer.id,
|
id: nextId(), type: 'arc', layerId: layer.id,
|
||||||
x: c.x - r, y: c.y - r, width: r * 2, height: r * 2,
|
x: c.x, y: c.y, width: r * 2, height: r * 2,
|
||||||
properties: { ...baseProps, radius: r, startAngle: entity.startAngle, endAngle: entity.endAngle },
|
properties: { ...baseProps, radius: r, startAngle: toDeg(entity.startAngle), endAngle: toDeg(entity.endAngle) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'ELLIPSE': {
|
||||||
|
const pts = ellipseToPoints(entity);
|
||||||
|
if (!pts || pts.length < 2) return null;
|
||||||
|
const bbox = bboxOf(pts);
|
||||||
|
return {
|
||||||
|
id: nextId(), type: 'polyline', layerId: layer.id,
|
||||||
|
...bbox,
|
||||||
|
properties: { ...baseProps, points: pts },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'SPLINE': {
|
||||||
|
const degree = entity.degreeOfSplineCurve ?? 3;
|
||||||
|
const ctrl = (entity.controlPoints || []) as Pt[];
|
||||||
|
if (degree < 1 || degree > 3) return null;
|
||||||
|
if (!Array.isArray(ctrl) || ctrl.length < degree + 1) return null;
|
||||||
|
const pts = splineToPoints(entity);
|
||||||
|
if (!pts || pts.length < 2) return null;
|
||||||
|
const bbox = bboxOf(pts);
|
||||||
|
return {
|
||||||
|
id: nextId(), type: 'polyline', layerId: layer.id,
|
||||||
|
...bbox,
|
||||||
|
properties: { ...baseProps, points: pts },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'LWPOLYLINE':
|
case 'LWPOLYLINE':
|
||||||
case 'POLYLINE': {
|
case 'POLYLINE': {
|
||||||
const pts = (entity.vertices || []).map((v: any) => ({ x: v.x, y: v.y }));
|
const pts = (entity.vertices || []).map((v: any) => ({ x: v.x, y: v.y }));
|
||||||
if (pts.length < 2) return null;
|
if (pts.length < 2) return null;
|
||||||
const minX = Math.min(...pts.map((p: any) => p.x));
|
const bbox = bboxOf(pts);
|
||||||
const minY = Math.min(...pts.map((p: any) => p.y));
|
|
||||||
const maxX = Math.max(...pts.map((p: any) => p.x));
|
|
||||||
const maxY = Math.max(...pts.map((p: any) => p.y));
|
|
||||||
const isClosed = entity.shape === true;
|
const isClosed = entity.shape === true;
|
||||||
const type: ElementType = isClosed ? 'polygon' : 'polyline';
|
const type: ElementType = isClosed ? 'polygon' : 'polyline';
|
||||||
return {
|
return {
|
||||||
id: nextId(), type, layerId: layer.id,
|
id: nextId(), type, layerId: layer.id,
|
||||||
x: minX, y: minY, width: maxX - minX, height: maxY - minY,
|
...bbox,
|
||||||
properties: { ...baseProps, points: pts },
|
properties: { ...baseProps, points: pts },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -139,16 +345,62 @@ function entityToCADElement(
|
|||||||
properties: { ...baseProps, text: entity.text || '', fontSize: entity.height || 12 },
|
properties: { ...baseProps, text: entity.text || '', fontSize: entity.height || 12 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'INSERT': {
|
case 'MTEXT': {
|
||||||
// Block reference — store as block_instance
|
|
||||||
const pos = entity.position;
|
const pos = entity.position;
|
||||||
if (!pos) return null;
|
if (!pos) return null;
|
||||||
|
const text = cleanMText(String(entity.text ?? ''));
|
||||||
|
if (!text) return null;
|
||||||
return {
|
return {
|
||||||
id: nextId(), type: 'block_instance', layerId: layer.id,
|
id: nextId(), type: 'text', layerId: layer.id,
|
||||||
x: pos.x, y: pos.y, width: 0, height: 0,
|
x: pos.x, y: pos.y, width: entity.width || 0, height: entity.height || 0,
|
||||||
properties: { ...baseProps, blockId: entity.name, scale: entity.scale || 1, rotation: entity.rotation || 0 },
|
properties: { ...baseProps, text, fontSize: entity.height || 12, rotation: entity.rotation || 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'POINT': {
|
||||||
|
const pos = entity.position;
|
||||||
|
if (!pos) return null;
|
||||||
|
const r = 1.5;
|
||||||
|
return {
|
||||||
|
id: nextId(), type: 'circle', layerId: layer.id,
|
||||||
|
x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2,
|
||||||
|
properties: { ...baseProps, radius: r, fill: color, point: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'ATTDEF': {
|
||||||
|
const s = entity.startPoint;
|
||||||
|
if (!entity.tag || !s) return null;
|
||||||
|
return {
|
||||||
|
id: nextId(), type: 'text', layerId: layer.id,
|
||||||
|
x: s.x, y: s.y, width: 0, height: 0,
|
||||||
|
properties: {
|
||||||
|
...baseProps,
|
||||||
|
text: `${entity.tag}=${entity.text ?? ''}`,
|
||||||
|
fontSize: entity.textHeight || 12,
|
||||||
|
tag: entity.tag,
|
||||||
|
prompt: entity.prompt ?? '',
|
||||||
|
attdef: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'HATCH': {
|
||||||
|
const pts = (entity.boundaryPoints || []) as Pt[];
|
||||||
|
if (pts.length < 3) return null;
|
||||||
|
const bbox = bboxOf(pts);
|
||||||
|
return {
|
||||||
|
id: nextId(), type: 'polygon', layerId: layer.id,
|
||||||
|
...bbox,
|
||||||
|
properties: {
|
||||||
|
...baseProps,
|
||||||
|
points: pts,
|
||||||
|
hatchPattern: entity.patternName || 'SOLID',
|
||||||
|
fill: entity.isSolid ? color : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'INSERT': {
|
||||||
|
const [inst] = expandInsert(entity, layerMap);
|
||||||
|
return inst ?? null;
|
||||||
|
}
|
||||||
case 'DIMENSION': {
|
case 'DIMENSION': {
|
||||||
// Basic dimension support
|
// Basic dimension support
|
||||||
const pts = entity.definitionPoint || entity.points;
|
const pts = entity.definitionPoint || entity.points;
|
||||||
|
|||||||
@@ -1,23 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* DXF Writer – converts CADElement[] to DXF string
|
* DXF Writer – converts CADElement[] to DXF string
|
||||||
* Minimal DXF R12 format for compatibility
|
* Zielversionen: AC1009 (R12, Default) und AC1015 (AutoCAD 2000).
|
||||||
|
*
|
||||||
|
* Task F6: BLOCKS+INSERTS mit Name-Mapping/Sanitizing, ATTDEF in
|
||||||
|
* Blockdefinitionen, ATTRIB/SEQEND an Instanzen mit attrValues,
|
||||||
|
* R12-korrektes POLYLINE+VERTEX+SEQEND statt LWPOLYLINE sowie die
|
||||||
|
* Center-/Winkel-Konventionen der App (el.x/el.y = Zentrum, Grad-Winkel).
|
||||||
*/
|
*/
|
||||||
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
|
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
|
||||||
|
|
||||||
|
export interface DXFWriterOptions {
|
||||||
|
/** Zielversion: AC1009 (R12, Default) oder AC1015. */
|
||||||
|
version?: 'AC1009' | 'AC1015';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pt { x: number; y: number }
|
||||||
|
type Write = (code: number, value: string | number) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a DXF R12 string from project data.
|
* Generate a DXF string from project data (R12-kompatibel).
|
||||||
*/
|
*/
|
||||||
export function writeDXF(data: ProjectData): string {
|
export function writeDXF(data: ProjectData, options: DXFWriterOptions = {}): string {
|
||||||
|
const version = options.version ?? 'AC1009';
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const w = (code: number, value: string | number) => {
|
const w: Write = (code, value) => {
|
||||||
lines.push(String(code));
|
lines.push(String(code));
|
||||||
lines.push(String(value));
|
lines.push(String(value));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Name-Mapping: Block-ID → sanitierter DXF-Blockname ───
|
||||||
|
const usedNames = new Set<string>(['0']); // '0' ist in DXF reserviert
|
||||||
|
const nameMap = new Map<string, string>();
|
||||||
|
for (const block of data.blocks) {
|
||||||
|
nameMap.set(block.id, sanitizeBlockName(block.name || block.id, usedNames));
|
||||||
|
}
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
w(0, 'SECTION');
|
w(0, 'SECTION');
|
||||||
w(2, 'HEADER');
|
w(2, 'HEADER');
|
||||||
w(9, '$ACADVER'); w(1, 'AC1009'); // R12
|
w(9, '$ACADVER'); w(1, version);
|
||||||
w(9, '$INSBASE'); w(10, 0); w(20, 0); w(30, 0);
|
w(9, '$INSBASE'); w(10, 0); w(20, 0); w(30, 0);
|
||||||
w(9, '$EXTMIN'); w(10, 0); w(20, 0);
|
w(9, '$EXTMIN'); w(10, 0); w(20, 0);
|
||||||
w(9, '$EXTMAX'); w(10, 1000); w(20, 1000);
|
w(9, '$EXTMAX'); w(10, 1000); w(20, 1000);
|
||||||
@@ -26,8 +47,6 @@ export function writeDXF(data: ProjectData): string {
|
|||||||
// Tables section
|
// Tables section
|
||||||
w(0, 'SECTION');
|
w(0, 'SECTION');
|
||||||
w(2, 'TABLES');
|
w(2, 'TABLES');
|
||||||
|
|
||||||
// Layer table
|
|
||||||
w(0, 'TABLE');
|
w(0, 'TABLE');
|
||||||
w(2, 'LAYER');
|
w(2, 'LAYER');
|
||||||
w(70, data.layers.length);
|
w(70, data.layers.length);
|
||||||
@@ -41,15 +60,31 @@ export function writeDXF(data: ProjectData): string {
|
|||||||
w(0, 'ENDTAB');
|
w(0, 'ENDTAB');
|
||||||
w(0, 'ENDSEC');
|
w(0, 'ENDSEC');
|
||||||
|
|
||||||
|
// ─── BLOCKS section (Task F6) ───
|
||||||
|
w(0, 'SECTION');
|
||||||
|
w(2, 'BLOCKS');
|
||||||
|
for (const block of data.blocks) {
|
||||||
|
const dxfName = nameMap.get(block.id)!;
|
||||||
|
w(0, 'BLOCK');
|
||||||
|
w(2, dxfName);
|
||||||
|
w(70, 0);
|
||||||
|
w(10, 0); w(20, 0); w(30, 0);
|
||||||
|
w(3, dxfName);
|
||||||
|
w(1, block.description || '');
|
||||||
|
for (const el of block.elements) {
|
||||||
|
writeEntity(w, el, getLayerName(data.layers, el.layerId), nameMap, true);
|
||||||
|
}
|
||||||
|
w(0, 'ENDBLK');
|
||||||
|
}
|
||||||
|
w(0, 'ENDSEC');
|
||||||
|
|
||||||
// Entities section
|
// Entities section
|
||||||
w(0, 'SECTION');
|
w(0, 'SECTION');
|
||||||
w(2, 'ENTITIES');
|
w(2, 'ENTITIES');
|
||||||
|
|
||||||
for (const el of data.elements) {
|
for (const el of data.elements) {
|
||||||
const layerName = getLayerName(data.layers, el.layerId);
|
const layerName = getLayerName(data.layers, el.layerId);
|
||||||
writeEntity(w, el, layerName);
|
writeEntity(w, el, layerName, nameMap, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
w(0, 'ENDSEC');
|
w(0, 'ENDSEC');
|
||||||
|
|
||||||
// EOF
|
// EOF
|
||||||
@@ -58,10 +93,58 @@ export function writeDXF(data: ProjectData): string {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Blocknamen für DXF sanitizen: nur [A-Za-z0-9_$-], Kollisionen deduplizieren. */
|
||||||
|
function sanitizeBlockName(name: string, used: Set<string>): string {
|
||||||
|
const base = (name.replace(/[^\w$-]/g, '_') || 'BLOCK');
|
||||||
|
let candidate = base;
|
||||||
|
let i = 2;
|
||||||
|
while (used.has(candidate.toUpperCase())) {
|
||||||
|
candidate = `${base}_${i++}`;
|
||||||
|
}
|
||||||
|
used.add(candidate.toUpperCase());
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** R12-korrektes POLYLINE+VERTEX+SEQEND (LWPOLYLINE erst ab R13). */
|
||||||
|
function writePolylineR12(
|
||||||
|
w: Write,
|
||||||
|
layerName: string,
|
||||||
|
aci: number,
|
||||||
|
pts: Pt[],
|
||||||
|
closed: boolean,
|
||||||
|
): void {
|
||||||
|
w(0, 'POLYLINE');
|
||||||
|
w(8, layerName);
|
||||||
|
w(62, aci);
|
||||||
|
w(66, 1); // Vertices folgen
|
||||||
|
w(70, closed ? 1 : 0);
|
||||||
|
w(10, 0); w(20, 0); w(30, 0);
|
||||||
|
for (const pt of pts) {
|
||||||
|
w(0, 'VERTEX');
|
||||||
|
w(8, layerName);
|
||||||
|
w(10, pt.x); w(20, pt.y); w(30, 0);
|
||||||
|
}
|
||||||
|
w(0, 'SEQEND');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BBox-Fallback für Elemente ohne native DXF-Repräsentation. */
|
||||||
|
function writeBBoxPolyline(w: Write, el: CADElement, layerName: string, aci: number): void {
|
||||||
|
const x0 = el.x;
|
||||||
|
const y0 = el.y;
|
||||||
|
writePolylineR12(w, layerName, aci, [
|
||||||
|
{ x: x0, y: y0 },
|
||||||
|
{ x: x0 + el.width, y: y0 },
|
||||||
|
{ x: x0 + el.width, y: y0 + el.height },
|
||||||
|
{ x: x0, y: y0 + el.height },
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
function writeEntity(
|
function writeEntity(
|
||||||
w: (code: number, value: string | number) => void,
|
w: Write,
|
||||||
el: CADElement,
|
el: CADElement,
|
||||||
layerName: string,
|
layerName: string,
|
||||||
|
nameMap: Map<string, string>,
|
||||||
|
inBlockDefinition: boolean,
|
||||||
): void {
|
): void {
|
||||||
const p = el.properties;
|
const p = el.properties;
|
||||||
const stroke = (p.stroke as string) || '#ffffff';
|
const stroke = (p.stroke as string) || '#ffffff';
|
||||||
@@ -77,68 +160,69 @@ function writeEntity(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'circle': {
|
case 'circle': {
|
||||||
const cx = el.x + el.width / 2;
|
// App-Konvention: el.x/el.y IST das Zentrum (beide Renderer lesen es direkt)
|
||||||
const cy = el.y + el.height / 2;
|
|
||||||
const r = p.radius ?? el.width / 2;
|
|
||||||
w(0, 'CIRCLE');
|
w(0, 'CIRCLE');
|
||||||
w(8, layerName);
|
w(8, layerName);
|
||||||
w(62, aci);
|
w(62, aci);
|
||||||
w(10, cx); w(20, cy); w(30, 0);
|
w(10, el.x); w(20, el.y); w(30, 0);
|
||||||
w(40, r);
|
w(40, p.radius ?? el.width / 2);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'arc': {
|
case 'arc': {
|
||||||
const cx = el.x + el.width / 2;
|
|
||||||
const cy = el.y + el.height / 2;
|
|
||||||
const r = p.radius ?? el.width / 2;
|
|
||||||
w(0, 'ARC');
|
w(0, 'ARC');
|
||||||
w(8, layerName);
|
w(8, layerName);
|
||||||
w(62, aci);
|
w(62, aci);
|
||||||
w(10, cx); w(20, cy); w(30, 0);
|
w(10, el.x); w(20, el.y); w(30, 0);
|
||||||
w(40, r);
|
w(40, p.radius ?? el.width / 2);
|
||||||
w(50, radToDeg(p.startAngle ?? 0));
|
// App-Konvention: Winkel in Grad (0° = 3 Uhr, CCW) — direkt schreiben
|
||||||
w(51, radToDeg(p.endAngle ?? 360));
|
w(50, p.startAngle ?? 0);
|
||||||
|
w(51, p.endAngle ?? 360);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'rect': {
|
case 'rect': {
|
||||||
// Rectangle as LWPOLYLINE
|
// App-Konvention: rect x/y = Zentrum der BBox
|
||||||
w(0, 'LWPOLYLINE');
|
const x0 = el.x - el.width / 2;
|
||||||
w(8, layerName);
|
const y0 = el.y - el.height / 2;
|
||||||
w(62, aci);
|
writePolylineR12(w, layerName, aci, [
|
||||||
w(90, 4);
|
{ x: x0, y: y0 },
|
||||||
w(70, 1); // closed
|
{ x: x0 + el.width, y: y0 },
|
||||||
w(10, el.x); w(20, el.y);
|
{ x: x0 + el.width, y: y0 + el.height },
|
||||||
w(10, el.x + el.width); w(20, el.y);
|
{ x: x0, y: y0 + el.height },
|
||||||
w(10, el.x + el.width); w(20, el.y + el.height);
|
], true);
|
||||||
w(10, el.x); w(20, el.y + el.height);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'polygon':
|
case 'polygon':
|
||||||
case 'polyline': {
|
case 'polyline': {
|
||||||
const pts = p.points ?? [];
|
const pts = (p.points as Pt[] | undefined) ?? [];
|
||||||
w(0, 'LWPOLYLINE');
|
if (pts.length >= 2) {
|
||||||
w(8, layerName);
|
writePolylineR12(w, layerName, aci, pts.map((pt) => ({ x: pt.x, y: pt.y })), el.type === 'polygon');
|
||||||
w(62, aci);
|
|
||||||
w(90, pts.length);
|
|
||||||
w(70, el.type === 'polygon' ? 1 : 0);
|
|
||||||
for (const pt of pts) {
|
|
||||||
w(10, pt.x); w(20, pt.y);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'text': {
|
case 'text': {
|
||||||
|
// ATTDEF: Text-Element als Attribut-Definition in Blockdefinitionen (Task F6)
|
||||||
|
if (inBlockDefinition && p.attdef === true && typeof p.tag === 'string' && p.tag) {
|
||||||
|
w(0, 'ATTDEF');
|
||||||
|
w(8, layerName);
|
||||||
|
w(10, el.x); w(20, el.y); w(30, 0);
|
||||||
|
w(40, p.fontSize ?? 12);
|
||||||
|
w(1, (p.text as string) ?? '');
|
||||||
|
w(2, p.tag);
|
||||||
|
w(3, (p.prompt as string) ?? '');
|
||||||
|
w(70, 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
w(0, 'TEXT');
|
w(0, 'TEXT');
|
||||||
w(8, layerName);
|
w(8, layerName);
|
||||||
w(62, aci);
|
w(62, aci);
|
||||||
w(10, el.x); w(20, el.y); w(30, 0);
|
w(10, el.x); w(20, el.y); w(30, 0);
|
||||||
w(40, p.fontSize ?? 12);
|
w(40, p.fontSize ?? 12);
|
||||||
w(1, p.text ?? '');
|
w(1, (p.text as string) ?? '');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'dimension': {
|
case 'dimension': {
|
||||||
const pts = p.points ?? [];
|
const pts = (p.points as Pt[] | undefined) ?? [];
|
||||||
if (pts.length >= 2) {
|
if (pts.length >= 2) {
|
||||||
// Draw dimension as LINE + TEXT
|
|
||||||
w(0, 'LINE');
|
w(0, 'LINE');
|
||||||
w(8, layerName);
|
w(8, layerName);
|
||||||
w(62, aci);
|
w(62, aci);
|
||||||
@@ -157,27 +241,41 @@ function writeEntity(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'block_instance': {
|
case 'block_instance': {
|
||||||
|
const mapped = nameMap.get(String(p.blockId ?? ''));
|
||||||
|
if (!mapped) {
|
||||||
|
writeBBoxPolyline(w, el, layerName, aci);
|
||||||
|
break;
|
||||||
|
}
|
||||||
w(0, 'INSERT');
|
w(0, 'INSERT');
|
||||||
w(8, layerName);
|
w(8, layerName);
|
||||||
w(62, aci);
|
w(62, aci);
|
||||||
w(2, p.blockId ?? 'BLOCK');
|
w(2, mapped);
|
||||||
w(10, el.x); w(20, el.y); w(30, 0);
|
w(10, el.x); w(20, el.y); w(30, 0);
|
||||||
w(41, p.scale ?? 1);
|
const sx = typeof p.scaleX === 'number' ? p.scaleX : (typeof p.scale === 'number' ? p.scale : 1);
|
||||||
w(42, p.scale ?? 1);
|
const sy = typeof p.scaleY === 'number' ? p.scaleY : (typeof p.scale === 'number' ? p.scale : 1);
|
||||||
w(50, radToDeg(p.rotation ?? 0));
|
w(41, sx);
|
||||||
|
w(42, sy);
|
||||||
|
w(50, p.rotation ?? 0); // Grad, direkt (keine radToDeg-Doppelkonvertierung)
|
||||||
|
// Attributwerte der Instanz → ATTRIB/SEQEND (Task F6)
|
||||||
|
const attrValues = p.attrValues as Record<string, string> | undefined;
|
||||||
|
if (attrValues && Object.keys(attrValues).length > 0) {
|
||||||
|
w(66, 1); // Attribute folgen
|
||||||
|
for (const [tag, value] of Object.entries(attrValues)) {
|
||||||
|
w(0, 'ATTRIB');
|
||||||
|
w(8, layerName);
|
||||||
|
w(10, el.x); w(20, el.y); w(30, 0);
|
||||||
|
w(40, 12);
|
||||||
|
w(1, value);
|
||||||
|
w(2, tag);
|
||||||
|
w(70, 0);
|
||||||
|
}
|
||||||
|
w(0, 'SEQEND');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// For chair, seating-row, etc. — export as LWPOLYLINE bounding box
|
// chair, seating-row, table, stage, revcloud, leader, image, … → BBox
|
||||||
w(0, 'LWPOLYLINE');
|
writeBBoxPolyline(w, el, layerName, aci);
|
||||||
w(8, layerName);
|
|
||||||
w(62, aci);
|
|
||||||
w(90, 4);
|
|
||||||
w(70, 1);
|
|
||||||
w(10, el.x); w(20, el.y);
|
|
||||||
w(10, el.x + el.width); w(20, el.y);
|
|
||||||
w(10, el.x + el.width); w(20, el.y + el.height);
|
|
||||||
w(10, el.x); w(20, el.y + el.height);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +289,6 @@ function hexToACI(hex: string): number {
|
|||||||
const r = parseInt(h.substring(0, 2), 16);
|
const r = parseInt(h.substring(0, 2), 16);
|
||||||
const g = parseInt(h.substring(2, 4), 16);
|
const g = parseInt(h.substring(2, 4), 16);
|
||||||
const b = parseInt(h.substring(4, 6), 16);
|
const b = parseInt(h.substring(4, 6), 16);
|
||||||
// Map common colors to ACI
|
|
||||||
if (r > 200 && g < 100 && b < 100) return 1; // red
|
if (r > 200 && g < 100 && b < 100) return 1; // red
|
||||||
if (r > 200 && g > 200 && b < 100) return 2; // yellow
|
if (r > 200 && g > 200 && b < 100) return 2; // yellow
|
||||||
if (r < 100 && g > 200 && b < 100) return 3; // green
|
if (r < 100 && g > 200 && b < 100) return 3; // green
|
||||||
@@ -200,7 +297,7 @@ function hexToACI(hex: string): number {
|
|||||||
if (r > 200 && g < 100 && b > 200) return 6; // magenta
|
if (r > 200 && g < 100 && b > 200) return 6; // magenta
|
||||||
if (r > 200 && g > 200 && b > 200) return 7; // white
|
if (r > 200 && g > 200 && b > 200) return 7; // white
|
||||||
if (r < 100 && g < 100 && b < 100) return 8; // gray
|
if (r < 100 && g < 100 && b < 100) return 8; // gray
|
||||||
return 7; // default white
|
return 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineTypeToDXF(lt: string): string {
|
function lineTypeToDXF(lt: string): string {
|
||||||
@@ -208,7 +305,3 @@ function lineTypeToDXF(lt: string): string {
|
|||||||
if (lt === 'dotted') return 'DOT';
|
if (lt === 'dotted') return 'DOT';
|
||||||
return 'CONTINUOUS';
|
return 'CONTINUOUS';
|
||||||
}
|
}
|
||||||
|
|
||||||
function radToDeg(rad: number): number {
|
|
||||||
return (rad * 180) / Math.PI;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -59,8 +59,218 @@ export function importDXF(dxfString: string): ImportResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task F8 – SVG-Path-Parser: d-Attribut → Subpaths (Punkte + closed).
|
||||||
|
*
|
||||||
|
* Unterstützte Befehle: M/L/H/V/C/S/Q/T/A/Z — absolut und relativ (lowercase).
|
||||||
|
* Kubische/quadratische Bézier werden mit je 16/12 Segmenten geflattet,
|
||||||
|
* Arc (A) über Endpoint→Center-Parametrisierung (SVG Spec F.6.5) gesampelt.
|
||||||
|
* Implizite Befehlswiederholung („L 10 0 20 0") und M→L-Fortsetzung
|
||||||
|
* sind abgedeckt. viewBox-Offset (vbX/vbY) wird von allen Punkten abgezogen.
|
||||||
|
*/
|
||||||
|
interface PathPoint { x: number; y: number }
|
||||||
|
interface PathSubpath { points: PathPoint[]; closed: boolean }
|
||||||
|
|
||||||
|
function parsePathData(d: string, vbX: number, vbY: number): PathSubpath[] {
|
||||||
|
const tokens = d.match(/[MmLlHhVvCcSsQqTtAaZz]|-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) ?? [];
|
||||||
|
const subpaths: PathSubpath[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let cx = 0, cy = 0; // aktueller Punkt
|
||||||
|
let sx = 0, sy = 0; // Subpath-Start (für Z)
|
||||||
|
let pcx = 0, pcy = 0; // letzter kubischer Kontrollpunkt (für S)
|
||||||
|
let pqx = 0, pqy = 0; // letzter quadratischer Kontrollpunkt (für T)
|
||||||
|
let prevCubic = false, prevQuad = false;
|
||||||
|
let current: PathPoint[] | null = null;
|
||||||
|
let lastCmd = '';
|
||||||
|
|
||||||
|
const flush = (closed: boolean) => {
|
||||||
|
if (current && current.length >= 2) subpaths.push({ points: current, closed });
|
||||||
|
current = null;
|
||||||
|
};
|
||||||
|
const moveTo = (x: number, y: number) => {
|
||||||
|
flush(false);
|
||||||
|
current = [{ x, y }];
|
||||||
|
cx = x; cy = y; sx = x; sy = y;
|
||||||
|
};
|
||||||
|
const lineTo = (x: number, y: number) => {
|
||||||
|
if (!current) current = [{ x: cx, y: cy }];
|
||||||
|
current.push({ x, y });
|
||||||
|
cx = x; cy = y;
|
||||||
|
};
|
||||||
|
const cubicTo = (x1: number, y1: number, x2: number, y2: number, ex: number, ey: number) => {
|
||||||
|
const SEG = 16;
|
||||||
|
for (let s = 1; s <= SEG; s++) {
|
||||||
|
const t = s / SEG;
|
||||||
|
const mt = 1 - t;
|
||||||
|
const px = mt * mt * mt * cx + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t * ex;
|
||||||
|
const py = mt * mt * mt * cy + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t * ey;
|
||||||
|
if (!current) current = [{ x: cx, y: cy }];
|
||||||
|
current.push({ x: px, y: py });
|
||||||
|
}
|
||||||
|
pcx = x2; pcy = y2;
|
||||||
|
prevCubic = true; prevQuad = false;
|
||||||
|
cx = ex; cy = ey;
|
||||||
|
};
|
||||||
|
const quadTo = (x1: number, y1: number, ex: number, ey: number) => {
|
||||||
|
const SEG = 12;
|
||||||
|
for (let s = 1; s <= SEG; s++) {
|
||||||
|
const t = s / SEG;
|
||||||
|
const mt = 1 - t;
|
||||||
|
const px = mt * mt * cx + 2 * mt * t * x1 + t * t * ex;
|
||||||
|
const py = mt * mt * cy + 2 * mt * t * y1 + t * t * ey;
|
||||||
|
if (!current) current = [{ x: cx, y: cy }];
|
||||||
|
current.push({ x: px, y: py });
|
||||||
|
}
|
||||||
|
pqx = x1; pqy = y1;
|
||||||
|
prevQuad = true; prevCubic = false;
|
||||||
|
cx = ex; cy = ey;
|
||||||
|
};
|
||||||
|
const arcTo = (rx: number, ry: number, rotDeg: number, laf: number, sf: number, ex: number, ey: number) => {
|
||||||
|
if (rx === 0 || ry === 0 || (ex === cx && ey === cy)) { lineTo(ex, ey); return; }
|
||||||
|
// Endpoint → Center-Parametrisierung (SVG Spec F.6.5)
|
||||||
|
const rot = (rotDeg * Math.PI) / 180;
|
||||||
|
const cosP = Math.cos(rot), sinP = Math.sin(rot);
|
||||||
|
const dx = (cx - ex) / 2, dy = (cy - ey) / 2;
|
||||||
|
const x1p = cosP * dx + sinP * dy;
|
||||||
|
const y1p = -sinP * dx + cosP * dy;
|
||||||
|
let RX = Math.abs(rx), RY = Math.abs(ry);
|
||||||
|
const lambda = (x1p * x1p) / (RX * RX) + (y1p * y1p) / (RY * RY);
|
||||||
|
if (lambda > 1) {
|
||||||
|
const scale = Math.sqrt(lambda);
|
||||||
|
RX *= scale; RY *= scale;
|
||||||
|
}
|
||||||
|
const num = RX * RX * RY * RY - RX * RX * y1p * y1p - RY * RY * x1p * x1p;
|
||||||
|
const den = RX * RX * y1p * y1p + RY * RY * x1p * x1p;
|
||||||
|
let co = Math.sqrt(Math.max(0, den === 0 ? 0 : num / den));
|
||||||
|
if (laf === sf) co = -co;
|
||||||
|
const cxp = (co * RX * y1p) / RY;
|
||||||
|
const cyp = (-co * RY * x1p) / RX;
|
||||||
|
const ccx = cosP * cxp - sinP * cyp + (cx + ex) / 2;
|
||||||
|
const ccy = sinP * cxp + cosP * cyp + (cy + ey) / 2;
|
||||||
|
const ang = (ux: number, uy: number, vx: number, vy: number): number => {
|
||||||
|
const dot = ux * vx + uy * vy;
|
||||||
|
const len = Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy) || 1;
|
||||||
|
let a = Math.acos(Math.max(-1, Math.min(1, dot / len)));
|
||||||
|
if (ux * vy - uy * vx < 0) a = -a;
|
||||||
|
return a;
|
||||||
|
};
|
||||||
|
const theta1 = ang(1, 0, (x1p - cxp) / RX, (y1p - cyp) / RY);
|
||||||
|
let dTheta = ang((x1p - cxp) / RX, (y1p - cyp) / RY, (-x1p - cxp) / RX, (-y1p - cyp) / RY) % (2 * Math.PI);
|
||||||
|
if (!sf && dTheta > 0) dTheta -= 2 * Math.PI;
|
||||||
|
if (sf && dTheta < 0) dTheta += 2 * Math.PI;
|
||||||
|
const N = 32;
|
||||||
|
for (let s = 1; s <= N; s++) {
|
||||||
|
if (!current) current = [{ x: cx, y: cy }];
|
||||||
|
if (s === N) {
|
||||||
|
// Endpunkt exakt (Float-Rundung der Trigonometrie vermeiden)
|
||||||
|
current.push({ x: ex, y: ey });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const th = theta1 + dTheta * (s / N);
|
||||||
|
const px = ccx + cosP * RX * Math.cos(th) - sinP * RY * Math.sin(th);
|
||||||
|
const py = ccy + sinP * RX * Math.cos(th) + cosP * RY * Math.sin(th);
|
||||||
|
current.push({ x: px, y: py });
|
||||||
|
}
|
||||||
|
cx = ex; cy = ey;
|
||||||
|
prevCubic = prevQuad = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readNums = (n: number): number[] => Array.from({ length: n }, () => parseFloat(tokens[i++] ?? '0'));
|
||||||
|
const isCmd = (t: string) => /^[MmLlHhVvCcSsQqTtAaZz]$/.test(t);
|
||||||
|
|
||||||
|
while (i < tokens.length) {
|
||||||
|
let cmd = tokens[i++];
|
||||||
|
if (!isCmd(cmd)) {
|
||||||
|
// implizite Wiederholung des letzten Befehls; nach M gilt L
|
||||||
|
i--;
|
||||||
|
if (!lastCmd) break;
|
||||||
|
cmd = lastCmd === 'M' ? 'L' : lastCmd === 'm' ? 'l' : lastCmd;
|
||||||
|
} else {
|
||||||
|
lastCmd = cmd;
|
||||||
|
}
|
||||||
|
const up = cmd.toUpperCase();
|
||||||
|
const abs = cmd === up;
|
||||||
|
switch (up) {
|
||||||
|
case 'M': {
|
||||||
|
const [x, y] = readNums(2);
|
||||||
|
moveTo(abs ? x - vbX : cx + x, abs ? y - vbY : cy + y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'L': {
|
||||||
|
const [x, y] = readNums(2);
|
||||||
|
lineTo(abs ? x - vbX : cx + x, abs ? y - vbY : cy + y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'H': {
|
||||||
|
const [x] = readNums(1);
|
||||||
|
lineTo(abs ? x - vbX : cx + x, cy);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'V': {
|
||||||
|
const [y] = readNums(1);
|
||||||
|
lineTo(cx, abs ? y - vbY : cy + y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'C': {
|
||||||
|
const n = readNums(6);
|
||||||
|
cubicTo(
|
||||||
|
abs ? n[0] - vbX : cx + n[0], abs ? n[1] - vbY : cy + n[1],
|
||||||
|
abs ? n[2] - vbX : cx + n[2], abs ? n[3] - vbY : cy + n[3],
|
||||||
|
abs ? n[4] - vbX : cx + n[4], abs ? n[5] - vbY : cy + n[5],
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'S': {
|
||||||
|
const n = readNums(4);
|
||||||
|
const x1 = prevCubic ? 2 * cx - pcx : cx;
|
||||||
|
const y1 = prevCubic ? 2 * cy - pcy : cy;
|
||||||
|
cubicTo(
|
||||||
|
x1, y1,
|
||||||
|
abs ? n[0] - vbX : cx + n[0], abs ? n[1] - vbY : cy + n[1],
|
||||||
|
abs ? n[2] - vbX : cx + n[2], abs ? n[3] - vbY : cy + n[3],
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Q': {
|
||||||
|
const n = readNums(4);
|
||||||
|
quadTo(
|
||||||
|
abs ? n[0] - vbX : cx + n[0], abs ? n[1] - vbY : cy + n[1],
|
||||||
|
abs ? n[2] - vbX : cx + n[2], abs ? n[3] - vbY : cy + n[3],
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'T': {
|
||||||
|
const n = readNums(2);
|
||||||
|
const x1 = prevQuad ? 2 * cx - pqx : cx;
|
||||||
|
const y1 = prevQuad ? 2 * cy - pqy : cy;
|
||||||
|
quadTo(x1, y1, abs ? n[0] - vbX : cx + n[0], abs ? n[1] - vbY : cy + n[1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'A': {
|
||||||
|
const n = readNums(7);
|
||||||
|
arcTo(
|
||||||
|
n[0], n[1], n[2], n[3], n[4],
|
||||||
|
abs ? n[5] - vbX : cx + n[5], abs ? n[6] - vbY : cy + n[6],
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Z': {
|
||||||
|
// Polygon schließt automatisch — keinen duplizierten Endpunkt anhängen
|
||||||
|
flush(true);
|
||||||
|
cx = sx; cy = sy;
|
||||||
|
prevCubic = prevQuad = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(false);
|
||||||
|
return subpaths;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import SVG string — converts SVG elements to CAD elements.
|
* Import SVG string — converts SVG elements to CAD elements.
|
||||||
|
* Task F8: path-Support (M/L/H/V/C/S/Q/T/A/Z), rect/circle in App-Konvention
|
||||||
|
* (x/y = Zentrum), ellipse als geschlossenes Polygon.
|
||||||
*/
|
*/
|
||||||
export function importSVG(svgString: string): ImportResult {
|
export function importSVG(svgString: string): ImportResult {
|
||||||
try {
|
try {
|
||||||
@@ -79,6 +289,12 @@ export function importSVG(svgString: string): ImportResult {
|
|||||||
vbY = parts[1] || 0;
|
vbY = parts[1] || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bboxOf = (pts: PathPoint[]) => {
|
||||||
|
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
|
||||||
|
const minX = Math.min(...xs), minY = Math.min(...ys);
|
||||||
|
return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
|
||||||
|
};
|
||||||
|
|
||||||
// Process SVG elements
|
// Process SVG elements
|
||||||
const processElement = (node: Element) => {
|
const processElement = (node: Element) => {
|
||||||
const tag = node.tagName.toLowerCase();
|
const tag = node.tagName.toLowerCase();
|
||||||
@@ -101,38 +317,50 @@ export function importSVG(svgString: string): ImportResult {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'rect': {
|
case 'rect': {
|
||||||
|
// App-Konvention (Task F8): rect x/y = Zentrum der BBox
|
||||||
const x = parseFloat(node.getAttribute('x') || '0') - vbX;
|
const x = parseFloat(node.getAttribute('x') || '0') - vbX;
|
||||||
const y = parseFloat(node.getAttribute('y') || '0') - vbY;
|
const y = parseFloat(node.getAttribute('y') || '0') - vbY;
|
||||||
const w = parseFloat(node.getAttribute('width') || '0');
|
const w = parseFloat(node.getAttribute('width') || '0');
|
||||||
const h = parseFloat(node.getAttribute('height') || '0');
|
const h = parseFloat(node.getAttribute('height') || '0');
|
||||||
elements.push({
|
elements.push({
|
||||||
id: nextId(), type: 'rect', layerId: 'layer-0',
|
id: nextId(), type: 'rect', layerId: 'layer-0',
|
||||||
x, y, width: w, height: h,
|
x: x + w / 2, y: y + h / 2, width: w, height: h,
|
||||||
properties: { stroke, strokeWidth, fill: fill !== 'none' ? fill : undefined },
|
properties: { stroke, strokeWidth, fill: fill !== 'none' ? fill : undefined },
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'circle': {
|
case 'circle': {
|
||||||
|
// App-Konvention (Task F8): el.x/el.y = Zentrum direkt (Renderer liest es so)
|
||||||
const cx = parseFloat(node.getAttribute('cx') || '0') - vbX;
|
const cx = parseFloat(node.getAttribute('cx') || '0') - vbX;
|
||||||
const cy = parseFloat(node.getAttribute('cy') || '0') - vbY;
|
const cy = parseFloat(node.getAttribute('cy') || '0') - vbY;
|
||||||
const r = parseFloat(node.getAttribute('r') || '0');
|
const r = parseFloat(node.getAttribute('r') || '0');
|
||||||
elements.push({
|
elements.push({
|
||||||
id: nextId(), type: 'circle', layerId: 'layer-0',
|
id: nextId(), type: 'circle', layerId: 'layer-0',
|
||||||
x: cx - r, y: cy - r, width: r * 2, height: r * 2,
|
x: cx, y: cy, width: r * 2, height: r * 2,
|
||||||
properties: { stroke, strokeWidth, radius: r },
|
properties: { stroke, strokeWidth, radius: r, fill: fill !== 'none' ? fill : undefined },
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ellipse': {
|
case 'ellipse': {
|
||||||
|
// Task F8: echte Ellipse als geschlossenes Polygon (64 Punkte),
|
||||||
|
// statt Kreis mit max(rx, ry)
|
||||||
const cx = parseFloat(node.getAttribute('cx') || '0') - vbX;
|
const cx = parseFloat(node.getAttribute('cx') || '0') - vbX;
|
||||||
const cy = parseFloat(node.getAttribute('cy') || '0') - vbY;
|
const cy = parseFloat(node.getAttribute('cy') || '0') - vbY;
|
||||||
const rx = parseFloat(node.getAttribute('rx') || '0');
|
const rx = parseFloat(node.getAttribute('rx') || '0');
|
||||||
const ry = parseFloat(node.getAttribute('ry') || '0');
|
const ry = parseFloat(node.getAttribute('ry') || '0');
|
||||||
|
const pts: PathPoint[] = [];
|
||||||
|
const N = 64;
|
||||||
|
for (let s = 0; s < N; s++) {
|
||||||
|
const t = (s / N) * Math.PI * 2;
|
||||||
|
pts.push({ x: cx + rx * Math.cos(t), y: cy + ry * Math.sin(t) });
|
||||||
|
}
|
||||||
|
if (pts.length >= 3) {
|
||||||
elements.push({
|
elements.push({
|
||||||
id: nextId(), type: 'circle', layerId: 'layer-0',
|
id: nextId(), type: 'polygon', layerId: 'layer-0',
|
||||||
x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2,
|
...bboxOf(pts),
|
||||||
properties: { stroke, strokeWidth, radius: Math.max(rx, ry) },
|
properties: { stroke, strokeWidth, points: pts, fill: fill !== 'none' ? fill : undefined },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'polyline':
|
case 'polyline':
|
||||||
@@ -144,14 +372,26 @@ export function importSVG(svgString: string): ImportResult {
|
|||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
if (pts.length >= 2) {
|
if (pts.length >= 2) {
|
||||||
const minX = Math.min(...pts.map(p => p.x));
|
|
||||||
const minY = Math.min(...pts.map(p => p.y));
|
|
||||||
const maxX = Math.max(...pts.map(p => p.x));
|
|
||||||
const maxY = Math.max(...pts.map(p => p.y));
|
|
||||||
elements.push({
|
elements.push({
|
||||||
id: nextId(), type: tag === 'polygon' ? 'polygon' : 'polyline', layerId: 'layer-0',
|
id: nextId(), type: tag === 'polygon' ? 'polygon' : 'polyline', layerId: 'layer-0',
|
||||||
x: minX, y: minY, width: maxX - minX, height: maxY - minY,
|
...bboxOf(pts),
|
||||||
properties: { stroke, strokeWidth, points: pts },
|
properties: { stroke, strokeWidth, points: pts, fill: fill !== 'none' ? fill : undefined },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'path': {
|
||||||
|
// Task F8: d-Attribut → Subpaths (polyline/polygon je Z-Flag)
|
||||||
|
const d = node.getAttribute('d') || '';
|
||||||
|
for (const sub of parsePathData(d, vbX, vbY)) {
|
||||||
|
elements.push({
|
||||||
|
id: nextId(), type: sub.closed ? 'polygon' : 'polyline', layerId: 'layer-0',
|
||||||
|
...bboxOf(sub.points),
|
||||||
|
properties: {
|
||||||
|
stroke, strokeWidth,
|
||||||
|
points: sub.points,
|
||||||
|
fill: sub.closed && fill !== 'none' ? fill : undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -189,6 +429,22 @@ export function importSVG(svgString: string): ImportResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task F8 – SVG als Block-Payload für die globale Bibliothek.
|
||||||
|
*
|
||||||
|
* Liefert die echte Geometrie (CADElement-Array als JSON-String) plus das
|
||||||
|
* Original-SVG als Thumbnail. Ohne Geometrie (z.B. nur <defs>) ist
|
||||||
|
* blockData null — der Caller fällt dann auf den {type:'svg'}-Raw-Fallback
|
||||||
|
* zurück.
|
||||||
|
*/
|
||||||
|
export function importSVGAsBlockPayload(svgString: string): { blockData: string | null; svg: string } {
|
||||||
|
const res = importSVG(svgString);
|
||||||
|
return {
|
||||||
|
blockData: res.success && res.elements.length > 0 ? JSON.stringify(res.elements) : null,
|
||||||
|
svg: svgString,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import JSON project file.
|
* Import JSON project file.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
*/
|
*/
|
||||||
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
|
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
|
||||||
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
|
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
|
||||||
|
import { elementToPrimitives, type ShapePrimitive } from '../render/pixi/elementDrawers';
|
||||||
|
import type { CADDocument } from '../kernel/document/CADDocument';
|
||||||
|
|
||||||
|
export interface LayoutPDFOptions {
|
||||||
|
/** Alles in Schwarz drucken (Task G4). */
|
||||||
|
monochrome?: boolean;
|
||||||
|
/** Seitenränder in pt (Default 20). */
|
||||||
|
margin?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export project data to a PDF byte array.
|
* Export project data to a PDF byte array.
|
||||||
@@ -172,3 +181,196 @@ function hexToRgb(hex: string | undefined): ReturnType<typeof rgb> | null {
|
|||||||
const b = parseInt(h.substring(4, 6), 16) / 255;
|
const b = parseInt(h.substring(4, 6), 16) / 255;
|
||||||
return rgb(r, g, b);
|
return rgb(r, g, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Task G4 — PDF-Export je Layout im Ausgabemaßstab
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/** A4 landscape in Punkten. */
|
||||||
|
const A4_LANDSCAPE_W = 842;
|
||||||
|
const A4_LANDSCAPE_H = 595;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exportiert EIN Layout als PDF (A4 landscape).
|
||||||
|
*
|
||||||
|
* Der gespeicherte Viewport (center/scale) definiert den sichtbaren
|
||||||
|
* Welt-Ausschnitt; die Zeichnung wird in den Ausgabemaßstab skaliert
|
||||||
|
* (Welt-pt × viewport.scale). Geometrie läuft über elementToPrimitives
|
||||||
|
* (C2) — identische Konventionen wie Pixi-Renderer (circle el.x/el.y =
|
||||||
|
* Zentrum). Linienbreiten: properties.strokeWidth, andernfalls Linientyp
|
||||||
|
* aus dem Layer (solid 0.7pt, dashed 0.9pt, dotted 0.5pt) mit passenden
|
||||||
|
* Dash-Patterns. `monochrome` erzwingt Schwarz. Der Titelblock aus
|
||||||
|
* layout.frameBlockRef wird mitgezeichnet (unskaliert als Rahmen unten
|
||||||
|
* rechts, Elemente relativ zur Ecke).
|
||||||
|
*/
|
||||||
|
export async function exportLayoutPDF(
|
||||||
|
doc: CADDocument,
|
||||||
|
layoutId: string,
|
||||||
|
options: LayoutPDFOptions = {},
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const layout = doc.getLayout(layoutId);
|
||||||
|
if (!layout) throw new Error(`exportLayoutPDF: Layout '${layoutId}' nicht gefunden`);
|
||||||
|
|
||||||
|
const margin = options.margin ?? 20;
|
||||||
|
const pdfDoc = await PDFDocument.create();
|
||||||
|
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||||
|
const page = pdfDoc.addPage([A4_LANDSCAPE_W, A4_LANDSCAPE_H]);
|
||||||
|
const mono = options.monochrome === true;
|
||||||
|
|
||||||
|
const layers = doc.getAllLayers();
|
||||||
|
const visibleLayerIds = new Set(layers.filter((l) => l.visible).map((l) => l.id));
|
||||||
|
const layerById = new Map(layers.map((l) => [l.id, l]));
|
||||||
|
|
||||||
|
// ── Ausgabemaßstab: Welt → pt ──
|
||||||
|
const scale = layout.viewport.scale;
|
||||||
|
const viewW = (A4_LANDSCAPE_W - 2 * margin) / scale;
|
||||||
|
const viewH = (A4_LANDSCAPE_H - 2 * margin) / scale;
|
||||||
|
const worldMinX = layout.viewport.center.x - viewW / 2;
|
||||||
|
const worldMinY = layout.viewport.center.y - viewH / 2;
|
||||||
|
|
||||||
|
// PDF-Y-Achse zeigt nach OBEN (bottom-left origin) — CAD nach unten:
|
||||||
|
const toPage = (wx: number, wy: number): { x: number; y: number } => ({
|
||||||
|
x: margin + (wx - worldMinX) * scale,
|
||||||
|
y: A4_LANDSCAPE_H - margin - (wy - worldMinY) * scale,
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorFor = (el: CADElement): ReturnType<typeof rgb> => {
|
||||||
|
if (mono) return rgb(0, 0, 0);
|
||||||
|
const layer = layerById.get(el.layerId);
|
||||||
|
const hex = (el.properties.stroke as string | undefined) ?? layer?.color;
|
||||||
|
return hexToRgb(hex) ?? rgb(0, 0, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineWidthFor = (el: CADElement): number => {
|
||||||
|
const explicit = el.properties.strokeWidth as number | undefined;
|
||||||
|
if (typeof explicit === 'number' && explicit > 0) return explicit;
|
||||||
|
const layer = layerById.get(el.layerId);
|
||||||
|
switch (layer?.lineType) {
|
||||||
|
case 'dashed': return 0.9;
|
||||||
|
case 'dotted': return 0.5;
|
||||||
|
default: return 0.7;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dashFor = (el: CADElement): number[] | undefined => {
|
||||||
|
if (mono) return undefined;
|
||||||
|
const layer = layerById.get(el.layerId);
|
||||||
|
if (layer?.lineType === 'dashed') return [4, 3];
|
||||||
|
if (layer?.lineType === 'dotted') return [0.5, 2];
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawPrimitives = (els: CADElement[], transform: (wx: number, wy: number) => { x: number; y: number }, scaleOverride?: number): void => {
|
||||||
|
for (const el of els) {
|
||||||
|
if (!visibleLayerIds.has(el.layerId)) continue;
|
||||||
|
const prims = elementToPrimitives(el);
|
||||||
|
const lw = lineWidthFor(el) * (scaleOverride ?? scale);
|
||||||
|
const dash = dashFor(el);
|
||||||
|
const color = colorFor(el);
|
||||||
|
for (const prim of prims) {
|
||||||
|
drawPrimitive(page, prim, transform, lw, color, dash, font, mono);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Modellinhalt ──
|
||||||
|
drawPrimitives(doc.getAllElements(), toPage);
|
||||||
|
|
||||||
|
// ── Titelblock (frameBlockRef) unten rechts, unskaliert ──
|
||||||
|
if (layout.frameBlockRef) {
|
||||||
|
const block = doc.getBlock(layout.frameBlockRef);
|
||||||
|
if (block) {
|
||||||
|
const FRAME_MARGIN = 12;
|
||||||
|
// Block-Elemente liegen um (0,0) herum — an Ecke unten rechts verschieben
|
||||||
|
const frameOriginX = A4_LANDSCAPE_W - margin - 190 - FRAME_MARGIN;
|
||||||
|
const frameOriginY = margin + FRAME_MARGIN;
|
||||||
|
const frameTransform = (wx: number, wy: number) => ({
|
||||||
|
x: frameOriginX + wx,
|
||||||
|
y: frameOriginY + wy,
|
||||||
|
});
|
||||||
|
drawPrimitives(block.elements, frameTransform, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pdfDoc.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichnet ein einzelnes ShapePrimitive auf die PDF-Seite. */
|
||||||
|
function drawPrimitive(
|
||||||
|
page: any,
|
||||||
|
prim: ShapePrimitive,
|
||||||
|
transform: (wx: number, wy: number) => { x: number; y: number },
|
||||||
|
lineWidth: number,
|
||||||
|
color: ReturnType<typeof rgb>,
|
||||||
|
dash: number[] | undefined,
|
||||||
|
font: any,
|
||||||
|
mono: boolean,
|
||||||
|
): void {
|
||||||
|
switch (prim.kind) {
|
||||||
|
case 'line': {
|
||||||
|
const s = transform(prim.from.x, prim.from.y);
|
||||||
|
const e = transform(prim.to.x, prim.to.y);
|
||||||
|
page.drawLine({ start: s, end: e, thickness: lineWidth, color, dashArray: dash });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rect': {
|
||||||
|
const tl = transform(prim.x, prim.y + prim.h);
|
||||||
|
page.drawRectangle({
|
||||||
|
x: tl.x, y: tl.y, width: prim.w, height: prim.h,
|
||||||
|
borderColor: color, borderWidth: lineWidth,
|
||||||
|
...(prim.fill ? { color: hexToRgb(prim.fill) ?? color } : {}),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'circle': {
|
||||||
|
const c = transform(prim.center.x, prim.center.y);
|
||||||
|
page.drawCircle({
|
||||||
|
x: c.x, y: c.y, radius: prim.radius,
|
||||||
|
borderColor: color, borderWidth: lineWidth,
|
||||||
|
...(prim.fill ? { color: hexToRgb(prim.fill) ?? color } : {}),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'arc': {
|
||||||
|
// Arc → 32 Liniensegmente (Grad, 0° = 3 Uhr, CCW — CAD-Konvention)
|
||||||
|
const c = transform(prim.center.x, prim.center.y);
|
||||||
|
const SEG = 32;
|
||||||
|
const a0 = (prim.startDeg * Math.PI) / 180;
|
||||||
|
const a1 = (prim.endDeg * Math.PI) / 180;
|
||||||
|
let span = a1 - a0;
|
||||||
|
if (span <= 0) span += 2 * Math.PI;
|
||||||
|
let prev = { x: c.x + Math.cos(a0) * prim.radius, y: c.y + Math.sin(a0) * prim.radius };
|
||||||
|
for (let i = 1; i <= SEG; i++) {
|
||||||
|
const a = a0 + (span * i) / SEG;
|
||||||
|
const pt = { x: c.x + Math.cos(a) * prim.radius, y: c.y + Math.sin(a) * prim.radius };
|
||||||
|
page.drawLine({ start: prev, end: pt, thickness: lineWidth, color, dashArray: dash });
|
||||||
|
prev = pt;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'polyline': {
|
||||||
|
for (let i = 0; i < prim.points.length - 1; i++) {
|
||||||
|
const s = transform(prim.points[i].x, prim.points[i].y);
|
||||||
|
const e = transform(prim.points[i + 1].x, prim.points[i + 1].y);
|
||||||
|
page.drawLine({ start: s, end: e, thickness: lineWidth, color, dashArray: dash });
|
||||||
|
}
|
||||||
|
if (prim.close && prim.points.length > 2) {
|
||||||
|
const s = transform(prim.points[prim.points.length - 1].x, prim.points[prim.points.length - 1].y);
|
||||||
|
const e = transform(prim.points[0].x, prim.points[0].y);
|
||||||
|
page.drawLine({ start: s, end: e, thickness: lineWidth, color, dashArray: dash });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
const at = transform(prim.at.x, prim.at.y);
|
||||||
|
page.drawText(prim.text, {
|
||||||
|
x: at.x, y: at.y - prim.fontSize,
|
||||||
|
size: prim.fontSize,
|
||||||
|
font,
|
||||||
|
color: mono ? rgb(0, 0, 0) : (hexToRgb(prim.color) ?? color),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Task G5 – Browser-Print-Service.
|
||||||
|
*
|
||||||
|
* Bereitet die App für window.print() vor: Der Body erhält die Klasse
|
||||||
|
* `print-layout`, das @media print Stylesheet blendet das UI-Chrome aus
|
||||||
|
* und zeigt nur den Druckbereich (Canvas/Layout). Nach dem Druck wird
|
||||||
|
* die Klasse wieder entfernt (kein DOM-Mutation-Restzustand).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PRINT_CLASS = 'print-layout';
|
||||||
|
|
||||||
|
/** Setzt den Print-Modus (Body-Klasse; @media print regelt das Rendering). */
|
||||||
|
export function preparePrintLayout(): void {
|
||||||
|
document.body.classList.add(PRINT_CLASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt den Print-Modus (nach Druck oder bei Abbruch). */
|
||||||
|
export function cleanupPrintLayout(): void {
|
||||||
|
document.body.classList.remove(PRINT_CLASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Druck-Flow: Vorbereitung → Rendern der nächsten Frames (Styles müssen
|
||||||
|
* greifen, bevor der Browser den Print-Dialog aufbaut) → window.print
|
||||||
|
* → Aufräumen. await-fähig für Tests (window.print wird gemockt).
|
||||||
|
*/
|
||||||
|
export async function printLayout(): Promise<void> {
|
||||||
|
preparePrintLayout();
|
||||||
|
// Zwei Frames warten: Style-Anwendung + Layout der Print-Ansicht
|
||||||
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
try {
|
||||||
|
window.print();
|
||||||
|
} finally {
|
||||||
|
cleanupPrintLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Task H2 – Projekt-Templates: Branchen-Presets als Startzustand.
|
||||||
|
*
|
||||||
|
* Jedes Preset definiert: gridSize + unit (Raster), Layer-Set,
|
||||||
|
* enabledPlugins (welche V2-Plugins aktiviert werden) und einen
|
||||||
|
* libraryFolderSeed (Startordner der Block-Bibliothek).
|
||||||
|
*/
|
||||||
|
import type { CADLayer } from '../types/cad.types';
|
||||||
|
import type { CADDocument } from '../kernel/document/CADDocument';
|
||||||
|
|
||||||
|
export type ProjectTemplateId = 'halle' | 'openair' | 'saal' | 'messe' | 'buero' | 'garten';
|
||||||
|
|
||||||
|
export interface ProjectTemplate {
|
||||||
|
id: ProjectTemplateId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
gridSize: number;
|
||||||
|
unit: 'mm' | 'cm' | 'm';
|
||||||
|
layers: CADLayer[];
|
||||||
|
enabledPlugins: string[];
|
||||||
|
libraryFolderSeed: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layer(id: string, name: string, color: string, sortOrder: number): CADLayer {
|
||||||
|
return {
|
||||||
|
id, name, visible: true, locked: false,
|
||||||
|
color, lineType: 'solid', transparency: 0, sortOrder, parentId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROJECT_TEMPLATES: ProjectTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 'halle',
|
||||||
|
name: 'Halle',
|
||||||
|
description: 'Veranstaltungshalle: Bestuhlung, Bühne, Technik, Wege',
|
||||||
|
gridSize: 100,
|
||||||
|
unit: 'cm',
|
||||||
|
layers: [
|
||||||
|
layer('tpl-h-wand', 'Wände', '#8a8f98', 0),
|
||||||
|
layer('tpl-h-bestuhlung', 'Bestuhlung', '#4a90d9', 1),
|
||||||
|
layer('tpl-h-buehne', 'Bühne', '#c0392b', 2),
|
||||||
|
layer('tpl-h-technik', 'Technik', '#f39c12', 3),
|
||||||
|
],
|
||||||
|
enabledPlugins: ['event-tools', 'core-drawing', 'core-modify', 'core-annotate', 'builtin-library'],
|
||||||
|
libraryFolderSeed: 'Veranstaltungstechnik',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'openair',
|
||||||
|
name: 'OpenAir',
|
||||||
|
description: 'Open-Air-Gelände: Zonen, Bestuhlung, Absperrungen, Wege',
|
||||||
|
gridSize: 100,
|
||||||
|
unit: 'cm',
|
||||||
|
layers: [
|
||||||
|
layer('tpl-o-zonen', 'Zonen', '#27ae60', 0),
|
||||||
|
layer('tpl-o-bestuhlung', 'Bestuhlung', '#4a90d9', 1),
|
||||||
|
layer('tpl-o-absperrung', 'Absperrung', '#c0392b', 2),
|
||||||
|
layer('tpl-o-wege', 'Wege', '#b8a888', 3),
|
||||||
|
],
|
||||||
|
enabledPlugins: ['event-tools', 'core-drawing', 'core-modify', 'core-annotate', 'builtin-library'],
|
||||||
|
libraryFolderSeed: 'OpenAir',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'saal',
|
||||||
|
name: 'Saal',
|
||||||
|
description: 'Festsaal: Bestuhlung, Bühne, Catering, Deko',
|
||||||
|
gridSize: 50,
|
||||||
|
unit: 'cm',
|
||||||
|
layers: [
|
||||||
|
layer('tpl-s-bestuhlung', 'Bestuhlung', '#4a90d9', 0),
|
||||||
|
layer('tpl-s-buehne', 'Bühne', '#c0392b', 1),
|
||||||
|
layer('tpl-s-catering', 'Catering', '#f39c12', 2),
|
||||||
|
layer('tpl-s-deko', 'Deko', '#9b59b6', 3),
|
||||||
|
],
|
||||||
|
enabledPlugins: ['event-tools', 'core-drawing', 'core-modify', 'core-annotate', 'builtin-library'],
|
||||||
|
libraryFolderSeed: 'Eventservice',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'messe',
|
||||||
|
name: 'Messe',
|
||||||
|
description: 'Messestand: Standlayout, Theken, Medien, Strom',
|
||||||
|
gridSize: 10,
|
||||||
|
unit: 'mm',
|
||||||
|
layers: [
|
||||||
|
layer('tpl-m-stand', 'Standlayout', '#e74c3c', 0),
|
||||||
|
layer('tpl-m-theken', 'Theken', '#f39c12', 1),
|
||||||
|
layer('tpl-m-medien', 'Medien', '#3498db', 2),
|
||||||
|
layer('tpl-m-strom', 'Strom', '#f1c40f', 3),
|
||||||
|
],
|
||||||
|
enabledPlugins: ['core-drawing', 'core-modify', 'core-annotate', 'builtin-library'],
|
||||||
|
libraryFolderSeed: 'Messebau',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'buero',
|
||||||
|
name: 'Büro',
|
||||||
|
description: 'Büroplanung: Möbel, Wände, Netzwerk, Beschriftung',
|
||||||
|
gridSize: 10,
|
||||||
|
unit: 'mm',
|
||||||
|
layers: [
|
||||||
|
layer('tpl-b-waende', 'Wände', '#8a8f98', 0),
|
||||||
|
layer('tpl-b-moebel', 'Möbel', '#4a90d9', 1),
|
||||||
|
layer('tpl-b-netzwerk', 'Netzwerk', '#27ae60', 2),
|
||||||
|
layer('tpl-b-text', 'Beschriftung', '#9aa0a6', 3),
|
||||||
|
],
|
||||||
|
enabledPlugins: ['core-drawing', 'core-modify', 'core-annotate', 'builtin-library'],
|
||||||
|
libraryFolderSeed: 'Büroeinrichtung',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'garten',
|
||||||
|
name: 'Garten',
|
||||||
|
description: 'Gartenplanung: Bepflanzung, Wege, Wasser, Beleuchtung',
|
||||||
|
gridSize: 100,
|
||||||
|
unit: 'cm',
|
||||||
|
layers: [
|
||||||
|
layer('tpl-g-bepflanzung', 'Bepflanzung', '#27ae60', 0),
|
||||||
|
layer('tpl-g-wege', 'Wege', '#b8a888', 1),
|
||||||
|
layer('tpl-g-wasser', 'Wasser', '#3498db', 2),
|
||||||
|
layer('tpl-g-licht', 'Beleuchtung', '#f1c40f', 3),
|
||||||
|
],
|
||||||
|
enabledPlugins: ['core-drawing', 'core-modify', 'core-annotate', 'builtin-library'],
|
||||||
|
libraryFolderSeed: 'Gartenlandschaft',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Template per ID (oder undefined). */
|
||||||
|
export function getProjectTemplate(id: string): ProjectTemplate | undefined {
|
||||||
|
return PROJECT_TEMPLATES.find((t) => t.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Überträgt ein Template auf ein Dokument: Layer-Set wird ERSETZT
|
||||||
|
* (Template = Startzustand). Grid/unit/pluginSeed sind UI-Einstellungen,
|
||||||
|
* die der Aufrufer aus dem Template liest.
|
||||||
|
*/
|
||||||
|
export function applyProjectTemplate(doc: CADDocument, tpl: ProjectTemplate): void {
|
||||||
|
doc.replaceLayers(tpl.layers);
|
||||||
|
}
|
||||||
@@ -280,6 +280,47 @@ export class SeatingService {
|
|||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create an arc row (curved seating around a center point) — Task E2 */
|
||||||
|
createArcRow(
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
radius: number,
|
||||||
|
startAngleDeg: number,
|
||||||
|
endAngleDeg: number,
|
||||||
|
layerId: string,
|
||||||
|
spacing = 50,
|
||||||
|
): CADElement[] {
|
||||||
|
const arcLen = Math.abs(endAngleDeg - startAngleDeg) * Math.PI / 180 * radius;
|
||||||
|
const count = Math.max(2, Math.floor(arcLen / spacing));
|
||||||
|
const rowId = `arcrow_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const elements: CADElement[] = [];
|
||||||
|
for (let i = 0; i <= count; i++) {
|
||||||
|
const angle = startAngleDeg + (endAngleDeg - startAngleDeg) * (i / count);
|
||||||
|
const rad = angle * Math.PI / 180;
|
||||||
|
const px = centerX + radius * Math.cos(rad);
|
||||||
|
const py = centerY + radius * Math.sin(rad);
|
||||||
|
elements.push({
|
||||||
|
id: this.generateId(),
|
||||||
|
type: 'chair',
|
||||||
|
layerId,
|
||||||
|
x: px,
|
||||||
|
y: py,
|
||||||
|
width: 45,
|
||||||
|
height: 45,
|
||||||
|
properties: {
|
||||||
|
rotation: angle + 90,
|
||||||
|
fill: '#4a90d9',
|
||||||
|
backrestColor: '#3a7ac9',
|
||||||
|
outlineColor: '#2a5a99',
|
||||||
|
seatType: 'standard',
|
||||||
|
rowIndex: i,
|
||||||
|
rowId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
/** Create a table element */
|
/** Create a table element */
|
||||||
createTable(x: number, y: number, layerId: string, config: Partial<TableConfig> = {}): CADElement {
|
createTable(x: number, y: number, layerId: string, config: Partial<TableConfig> = {}): CADElement {
|
||||||
const cfg = { ...DEFAULT_TABLE, ...config };
|
const cfg = { ...DEFAULT_TABLE, ...config };
|
||||||
@@ -318,6 +359,7 @@ export class SeatingService {
|
|||||||
stroke: '#1a2e3f',
|
stroke: '#1a2e3f',
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
label: cfg.label,
|
label: cfg.label,
|
||||||
|
height: cfg.height, // Bühnenhöhe in cm (Task E7: stage height attr)
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -384,6 +426,23 @@ export class SeatingService {
|
|||||||
return { total, byRow, byBlock };
|
return { total, byRow, byBlock };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task E5: Exportiert eine Sitzplatzliste als CSV-String.
|
||||||
|
* Spalten: Typ, rowId, blockId, X, Y
|
||||||
|
* Zeilentrenner: \n (kompatibel mit Excel via „Daten importieren“).
|
||||||
|
*/
|
||||||
|
exportSeatListCSV(elements: CADElement[]): string {
|
||||||
|
const header = 'type,row_id,block_id,x,y';
|
||||||
|
const rows: string[] = [header];
|
||||||
|
for (const el of elements) {
|
||||||
|
if (el.type !== 'chair') continue;
|
||||||
|
const rowId = (el.properties.rowId as string | undefined) ?? '';
|
||||||
|
const blockId = (el.properties.blockId as string | undefined) ?? '';
|
||||||
|
rows.push(`chair,${rowId},${blockId},${el.x.toFixed(1)},${el.y.toFixed(1)}`);
|
||||||
|
}
|
||||||
|
return rows.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/** Get all chairs belonging to a specific row */
|
/** Get all chairs belonging to a specific row */
|
||||||
getRowElements(elements: CADElement[], rowId: string): CADElement[] {
|
getRowElements(elements: CADElement[], rowId: string): CADElement[] {
|
||||||
return elements.filter(el => el.type === 'chair' && el.properties.rowId === rowId);
|
return elements.filter(el => el.type === 'chair' && el.properties.rowId === rowId);
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Task G3 – Titelblock-Service.
|
||||||
|
*
|
||||||
|
* Erzeugt eine Titelblock-Blockdefinition mit attributierten Texten
|
||||||
|
* (Projekt/Datum/Maßstab/Autor) nach der ATTDEF-Konvention aus F5/F6
|
||||||
|
* (properties.tag + attdef:true + text 'TAG=Wert'), sodass:
|
||||||
|
* - der DXF-Writer (F6) sie als ATTDEF in Blockdefinitionen exportiert
|
||||||
|
* - das spätere PropertiesPanel (D12) daraus ein Formular bauen kann
|
||||||
|
* - Layouts über frameBlockRef referenzieren (G1)
|
||||||
|
*/
|
||||||
|
import type { CADElement, BlockDefinition } from '../types/cad.types';
|
||||||
|
import type { CADDocument } from '../kernel/document/CADDocument';
|
||||||
|
|
||||||
|
export const TITLE_BLOCK_NAME = 'Titelblock A4';
|
||||||
|
|
||||||
|
export interface TitleBlockMeta {
|
||||||
|
project?: string;
|
||||||
|
date?: string;
|
||||||
|
scale?: string;
|
||||||
|
author?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttrSlot {
|
||||||
|
tag: string;
|
||||||
|
prompt: string;
|
||||||
|
/** Position der Textzeile relativ zum Block-Ursprung (unten-links im Rahmen). */
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOTS: AttrSlot[] = [
|
||||||
|
{ tag: 'PROJEKT', prompt: 'Projektname', dx: 10, dy: 18 },
|
||||||
|
{ tag: 'DATUM', prompt: 'Datum der Zeichnung', dx: 10, dy: 36 },
|
||||||
|
{ tag: 'MASSSTAB', prompt: 'Zeichnungsmaßstab', dx: 10, dy: 54 },
|
||||||
|
{ tag: 'AUTOR', prompt: 'Bearbeiter/Autor', dx: 10, dy: 72 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FRAME_W = 180;
|
||||||
|
const FRAME_H = 100;
|
||||||
|
const FALLBACK = '\u2014'; // —
|
||||||
|
|
||||||
|
let idSeq = 0;
|
||||||
|
function nid(prefix: string): string {
|
||||||
|
return `titleblk-${prefix}-${Date.now()}-${idSeq++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Titelblock-Elemente: Rahmen (Zentrum 0,0) + Titel + 4 attributierte Texte.
|
||||||
|
* Rein — keine Doc-Interaktion (unit-testbar).
|
||||||
|
*/
|
||||||
|
export function createTitleBlockElements(meta: TitleBlockMeta): CADElement[] {
|
||||||
|
const els: CADElement[] = [];
|
||||||
|
|
||||||
|
// Rahmen: rect in App-Konvention (x/y = Zentrum), Umriss unten-links
|
||||||
|
els.push({
|
||||||
|
id: nid('frame'),
|
||||||
|
type: 'rect',
|
||||||
|
layerId: 'layer-0',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: FRAME_W,
|
||||||
|
height: FRAME_H,
|
||||||
|
properties: {
|
||||||
|
stroke: '#e0e0e0',
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
fill: 'none',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Titel (kein Attribut — fester Blockname als Beschriftung)
|
||||||
|
els.push({
|
||||||
|
id: nid('title'),
|
||||||
|
type: 'text',
|
||||||
|
layerId: 'layer-0',
|
||||||
|
x: FRAME_W / 2 - 10,
|
||||||
|
y: FRAME_H / 2 - 6,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
properties: {
|
||||||
|
text: TITLE_BLOCK_NAME,
|
||||||
|
fontSize: 10,
|
||||||
|
stroke: '#9aa0a6',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const valueFor = (tag: string): string => {
|
||||||
|
switch (tag) {
|
||||||
|
case 'PROJEKT': return meta.project?.trim() || FALLBACK;
|
||||||
|
case 'DATUM': return meta.date?.trim() || FALLBACK;
|
||||||
|
case 'MASSSTAB': return meta.scale?.trim() || FALLBACK;
|
||||||
|
case 'AUTOR': return meta.author?.trim() || FALLBACK;
|
||||||
|
default: return FALLBACK;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const slot of SLOTS) {
|
||||||
|
els.push({
|
||||||
|
id: nid('attr'),
|
||||||
|
type: 'text',
|
||||||
|
layerId: 'layer-0',
|
||||||
|
x: -FRAME_W / 2 + slot.dx,
|
||||||
|
y: -FRAME_H / 2 + slot.dy,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
properties: {
|
||||||
|
text: `${slot.tag}=${valueFor(slot.tag)}`,
|
||||||
|
fontSize: 9,
|
||||||
|
stroke: '#e0e0e0',
|
||||||
|
tag: slot.tag,
|
||||||
|
prompt: slot.prompt,
|
||||||
|
attdef: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return els;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-insert: Erzeugt Blockdefinition + setzt frameBlockRef im Layout.
|
||||||
|
* Bestehende frameBlockRef wird ersetzt (Re-Insert aktualisiert Metadaten
|
||||||
|
* über einen neuen Block; der alte Block bleibt unreferenziert bestehen).
|
||||||
|
* @returns Block-ID des neuen Titelblocks.
|
||||||
|
* @throws wenn Layout nicht existiert.
|
||||||
|
*/
|
||||||
|
export function insertTitleBlockForLayout(
|
||||||
|
doc: CADDocument,
|
||||||
|
layoutId: string,
|
||||||
|
meta: TitleBlockMeta,
|
||||||
|
): string {
|
||||||
|
const layout = doc.getLayout(layoutId);
|
||||||
|
if (!layout) throw new Error(`insertTitleBlockForLayout: Layout '${layoutId}' nicht gefunden`);
|
||||||
|
|
||||||
|
const blockId = nid('blk');
|
||||||
|
const block: BlockDefinition = {
|
||||||
|
id: blockId,
|
||||||
|
name: TITLE_BLOCK_NAME,
|
||||||
|
description: 'Titelblock mit Attributen (Projekt/Datum/Maßstab/Autor) — Task G3',
|
||||||
|
category: 'frame',
|
||||||
|
elements: createTitleBlockElements(meta),
|
||||||
|
};
|
||||||
|
doc.addBlock(block);
|
||||||
|
|
||||||
|
doc.updateLayout(layoutId, { frameBlockRef: blockId });
|
||||||
|
return blockId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Task H1 – UI-Modus simple/pro.
|
||||||
|
*
|
||||||
|
* - 'pro': vollständige Werkzeugpalette + CommandLine (Default)
|
||||||
|
* - 'simple': nur basic-Tools, keine CommandLine
|
||||||
|
*
|
||||||
|
* Persistenz über localStorage ('webcad.uiMode'), Subscribe-Set für
|
||||||
|
* Live-Updates (RibbonBar/CommandLine/Topbar hören zu). Bewusst KEIN
|
||||||
|
* zustand (Q2-Entscheidung wurde nie umgesetzt; Muster folgt dem
|
||||||
|
* etablierten documentService/featureFlags-Stil des Projekts).
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export type UIMode = 'simple' | 'pro';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'webcad.uiMode';
|
||||||
|
const listeners = new Set<(mode: UIMode) => void>();
|
||||||
|
|
||||||
|
function readStored(): UIMode {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === 'simple' || raw === 'pro') return raw;
|
||||||
|
} catch {
|
||||||
|
// localStorage nicht verfügbar — Default
|
||||||
|
}
|
||||||
|
return 'pro';
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: UIMode = readStored();
|
||||||
|
|
||||||
|
/** Aktueller UI-Modus (Default pro, persistiert). */
|
||||||
|
export function getUIMode(): UIMode {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wechselt den Modus und persistiert. */
|
||||||
|
export function setUIMode(mode: UIMode): void {
|
||||||
|
current = mode;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, mode);
|
||||||
|
} catch {
|
||||||
|
// Persistenz optional
|
||||||
|
}
|
||||||
|
for (const cb of listeners) cb(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle simple ↔ pro. */
|
||||||
|
export function toggleUIMode(): void {
|
||||||
|
setUIMode(current === 'pro' ? 'simple' : 'pro');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe auf Moduswechsel; gibt Unsubscribe zurück. */
|
||||||
|
export function subscribeUIMode(cb: (mode: UIMode) => void): () => void {
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => { listeners.delete(cb); };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** React-Hook: aktueller Modus mit Re-Render bei Wechsel. */
|
||||||
|
export function useUIMode(): UIMode {
|
||||||
|
const [mode, setMode] = useState<UIMode>(current);
|
||||||
|
useEffect(() => {
|
||||||
|
const off = subscribeUIMode(setMode);
|
||||||
|
return off;
|
||||||
|
}, []);
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
@@ -2456,6 +2456,130 @@ body.drawer-open { overflow: hidden; }
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Task F2: Suche, Favoriten, Thumbnail-Grid ────────── */
|
||||||
|
.global-lib-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 12px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-lib-search {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-lib-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lib-fav-section {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lib-fav-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #d9a441;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-fav-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-fav-btn:hover { color: var(--color-text); }
|
||||||
|
|
||||||
|
.tree-fav-btn.active { color: #d9a441; }
|
||||||
|
|
||||||
|
.tree-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-thumb svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lib-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lib-grid-cell {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 4px 4px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: grab;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lib-grid-cell:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lib-grid-cell:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
.lib-grid-thumb {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lib-grid-thumb .tree-thumb {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lib-grid-placeholder { font-size: 20px; }
|
||||||
|
|
||||||
|
.lib-grid-name {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text);
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lib-grid-cell .tree-fav-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Context Menu ─────────────────────────────────────── */
|
/* ─── Context Menu ─────────────────────────────────────── */
|
||||||
.context-menu {
|
.context-menu {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
@@ -2672,3 +2796,181 @@ body.drawer-open { overflow: hidden; }
|
|||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ─── Task F4 – LibraryProviderPanel ─── */
|
||||||
|
.lib-provider-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0 4px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.lprov-section { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.lprov-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9aa0a6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
.lprov-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
.lprov-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: grab;
|
||||||
|
color: inherit;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.lprov-block:hover { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.12); }
|
||||||
|
.lprov-block .tree-thumb svg { width: 44px; height: 36px; display: block; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ─── Task G2 – LayoutBar ─── */
|
||||||
|
.layout-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(20, 22, 26, 0.6);
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.layout-bar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.layout-bar-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9aa0a6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.layout-bar-add {
|
||||||
|
background: transparent;
|
||||||
|
color: #4a90d9;
|
||||||
|
border: 1px solid rgba(74, 144, 217, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.layout-bar-add:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.layout-bar-add:not(:disabled):hover { background: rgba(74, 144, 217, 0.15); }
|
||||||
|
.layout-bar-empty {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.layout-bar-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 2px 0 4px;
|
||||||
|
}
|
||||||
|
.layout-bar-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.layout-bar-entry {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.layout-bar-entry:hover { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.12); }
|
||||||
|
.layout-minimap { border-radius: 4px; background: rgba(0, 0, 0, 0.25); }
|
||||||
|
.layout-bar-name { font-size: 10px; color: #9aa0a6; }
|
||||||
|
.layout-bar-del {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.layout-bar-del:hover { color: #c0392b; background: rgba(192, 57, 43, 0.15); }
|
||||||
|
|
||||||
|
.layout-bar-tb {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.layout-bar-tb:hover { color: #4a90d9; background: rgba(74, 144, 217, 0.15); }
|
||||||
|
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════
|
||||||
|
Task G5 – Browser-Print: @media print zeigt nur den Druckbereich
|
||||||
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
size: A4 landscape;
|
||||||
|
margin: 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* UI-Chrome im Druck ausblenden */
|
||||||
|
.screen-only {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Druckbereich füllt die Seite */
|
||||||
|
.print-area {
|
||||||
|
width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Farben/Linien so drucken wie angezeigt */
|
||||||
|
.print-layout * {
|
||||||
|
-webkit-print-color-adjust: exact !important;
|
||||||
|
print-color-adjust: exact !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body: kein Scroll, weiße Fläche */
|
||||||
|
body {
|
||||||
|
background: #fff !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-bar-print {
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.layout-bar-print:hover { color: #4a90d9; background: rgba(74, 144, 217, 0.15); }
|
||||||
|
|||||||
@@ -588,3 +588,19 @@ export function distance(p1: { x: number; y: number }, p2: { x: number; y: numbe
|
|||||||
export function angleBetween(p1: { x: number; y: number }, p2: { x: number; y: number }): number {
|
export function angleBetween(p1: { x: number; y: number }, p2: { x: number; y: number }): number {
|
||||||
return (Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180) / Math.PI;
|
return (Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180) / Math.PI;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fläche eines Polygons via Shoelace-Formel (Task H5).
|
||||||
|
* Absolutwert — Uhrzeigersinn und gegen Uhrzeigersinn ergeben dasselbe.
|
||||||
|
* Weniger als 3 Punkte oder kollineare Punkte → 0.
|
||||||
|
*/
|
||||||
|
export function polygonArea(pts: Array<{ x: number; y: number }>): number {
|
||||||
|
if (!Array.isArray(pts) || pts.length < 3) return 0;
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const a = pts[i];
|
||||||
|
const b = pts[(i + 1) % pts.length];
|
||||||
|
sum += a.x * b.y - b.x * a.y;
|
||||||
|
}
|
||||||
|
return Math.abs(sum / 2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export type ElementType =
|
|||||||
| 'line' | 'circle' | 'arc' | 'rect' | 'polygon'
|
| 'line' | 'circle' | 'arc' | 'rect' | 'polygon'
|
||||||
| 'polyline' | 'text' | 'dimension' | 'block_instance' | 'chair'
|
| 'polyline' | 'text' | 'dimension' | 'block_instance' | 'chair'
|
||||||
| 'seating-row' | 'seating-block' | 'table' | 'stage'
|
| 'seating-row' | 'seating-block' | 'table' | 'stage'
|
||||||
| 'leader' | 'revcloud' | 'image';
|
| 'leader' | 'revcloud' | 'image'
|
||||||
|
| 'curtain' | 'spotlight' | 'barrier';
|
||||||
|
|
||||||
export type LineType = 'solid' | 'dashed' | 'dotted';
|
export type LineType = 'solid' | 'dashed' | 'dotted';
|
||||||
|
|
||||||
@@ -62,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;
|
||||||
@@ -69,6 +80,25 @@ export interface BlockDefinition {
|
|||||||
category: string;
|
category: string;
|
||||||
elements: CADElement[];
|
elements: CADElement[];
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
|
/** Attribut-Slots dieser Blockdefinition (Task D12). */
|
||||||
|
attrDefs?: BlockAttrDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout (Task G1): Papier-/Plot-Seite mit eigenem Viewport aufs Modell.
|
||||||
|
* Persistiert in der Yjs-Map 'layouts' (key = layoutId).
|
||||||
|
*/
|
||||||
|
export interface LayoutViewport {
|
||||||
|
center: { x: number; y: number };
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
viewport: LayoutViewport;
|
||||||
|
/** Referenz auf den Titelblock-Block (frame block), optional. */
|
||||||
|
frameBlockRef?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoundingBox {
|
export interface BoundingBox {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* blockThumbnail – SVG-Thumbnail-Generierung für Block-Bibliothek (Task F3).
|
||||||
|
*
|
||||||
|
* Statt canvas.toDataURL werden die Elemente über die bereits vorhandene,
|
||||||
|
* testbare Shape-Fabrik elementToPrimitives (Task C2) beschrieben und als
|
||||||
|
* eigenständiges SVG serialisiert. Vorteile:
|
||||||
|
* - Konsistent zu den svg_data-Thumbnails der globalen Bibliothek (Tree/Grid)
|
||||||
|
* - Unit-testbar ohne Canvas/DOM
|
||||||
|
* - Skalierbar & themefarben-identisch zur Zeichenfläche
|
||||||
|
*
|
||||||
|
* BBox-Konvention wie elementDrawers: rect x/y = Zentrum der BBox,
|
||||||
|
* circle/arc radius via center ± r.
|
||||||
|
*/
|
||||||
|
import { elementToPrimitives } from '../render/pixi/elementDrawers';
|
||||||
|
import type { ShapePrimitive } from '../render/pixi/elementDrawers';
|
||||||
|
import type { CADElement } from '../types/cad.types';
|
||||||
|
|
||||||
|
const PAD = 10;
|
||||||
|
|
||||||
|
function fmt(n: number): string {
|
||||||
|
return String(Math.round(n * 100) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BBox { minX: number; minY: number; maxX: number; maxY: number }
|
||||||
|
|
||||||
|
function primExtent(p: ShapePrimitive, acc: BBox): void {
|
||||||
|
const add = (x: number, y: number) => {
|
||||||
|
if (x < acc.minX) acc.minX = x;
|
||||||
|
if (y < acc.minY) acc.minY = y;
|
||||||
|
if (x > acc.maxX) acc.maxX = x;
|
||||||
|
if (y > acc.maxY) acc.maxY = y;
|
||||||
|
};
|
||||||
|
switch (p.kind) {
|
||||||
|
case 'line':
|
||||||
|
add(p.from.x, p.from.y); add(p.to.x, p.to.y);
|
||||||
|
break;
|
||||||
|
case 'rect':
|
||||||
|
add(p.x - p.w / 2, p.y - p.h / 2); add(p.x + p.w / 2, p.y + p.h / 2);
|
||||||
|
break;
|
||||||
|
case 'circle':
|
||||||
|
add(p.center.x - p.radius, p.center.y - p.radius); add(p.center.x + p.radius, p.center.y + p.radius);
|
||||||
|
break;
|
||||||
|
case 'arc':
|
||||||
|
add(p.center.x - p.radius, p.center.y - p.radius); add(p.center.x + p.radius, p.center.y + p.radius);
|
||||||
|
break;
|
||||||
|
case 'polyline':
|
||||||
|
for (const pt of p.points) add(pt.x, pt.y);
|
||||||
|
break;
|
||||||
|
case 'text':
|
||||||
|
add(p.at.x, p.at.y - p.fontSize);
|
||||||
|
add(p.at.x + p.fontSize * 0.6 * p.text.length, p.at.y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function primToSvgTag(p: ShapePrimitive): string {
|
||||||
|
switch (p.kind) {
|
||||||
|
case 'line':
|
||||||
|
return `<line x1="${fmt(p.from.x)}" y1="${fmt(p.from.y)}" x2="${fmt(p.to.x)}" y2="${fmt(p.to.y)}" stroke="${p.stroke}" stroke-width="${p.strokeWidth}"/>`;
|
||||||
|
case 'rect':
|
||||||
|
return `<rect x="${fmt(p.x - p.w / 2)}" y="${fmt(p.y - p.h / 2)}" width="${fmt(p.w)}" height="${fmt(p.h)}" fill="${p.fill ?? 'none'}" stroke="${p.stroke}" stroke-width="${p.strokeWidth}"/>`;
|
||||||
|
case 'circle':
|
||||||
|
return `<circle cx="${fmt(p.center.x)}" cy="${fmt(p.center.y)}" r="${fmt(p.radius)}" fill="${p.fill ?? 'none'}" stroke="${p.stroke}" stroke-width="${p.strokeWidth}"/>`;
|
||||||
|
case 'arc': {
|
||||||
|
// Konvention: 0° = 3 Uhr, gegen Uhrzeigersinn (Bildschirm-Y zeigt nach unten)
|
||||||
|
const rad = (deg: number) => (deg * Math.PI) / 180;
|
||||||
|
const sx = p.center.x + p.radius * Math.cos(rad(p.startDeg));
|
||||||
|
const sy = p.center.y - p.radius * Math.sin(rad(p.startDeg));
|
||||||
|
const ex = p.center.x + p.radius * Math.cos(rad(p.endDeg));
|
||||||
|
const ey = p.center.y - p.radius * Math.sin(rad(p.endDeg));
|
||||||
|
let delta = (p.endDeg - p.startDeg) % 360;
|
||||||
|
if (delta < 0) delta += 360;
|
||||||
|
const largeArc = delta > 180 ? 1 : 0;
|
||||||
|
// CCW auf dem Schirm = SVG sweep-flag 0
|
||||||
|
return `<path d="M ${fmt(sx)} ${fmt(sy)} A ${fmt(p.radius)} ${fmt(p.radius)} 0 ${largeArc} 0 ${fmt(ex)} ${fmt(ey)}" fill="none" stroke="${p.stroke}" stroke-width="${p.strokeWidth}"/>`;
|
||||||
|
}
|
||||||
|
case 'polyline': {
|
||||||
|
const pts = p.points.map(pt => `${fmt(pt.x)},${fmt(pt.y)}`).join(' ');
|
||||||
|
if (p.close) {
|
||||||
|
return `<polygon points="${pts}" fill="${'none'}" stroke="${p.stroke}" stroke-width="${p.strokeWidth}"/>`;
|
||||||
|
}
|
||||||
|
return `<polyline points="${pts}" fill="none" stroke="${p.stroke}" stroke-width="${p.strokeWidth}"/>`;
|
||||||
|
}
|
||||||
|
case 'text':
|
||||||
|
return `<text x="${fmt(p.at.x)}" y="${fmt(p.at.y)}" font-size="${p.fontSize}" fill="${p.color}">${escapeXml(p.text)}</text>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialisiert Primitive zu einem eigenständigen SVG-String (leer bei []). */
|
||||||
|
export function primitivesToSvg(prims: ShapePrimitive[]): string {
|
||||||
|
if (prims.length === 0) return '';
|
||||||
|
const acc: BBox = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
||||||
|
for (const p of prims) primExtent(p, acc);
|
||||||
|
const w = acc.maxX - acc.minX;
|
||||||
|
const h = acc.maxY - acc.minY;
|
||||||
|
const vb = `${fmt(acc.minX - PAD)} ${fmt(acc.minY - PAD)} ${fmt(w + 2 * PAD)} ${fmt(h + 2 * PAD)}`;
|
||||||
|
const body = prims.map(primToSvgTag).join('');
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vb}">${body}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erzeugt ein SVG-Thumbnail aus einer Element-Auswahl (Task F3). */
|
||||||
|
export function generateBlockSvg(elements: CADElement[]): string {
|
||||||
|
const prims = elements.flatMap(el => elementToPrimitives(el));
|
||||||
|
return primitivesToSvg(prims);
|
||||||
|
}
|
||||||
@@ -92,3 +92,28 @@ export function formatInputValue(
|
|||||||
return `${(mm / 1000).toFixed(3)}`;
|
return `${(mm / 1000).toFixed(3)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a raw world-unit squared area value (Task H5).
|
||||||
|
* @param rawSquared squared world-units (e.g. polygonArea result)
|
||||||
|
* @param unit target display unit
|
||||||
|
* @param scaleFactor world-units → mm conversion (like formatDistance)
|
||||||
|
* @returns formatted string like "5000 mm²", "50.0 cm²", "5.00 m²"
|
||||||
|
*/
|
||||||
|
export function formatArea(
|
||||||
|
rawSquared: number,
|
||||||
|
unit: UnitType,
|
||||||
|
scaleFactor: number = 1,
|
||||||
|
): string {
|
||||||
|
const mmSquared = rawSquared * scaleFactor * scaleFactor;
|
||||||
|
switch (unit) {
|
||||||
|
case 'mm':
|
||||||
|
return `${mmSquared.toFixed(0)} mm²`;
|
||||||
|
case 'cm':
|
||||||
|
return `${(mmSquared / 100).toFixed(1)} cm²`;
|
||||||
|
case 'm':
|
||||||
|
return `${(mmSquared / 1000000).toFixed(2)} m²`;
|
||||||
|
default:
|
||||||
|
return `${mmSquared.toFixed(0)} mm²`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Task D10-Rest – array-path: Elemente entlang einer Polyline verteilen.
|
||||||
|
*
|
||||||
|
* Klick1 = Quelle (Auto-HitSelect bei leerer Selektion), Klick2 auf
|
||||||
|
* Pfad-Polyline → Element-Kopien gleichmaessig entlang der Pfad-
|
||||||
|
* laenge verteilt (count aus Options, Default 5), Original bleibt,
|
||||||
|
* ALLES in EINER Transaktion = 1 Undo-Schritt.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { arrayPathTool } from '../src/plugins/builtin/core-modify';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function mkCtx(elements: CADElement[], options: Record<string, unknown> = {}) {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
for (const el of elements) doc.addElement(el);
|
||||||
|
const selectedIds = new Set<string>();
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const ctx: any = {
|
||||||
|
doc,
|
||||||
|
options,
|
||||||
|
setStatus: (m: string) => statuses.push(m),
|
||||||
|
setPreview: (_el: CADElement | null) => {},
|
||||||
|
selection: {
|
||||||
|
getIds: () => Array.from(selectedIds),
|
||||||
|
setIds: (ids: string[]) => {
|
||||||
|
selectedIds.clear();
|
||||||
|
for (const id of ids) selectedIds.add(id);
|
||||||
|
},
|
||||||
|
hitTest: (x: number, y: number, _tol: number) => {
|
||||||
|
// trifft das Element, dessen BBox den Punkt enthaelt (erstes Gewinnt)
|
||||||
|
for (const el of elements) {
|
||||||
|
if (
|
||||||
|
x >= el.x - el.width / 2 && x <= el.x + el.width / 2 &&
|
||||||
|
y >= el.y - el.height / 2 && y <= el.y + el.height / 2
|
||||||
|
) return el;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ctx, doc, selectedIds, statuses };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pe(x: number, y: number): { world: Pt } {
|
||||||
|
return { world: { x, y } } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAIR = {
|
||||||
|
id: 'chair-1', type: 'chair', layerId: 'layer-0',
|
||||||
|
x: 0, y: 0, width: 40, height: 40, properties: {},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
|
||||||
|
const PATH = {
|
||||||
|
id: 'path-1', type: 'polyline', layerId: 'layer-0',
|
||||||
|
x: 250, y: 0, width: 500, height: 0,
|
||||||
|
properties: { points: [{ x: 0, y: 0 }, { x: 500, y: 0 }] },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
arrayPathTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D10: arrayPathTool', () => {
|
||||||
|
it('verteilt count Kopien gleichmaessig entlang der Pfadlaenge (Original bleibt)', () => {
|
||||||
|
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||||
|
selectedIds.add('chair-1');
|
||||||
|
// Klick1: Quelle ist schon selektiert -> Basis direkt setzen
|
||||||
|
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
// Klick2: auf den Pfad klicken (Pfad-Mitte 250,0)
|
||||||
|
arrayPathTool.handlers.down!(pe(250, 0), ctx);
|
||||||
|
|
||||||
|
const els = doc.getAllElements();
|
||||||
|
// Original + Pfad + 5 Kopien
|
||||||
|
const chairs = els.filter((e) => e.type === 'chair');
|
||||||
|
expect(chairs.length).toBe(1 + 5);
|
||||||
|
// Kopien getrennt vom Original (Original bleibt bei x=0 unangetastet):
|
||||||
|
const copies = chairs.filter((c) => c.id !== 'chair-1');
|
||||||
|
expect(copies).toHaveLength(5);
|
||||||
|
const copyXs = copies.map((c) => c.x).sort((a, b) => a - b);
|
||||||
|
// count=5 gleichmaessig ueber Pfadlaenge 500: s = i/(count-1)*total
|
||||||
|
expect(copyXs[0]).toBeCloseTo(0, 0);
|
||||||
|
expect(copyXs[1]).toBeCloseTo(125, 0);
|
||||||
|
expect(copyXs[2]).toBeCloseTo(250, 0);
|
||||||
|
expect(copyXs[3]).toBeCloseTo(375, 0);
|
||||||
|
expect(copyXs[4]).toBeCloseTo(500, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Auto-HitSelect bei leerer Selektion (Klick1 auf Element)', () => {
|
||||||
|
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||||
|
// Selektion ist leer: Klick auf den Stuhl waehlt ihn automatisch
|
||||||
|
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
expect(selectedIds.has('chair-1')).toBe(true);
|
||||||
|
// Klick2 auf Pfad -> Commit
|
||||||
|
arrayPathTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
expect(doc.getAllElements().filter((e) => e.type === 'chair').length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Klick2 trifft KEINEN Pfad -> kein Commit, Status erklaert', () => {
|
||||||
|
const { ctx, doc, selectedIds, statuses } = mkCtx([CHAIR, PATH]);
|
||||||
|
selectedIds.add('chair-1');
|
||||||
|
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
// Klick weit weg vom Pfad (kein Element getroffen)
|
||||||
|
arrayPathTool.handlers.down!(pe(9999, 9999), ctx);
|
||||||
|
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1);
|
||||||
|
expect(statuses[statuses.length - 1]).toContain('Pfad');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Kopien in EINER Transaktion = 1 Undo entfernt alle', () => {
|
||||||
|
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||||
|
selectedIds.add('chair-1');
|
||||||
|
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
arrayPathTool.handlers.down!(pe(250, 0), ctx);
|
||||||
|
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(6);
|
||||||
|
doc.undo();
|
||||||
|
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('count-Option steuert die Anzahl (3 statt 5)', () => {
|
||||||
|
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH], { count: 3 });
|
||||||
|
selectedIds.add('chair-1');
|
||||||
|
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
arrayPathTool.handlers.down!(pe(250, 0), ctx);
|
||||||
|
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(4); // Original + 3
|
||||||
|
// Kopien getrennt vom Original: count=3 -> s = 0, 250, 500
|
||||||
|
const copies3 = doc.getAllElements().filter((e) => e.type === 'chair' && e.id !== 'chair-1');
|
||||||
|
const copyXs3 = copies3.map((c) => c.x).sort((a, b) => a - b);
|
||||||
|
expect(copyXs3[0]).toBeCloseTo(0, 0);
|
||||||
|
expect(copyXs3[1]).toBeCloseTo(250, 0);
|
||||||
|
expect(copyXs3[2]).toBeCloseTo(500, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gebogener Pfad (3 Punkte): Kopien folgen der Geometrie', () => {
|
||||||
|
const cornerPath = {
|
||||||
|
id: 'path-2', type: 'polyline', layerId: 'layer-0',
|
||||||
|
x: 100, y: 100, width: 200, height: 200,
|
||||||
|
properties: { points: [{ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 200, y: 200 }] },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
const { ctx, doc, selectedIds } = mkCtx([CHAIR, cornerPath], { count: 4 });
|
||||||
|
selectedIds.add('chair-1');
|
||||||
|
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
arrayPathTool.handlers.down!(pe(100, 10), ctx);
|
||||||
|
const chairs = doc.getAllElements().filter((e) => e.type === 'chair');
|
||||||
|
expect(chairs.length).toBe(5); // Original + 4
|
||||||
|
// Kopien muessen auf dem L-foermigen Pfad liegen: alle x<=200, y>=0
|
||||||
|
for (const c of chairs.slice(1)) {
|
||||||
|
expect(c.x).toBeLessThanOrEqual(200.5);
|
||||||
|
expect(c.y).toBeGreaterThanOrEqual(-0.5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel resettet die Sitzung', () => {
|
||||||
|
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||||
|
selectedIds.add('chair-1');
|
||||||
|
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
arrayPathTool.handlers.cancel?.(ctx);
|
||||||
|
// Nach cancel: Klick startet frisch (Auto-HitSelect-Pfad wieder aktiv)
|
||||||
|
arrayPathTool.handlers.down!(pe(9999, 9999), ctx);
|
||||||
|
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Manifest: id=array-path, tags pro', () => {
|
||||||
|
expect(arrayPathTool.manifest.id).toBe('array-path');
|
||||||
|
expect((arrayPathTool.manifest.tags ?? [])).toContain('pro');
|
||||||
|
const keys = (arrayPathTool.optionsSchema ?? []).map((f) => f.key);
|
||||||
|
expect(keys).toContain('count');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,45 @@
|
|||||||
|
/**
|
||||||
|
* BlockThumbnail Tests (Task F3) – SVG-Thumbnail aus elementToPrimitives
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generateBlockSvg, primitivesToSvg } from '../src/utils/blockThumbnail';
|
||||||
|
import { elementToPrimitives } from '../src/render/pixi/elementDrawers';
|
||||||
|
import type { CADElement } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
describe('blockThumbnail (F3)', () => {
|
||||||
|
const chair: CADElement = {
|
||||||
|
id: 'c1',
|
||||||
|
type: 'chair',
|
||||||
|
layerId: 'l0',
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
properties: {},
|
||||||
|
} as CADElement;
|
||||||
|
|
||||||
|
it('erzeugt SVG mit viewBox aus Element-BBox', () => {
|
||||||
|
const svg = generateBlockSvg([chair]);
|
||||||
|
expect(svg).toContain('<svg');
|
||||||
|
expect(svg).toContain('viewBox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('konvertiert rect-Primitive in SVG-rect', () => {
|
||||||
|
const prims = elementToPrimitives(chair);
|
||||||
|
expect(prims.length).toBeGreaterThan(0);
|
||||||
|
const svg = primitivesToSvg(prims);
|
||||||
|
expect(svg).toContain('<rect');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert leeren String für leere Auswahl', () => {
|
||||||
|
expect(generateBlockSvg([])).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BBox umfasst mehrere Elemente korrekt', () => {
|
||||||
|
const a: CADElement = { ...chair, id: 'a', x: 0, y: 0 };
|
||||||
|
const b: CADElement = { ...chair, id: 'b', x: 500, y: 300 };
|
||||||
|
const svg = generateBlockSvg([a, b]);
|
||||||
|
// chair-Drawer erzeugt rect-Zentrum bei (el.x - w/2) → BBox -40..500/-40..300 + PAD 10
|
||||||
|
expect(svg).toContain('viewBox="-50 -50 560 360"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Task D3 – Point/XLine/Ray: Konstruktions-Elemente.
|
||||||
|
*
|
||||||
|
* pointTool: Klick-Platzierer (kleiner Kreis mit point:true-Marker,
|
||||||
|
* r=1.5 wie DXF-POINT-Import). xlineTool: 2 Klicks (Basis+Richtung),
|
||||||
|
* sehr lange Linie in BEIDE Richtungen. rayTool: halblang (nur eine
|
||||||
|
* Richtung). Alle mit construction:true und auf defpoints-Layer
|
||||||
|
* (auto-angelegt falls fehlt).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { pointTool, xlineTool, rayTool } from '../src/plugins/builtin/core-drawing';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function mkCtx() {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const previews: (CADElement | null)[] = [];
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const ctx: any = {
|
||||||
|
doc,
|
||||||
|
options: {},
|
||||||
|
setStatus: (m: string) => statuses.push(m),
|
||||||
|
setPreview: (el: CADElement | null) => previews.push(el),
|
||||||
|
};
|
||||||
|
return { ctx, doc, previews, statuses };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pe(x: number, y: number): { world: Pt } {
|
||||||
|
return { world: { x, y } } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pointTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||||
|
xlineTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||||
|
rayTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D3: pointTool', () => {
|
||||||
|
it('Klick platziert Kreis mit point-Marker (r=1.5, wie DXF POINT)', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
pointTool.handlers.down!(pe(55, 66), ctx);
|
||||||
|
const els = doc.getAllElements();
|
||||||
|
expect(els).toHaveLength(1);
|
||||||
|
const el = els[0];
|
||||||
|
expect(el.type).toBe('circle');
|
||||||
|
expect(el.x).toBe(55);
|
||||||
|
expect(el.y).toBe(66);
|
||||||
|
expect(el.properties.radius).toBe(1.5);
|
||||||
|
expect(el.properties.point).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defpoints-Layer wird automatisch angelegt falls fehlt', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
expect(doc.getLayer('defpoints')).toBeUndefined();
|
||||||
|
pointTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
expect(doc.getLayer('defpoints')).toBeDefined();
|
||||||
|
expect(doc.getLayer('defpoints')!.name).toBe('defpoints');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Element landet auf defpoints-Layer mit construction:true', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
pointTool.handlers.down!(pe(10, 10), ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
expect(el.layerId).toBe('defpoints');
|
||||||
|
expect(el.properties.construction).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D3: xlineTool (Konstruktionslinie, beide Richtungen)', () => {
|
||||||
|
it('2 Klicks erzeugen sehr lange Linie durch beide Punkte in BEIDE Richtungen', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
xlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
xlineTool.handlers.down!(pe(10, 10), ctx);
|
||||||
|
const els = doc.getAllElements();
|
||||||
|
expect(els).toHaveLength(1);
|
||||||
|
const el = els[0];
|
||||||
|
expect(el.type).toBe('line');
|
||||||
|
const dx = (el.properties.x2 as number) - (el.properties.x1 as number);
|
||||||
|
const dy = (el.properties.y2 as number) - (el.properties.y1 as number);
|
||||||
|
// Sehr lange: Länge >> Distanz der beiden Klicks (14.14)
|
||||||
|
expect(Math.hypot(dx, dy)).toBeGreaterThan(100000);
|
||||||
|
// Mittelpunkt der Linie = Basispunkt
|
||||||
|
expect((el.properties.x1 as number) + dx / 2).toBeCloseTo(0, 3);
|
||||||
|
expect((el.properties.y1 as number) + dy / 2).toBeCloseTo(0, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Element auf defpoints-Layer mit construction:true', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
xlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
xlineTool.handlers.down!(pe(5, 0), ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
expect(el.layerId).toBe('defpoints');
|
||||||
|
expect(el.properties.construction).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Preview bei move nach erstem Klick', () => {
|
||||||
|
const { ctx, previews } = mkCtx();
|
||||||
|
xlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
xlineTool.handlers.move!(pe(50, 50), ctx);
|
||||||
|
expect(previews.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Erster Klick ohne zweiten erzeugt NICHTS (Session-State)', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
xlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
expect(doc.getAllElements()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D3: rayTool (Strahl, nur EINE Richtung)', () => {
|
||||||
|
it('2 Klicks erzeugen Linie ab Basispunkt NUR in Zugrichtung', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
rayTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
rayTool.handlers.down!(pe(10, 0), ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
expect(el.type).toBe('line');
|
||||||
|
expect(el.properties.x1).toBe(0);
|
||||||
|
expect(el.properties.y1).toBe(0);
|
||||||
|
// Endpunkt weit in +x-Richtung
|
||||||
|
expect((el.properties.x2 as number)).toBeGreaterThanOrEqual(100000);
|
||||||
|
// NICHT in Gegenrichtung: x1 ist exakt der Basispunkt
|
||||||
|
expect(el.properties.x1).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('construction:true + defpoints-Layer', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
rayTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
rayTool.handlers.down!(pe(0, 10), ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
expect(el.layerId).toBe('defpoints');
|
||||||
|
expect(el.properties.construction).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D3: Manifeste', () => {
|
||||||
|
it('ids/ribbonTab/tags korrekt', () => {
|
||||||
|
expect(pointTool.manifest.id).toBe('point');
|
||||||
|
expect(xlineTool.manifest.id).toBe('xline');
|
||||||
|
expect(rayTool.manifest.id).toBe('ray');
|
||||||
|
for (const t of [pointTool, xlineTool, rayTool]) {
|
||||||
|
expect(t.manifest.ribbonTab).toBe('canvas');
|
||||||
|
expect((t.manifest.tags ?? [])).toContain('basic');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Task F5 – DXF-Parser-Erweiterungstests.
|
||||||
|
*
|
||||||
|
* Neue Entity-Abdeckung: ELLIPSE (als Polyline-Approx), SPLINE (grad<=3 →
|
||||||
|
* Polyline via de-Boor), MTEXT (Formatcodes gecleanup), POINT, ATTDEF,
|
||||||
|
* INSERT mit MINSERT-Arrays und HATCH (forward-kompatibel_via Fake-Entity).
|
||||||
|
* Plus strukturierter Fehlerreport (skippedEntities) neben warnings.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseDXF, entityToCADElement, expandInsert } from '../src/services/dxfParser';
|
||||||
|
import type { CADLayer } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
const layer0: CADLayer = {
|
||||||
|
id: 'dxf-layer-0', name: '0', visible: true, locked: false,
|
||||||
|
color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
|
||||||
|
};
|
||||||
|
const layers = new Map([['0', layer0]]);
|
||||||
|
|
||||||
|
function dxfOf(entityBody: string): string {
|
||||||
|
return `0\nSECTION\n2\nENTITIES\n${entityBody}0\nENDSEC\n0\nEOF\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ptsOf(el: { properties: { points?: Array<{ x: number; y: number }> } }): Array<{ x: number; y: number }> {
|
||||||
|
return el.properties.points ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('F5: ELLIPSE', () => {
|
||||||
|
it('volle Ellipse → geschlossene Polyline mit 64 Punkten und korrekter BBox', () => {
|
||||||
|
const el = entityToCADElement({
|
||||||
|
type: 'ELLIPSE', layer: '0',
|
||||||
|
center: { x: 0, y: 0 },
|
||||||
|
majorAxisEndPoint: { x: 100, y: 0 },
|
||||||
|
axisRatio: 0.5,
|
||||||
|
startAngle: 0, endAngle: Math.PI * 2,
|
||||||
|
} as any, layers);
|
||||||
|
expect(el).not.toBeNull();
|
||||||
|
expect(el!.type).toBe('polyline');
|
||||||
|
const pts = ptsOf(el!);
|
||||||
|
expect(pts.length).toBe(64);
|
||||||
|
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
|
||||||
|
expect(Math.max(...xs)).toBeCloseTo(100, 0);
|
||||||
|
expect(Math.min(...xs)).toBeCloseTo(-100, 0);
|
||||||
|
expect(Math.max(...ys)).toBeCloseTo(50, 0);
|
||||||
|
expect(Math.min(...ys)).toBeCloseTo(-50, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotierte Ellipse respektiert majorAxisEndPoint-Winkel', () => {
|
||||||
|
const el = entityToCADElement({
|
||||||
|
type: 'ELLIPSE', layer: '0',
|
||||||
|
center: { x: 0, y: 0 },
|
||||||
|
majorAxisEndPoint: { x: 0, y: 100 }, // 90° rotiert
|
||||||
|
axisRatio: 0.5,
|
||||||
|
startAngle: 0, endAngle: Math.PI * 2,
|
||||||
|
} as any, layers);
|
||||||
|
const pts = ptsOf(el!);
|
||||||
|
expect(Math.max(...pts.map((p) => p.y))).toBeCloseTo(100, 0);
|
||||||
|
expect(Math.max(...pts.map((p) => p.x))).toBeCloseTo(50, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('partielle Ellipse → offene Polyline', () => {
|
||||||
|
const el = entityToCADElement({
|
||||||
|
type: 'ELLIPSE', layer: '0',
|
||||||
|
center: { x: 0, y: 0 },
|
||||||
|
majorAxisEndPoint: { x: 100, y: 0 },
|
||||||
|
axisRatio: 1,
|
||||||
|
startAngle: 0, endAngle: Math.PI,
|
||||||
|
} as any, layers);
|
||||||
|
const pts = ptsOf(el!);
|
||||||
|
expect(pts.length).toBe(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Integration über parseDXF mit echtem ELLIPSE-DXF-String', () => {
|
||||||
|
const res = parseDXF(dxfOf(
|
||||||
|
'0\nELLIPSE\n8\n0\n10\n0\n20\n0\n30\n0\n11\n100\n21\n0\n31\n0\n41\n0.5\n42\n0\n51\n6.283185307179586\n',
|
||||||
|
));
|
||||||
|
expect(res.skippedEntities).toHaveLength(0);
|
||||||
|
const el = res.elements.find((e) => e.type === 'polyline');
|
||||||
|
expect(el).toBeDefined();
|
||||||
|
expect(ptsOf(el as any).length).toBeGreaterThan(32);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F5: SPLINE', () => {
|
||||||
|
const controlPoints = [
|
||||||
|
{ x: 0, y: 0 }, { x: 33, y: 200 }, { x: 66, y: -100 }, { x: 100, y: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('Grad 3 → Polyline-Approx mit mehr Punkten als Kontrollpunkte', () => {
|
||||||
|
const el = entityToCADElement({
|
||||||
|
type: 'SPLINE', layer: '0',
|
||||||
|
degreeOfSplineCurve: 3, controlPoints, fitPoints: [],
|
||||||
|
} as any, layers);
|
||||||
|
expect(el).not.toBeNull();
|
||||||
|
expect(el!.type).toBe('polyline');
|
||||||
|
const pts = ptsOf(el!);
|
||||||
|
expect(pts.length).toBeGreaterThan(16);
|
||||||
|
// Approximation startet/endet bei exakten Kontrollpunkten
|
||||||
|
expect(pts[0].x).toBeCloseTo(0, 3);
|
||||||
|
expect(pts[0].y).toBeCloseTo(0, 3);
|
||||||
|
expect(pts[pts.length - 1].x).toBeCloseTo(100, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Grad 4 → skipped mit Grund', () => {
|
||||||
|
const el = entityToCADElement({
|
||||||
|
type: 'SPLINE', layer: '0',
|
||||||
|
degreeOfSplineCurve: 4, controlPoints, fitPoints: [],
|
||||||
|
} as any, layers);
|
||||||
|
expect(el).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Kontrollpunkte → skipped', () => {
|
||||||
|
const el = entityToCADElement({ type: 'SPLINE', layer: '0', degreeOfSplineCurve: 3 } as any, layers);
|
||||||
|
expect(el).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F5: MTEXT', () => {
|
||||||
|
it('mappt auf text-Element, \\P → Zeilenumbruch, Formatcodes entfernt', () => {
|
||||||
|
const el = entityToCADElement({
|
||||||
|
type: 'MTEXT', layer: '0',
|
||||||
|
position: { x: 10, y: 20 },
|
||||||
|
height: 15, width: 200,
|
||||||
|
text: 'Zeile1\\PZeile2\\A1;\\H2x;Titel',
|
||||||
|
} as any, layers);
|
||||||
|
expect(el).not.toBeNull();
|
||||||
|
expect(el!.type).toBe('text');
|
||||||
|
expect(el!.properties.text).toBe('Zeile1\nZeile2Titel');
|
||||||
|
expect(el!.properties.fontSize).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F5: POINT', () => {
|
||||||
|
it('mappt auf kleinen gefüllten Kreis mit point-Marker', () => {
|
||||||
|
const el = entityToCADElement({
|
||||||
|
type: 'POINT', layer: '0',
|
||||||
|
position: { x: 55, y: 66 },
|
||||||
|
} as any, layers);
|
||||||
|
expect(el).not.toBeNull();
|
||||||
|
expect(el!.type).toBe('circle');
|
||||||
|
expect(el!.x).toBeCloseTo(55 - 1.5, 5);
|
||||||
|
expect(el!.properties.radius).toBeCloseTo(1.5, 5);
|
||||||
|
expect(el!.properties.point).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F5: ATTDEF', () => {
|
||||||
|
it('mappt auf text-Element mit tag=Wert und Metadaten', () => {
|
||||||
|
const el = entityToCADElement({
|
||||||
|
type: 'ATTDEF', layer: '0',
|
||||||
|
tag: 'NAME', text: 'Buehne', prompt: 'Name der Buehne',
|
||||||
|
startPoint: { x: 5, y: 5 }, textHeight: 12,
|
||||||
|
} as any, layers);
|
||||||
|
expect(el).not.toBeNull();
|
||||||
|
expect(el!.type).toBe('text');
|
||||||
|
expect(el!.properties.text).toBe('NAME=Buehne');
|
||||||
|
expect(el!.properties.fontSize).toBe(12);
|
||||||
|
expect(el!.properties.tag).toBe('NAME');
|
||||||
|
expect(el!.properties.prompt).toBe('Name der Buehne');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leerer ATTDEF-Text ohne Tag → skipped', () => {
|
||||||
|
const el = entityToCADElement({ type: 'ATTDEF', layer: '0', startPoint: { x: 0, y: 0 } } as any, layers);
|
||||||
|
expect(el).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F5: INSERT (MINSERT-Arrays)', () => {
|
||||||
|
it('columnCount/rowCount > 1 erzeugt n-fache Instanzen mit Spacing-Offset', () => {
|
||||||
|
const els = [] as any[];
|
||||||
|
// Der Parser-Einstieg ist parseDXF; INSERT-Expansion passiert dort je Entity — hier via Helper
|
||||||
|
const base = {
|
||||||
|
type: 'INSERT', layer: '0', name: 'stuhl',
|
||||||
|
position: { x: 0, y: 0 }, xScale: 2, yScale: 3, rotation: 0,
|
||||||
|
columnCount: 3, rowCount: 2, columnSpacing: 100, rowSpacing: 50,
|
||||||
|
} as any;
|
||||||
|
const out = expandInsert(base);
|
||||||
|
expect(out).toHaveLength(6);
|
||||||
|
const uniqXs = [...new Set(out.map((e) => e.x))].sort((a, b) => a - b);
|
||||||
|
expect(uniqXs).toEqual([0, 100, 200]);
|
||||||
|
const ys = [...new Set(out.map((e) => e.y))].sort((a, b) => a - b);
|
||||||
|
expect(ys).toEqual([0, 50]);
|
||||||
|
expect(out.every((e) => e.properties.scaleX === 2 && e.properties.scaleY === 3)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('einfacher INSERT bleibt einzelne Instanz mit kombiniertem scale', () => {
|
||||||
|
const out = expandInsert({
|
||||||
|
type: 'INSERT', layer: '0', name: 'b',
|
||||||
|
position: { x: 5, y: 5 }, xScale: 1, yScale: 1, rotation: 90,
|
||||||
|
} as any);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].properties.blockId).toBe('b');
|
||||||
|
expect(out[0].properties.rotation).toBe(90);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F5: HATCH (forward-kompatibel_via Entity-Injektion)', () => {
|
||||||
|
it('HATCH solid → polygon mit hatchPattern', () => {
|
||||||
|
const el = entityToCADElement({
|
||||||
|
type: 'HATCH', layer: '0',
|
||||||
|
patternName: 'SOLID', isSolid: true,
|
||||||
|
boundaryPoints: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }],
|
||||||
|
} as any, layers);
|
||||||
|
expect(el).not.toBeNull();
|
||||||
|
expect(el!.type).toBe('polygon');
|
||||||
|
expect(el!.properties.hatchPattern).toBe('SOLID');
|
||||||
|
expect(ptsOf(el as any)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F5: Fehlerreport (skippedEntities)', () => {
|
||||||
|
// Hinweis: dxf-parser 1.1.x verschluckt voellig unbekannte Typen (FOOBAR) schlicht;
|
||||||
|
// valide SPLINE-Entities mit Grad > 3 kommen dagegen durch und werden vom
|
||||||
|
// Mapper mit Grund uebersprungen — genau dafuer ist der Report da.
|
||||||
|
const DEG4_SPLINE = '0\nSPLINE\n8\n0\n71\n4\n11\n0\n21\n0\n11\n10\n21\n0\n11\n20\n21\n0\n11\n30\n21\n0\n';
|
||||||
|
|
||||||
|
it('unbrauchbare Entities (SPLINE Grad 4) landen im Report', () => {
|
||||||
|
const res = parseDXF(dxfOf(DEG4_SPLINE));
|
||||||
|
const types = res.skippedEntities.map((s) => s.type);
|
||||||
|
expect(types).toContain('SPLINE');
|
||||||
|
expect(res.warnings.length).toBeGreaterThan(0);
|
||||||
|
expect(res.skippedEntities[0].reason).toContain('> 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Result enthält skippedEntities-Feld mit type+reason (Design-Vertrag)', () => {
|
||||||
|
const res = parseDXF(dxfOf(DEG4_SPLINE));
|
||||||
|
expect(res.skippedEntities).toHaveLength(1);
|
||||||
|
expect(res.skippedEntities[0].type).toBe('SPLINE');
|
||||||
|
expect(typeof res.skippedEntities[0].reason).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Task F6 – DXF-Writer-Erweiterungstests.
|
||||||
|
*
|
||||||
|
* Deckt ab: BLOCKS+INSERTS mit Name-Mapping/Sanitizing, ATTRIB/SEQEND,
|
||||||
|
* Versionsoption AC1009/AC1015, R12-korrektes POLYLINE statt LWPOLYLINE,
|
||||||
|
* Center-Konvention (circle/arc el.x/el.y = Zentrum) und Grad-Winkel ohne
|
||||||
|
* Doppelkonvertierung — abgesichert über echte write→parse-Roundtrips.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { writeDXF } from '../src/services/dxfWriter';
|
||||||
|
import { parseDXF } from '../src/services/dxfParser';
|
||||||
|
import type { CADElement, CADLayer, ProjectData, BlockDefinition } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
const layer: CADLayer = {
|
||||||
|
id: 'layer-1', name: 'L1', visible: true, locked: false,
|
||||||
|
color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mkEl(over: Partial<CADElement> = {}): CADElement {
|
||||||
|
return {
|
||||||
|
id: 'e1', type: 'line', layerId: 'layer-1',
|
||||||
|
x: 0, y: 0, width: 0, height: 0, properties: {},
|
||||||
|
...over,
|
||||||
|
} as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkBlock(over: Partial<BlockDefinition> = {}): BlockDefinition {
|
||||||
|
return {
|
||||||
|
id: 'blk-1', name: 'Stuhl Reihe', description: '', category: 'event',
|
||||||
|
elements: [mkEl({ type: 'line', properties: { x1: 0, y1: 0, x2: 100, y2: 0 } })],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkProj(elements: CADElement[], blocks: BlockDefinition[] = []): ProjectData {
|
||||||
|
return { version: '1', name: 'T', layers: [layer], elements, blocks } as ProjectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('F6: Version', () => {
|
||||||
|
it('Default ist AC1009 (R12)', () => {
|
||||||
|
expect(writeDXF(mkProj([]))).toContain('AC1009');
|
||||||
|
});
|
||||||
|
it('Option AC1015 setzt Header-Version', () => {
|
||||||
|
expect(writeDXF(mkProj([]), { version: 'AC1015' })).toContain('AC1015');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F6: BLOCKS + INSERTS', () => {
|
||||||
|
it('BLOCKS-Sektion emittiert sanitisierten Blocknamen, INSERT referenziert gemappten Namen', () => {
|
||||||
|
const out = writeDXF(mkProj([
|
||||||
|
mkEl({ type: 'block_instance', properties: { blockId: 'blk-1' } }),
|
||||||
|
], [mkBlock()]));
|
||||||
|
expect(out).toContain('BLOCKS');
|
||||||
|
expect(out).toContain('BLOCK');
|
||||||
|
expect(out).toContain('ENDBLK');
|
||||||
|
// Name 'Stuhl Reihe' → 'Stuhl_Reihe' (Leerzeichen sanitizing)
|
||||||
|
expect(out.split('Stuhl_Reihe').length).toBeGreaterThanOrEqual(3); // BLOCK 2/3 + INSERT 2
|
||||||
|
expect(out).toContain('INSERT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('block_instance ohne Definition bleibt BBox-Fallback (kein INSERT)', () => {
|
||||||
|
const out = writeDXF(mkProj([
|
||||||
|
mkEl({ type: 'block_instance', x: 0, y: 0, width: 100, height: 50, properties: { blockId: 'unknown-block' } }),
|
||||||
|
]));
|
||||||
|
expect(out).not.toContain('INSERT');
|
||||||
|
expect(out).toContain('POLYLINE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Roundtrip: INSERT landet als block_instance mit gemapptem Namen', () => {
|
||||||
|
const out = writeDXF(mkProj([
|
||||||
|
mkEl({ type: 'block_instance', x: 10, y: 20, properties: { blockId: 'blk-1' } }),
|
||||||
|
], [mkBlock()]));
|
||||||
|
const res = parseDXF(out);
|
||||||
|
const inst = res.elements.find((e) => e.type === 'block_instance');
|
||||||
|
expect(inst).toBeDefined();
|
||||||
|
expect(inst!.properties.blockId).toBe('Stuhl_Reihe');
|
||||||
|
expect(inst!.x).toBe(10);
|
||||||
|
expect(inst!.y).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATTDEF aus Blockelement (attdef:true + tag) wird in der BLOCK-Definition emittiert', () => {
|
||||||
|
const blk = mkBlock({
|
||||||
|
elements: [
|
||||||
|
mkEl({ type: 'line', properties: { x1: 0, y1: 0, x2: 50, y2: 0 } }),
|
||||||
|
mkEl({ type: 'text', x: 5, y: 5, properties: { text: 'Buehne', tag: 'NAME', attdef: true, fontSize: 12 } }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const out = writeDXF(mkProj([], [blk]));
|
||||||
|
expect(out).toContain('ATTDEF');
|
||||||
|
expect(out).toContain('NAME');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Instanz mit attrValues emittiert 66/ATTRIB/SEQEND', () => {
|
||||||
|
const out = writeDXF(mkProj([
|
||||||
|
mkEl({
|
||||||
|
type: 'block_instance', x: 0, y: 0,
|
||||||
|
properties: { blockId: 'blk-1', attrValues: { NAME: 'Halle A' } },
|
||||||
|
}),
|
||||||
|
], [mkBlock()]));
|
||||||
|
expect(out).toContain('ATTRIB');
|
||||||
|
expect(out).toContain('SEQEND');
|
||||||
|
expect(out).toContain('Halle A');
|
||||||
|
expect(out).toContain('66');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F6: R12-korrektes POLYLINE', () => {
|
||||||
|
it('polygon → POLYLINE+VERTEX+SEQEND, kein LWPOLYLINE', () => {
|
||||||
|
const out = writeDXF(mkProj([
|
||||||
|
mkEl({
|
||||||
|
type: 'polygon',
|
||||||
|
properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }] },
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
expect(out).toContain('POLYLINE');
|
||||||
|
expect(out).toContain('VERTEX');
|
||||||
|
expect(out).toContain('SEQEND');
|
||||||
|
expect(out).not.toContain('LWPOLYLINE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Roundtrip: polygon bleibt geschlossen (shape-Flag 70=1)', () => {
|
||||||
|
const out = writeDXF(mkProj([
|
||||||
|
mkEl({
|
||||||
|
type: 'polygon',
|
||||||
|
properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }] },
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
const res = parseDXF(out);
|
||||||
|
const poly = res.elements.find((e) => e.type === 'polygon');
|
||||||
|
expect(poly).toBeDefined();
|
||||||
|
expect(res.elements.find((e) => e.type === 'polyline')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F6: Center- & Winkel-Konvention', () => {
|
||||||
|
it('Writer: circle Zentrum el.x/el.y direkt (kein +width/2)', () => {
|
||||||
|
const out = writeDXF(mkProj([
|
||||||
|
mkEl({ type: 'circle', x: 100, y: 50, width: 60, height: 60, properties: { radius: 30 } }),
|
||||||
|
]));
|
||||||
|
const res = parseDXF(out);
|
||||||
|
const circ = res.elements.find((e) => e.type === 'circle');
|
||||||
|
expect(circ).toBeDefined();
|
||||||
|
expect(circ!.x).toBeCloseTo(100, 5);
|
||||||
|
expect(circ!.y).toBeCloseTo(50, 5);
|
||||||
|
expect(circ!.properties.radius).toBeCloseTo(30, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Parser: CIRCLE center landet direkt auf el.x/el.y (Renderer-Konvention)', () => {
|
||||||
|
const dxf = [
|
||||||
|
'0', 'SECTION', '2', 'ENTITIES',
|
||||||
|
'0', 'CIRCLE', '8', '0', '10', '10', '20', '20', '30', '0', '40', '5',
|
||||||
|
'0', 'ENDSEC', '0', 'EOF',
|
||||||
|
].join(String.fromCharCode(10));
|
||||||
|
const res = parseDXF(dxf);
|
||||||
|
const circ = res.elements.find((e) => e.type === 'circle');
|
||||||
|
expect(circ).toBeDefined();
|
||||||
|
expect(circ!.x).toBe(10);
|
||||||
|
expect(circ!.y).toBe(20);
|
||||||
|
expect(circ!.properties.radius).toBe(5);
|
||||||
|
expect(circ!.width).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('arc/rotation Winkel in Grad ohne Doppelkonvertierung', () => {
|
||||||
|
const out = writeDXF(mkProj([
|
||||||
|
mkEl({ type: 'arc', x: 0, y: 0, width: 20, height: 20, properties: { radius: 10, startAngle: 30, endAngle: 120 } }),
|
||||||
|
mkEl({ type: 'block_instance', x: 0, y: 0, properties: { blockId: 'blk-1', rotation: 45 } }),
|
||||||
|
], [mkBlock()]));
|
||||||
|
expect(out).toContain('50' + String.fromCharCode(10) + '30');
|
||||||
|
expect(out).toContain('51' + String.fromCharCode(10) + '120');
|
||||||
|
expect(out).toContain('50' + String.fromCharCode(10) + '45');
|
||||||
|
const res = parseDXF(out);
|
||||||
|
const arc = res.elements.find((e) => e.type === 'arc');
|
||||||
|
expect(arc!.properties.startAngle).toBeCloseTo(30, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rect als geschlossene POLYLINE mit Ecken aus Zentrumskonvention', () => {
|
||||||
|
const out = writeDXF(mkProj([
|
||||||
|
mkEl({ type: 'rect', x: 0, y: 0, width: 100, height: 50, properties: {} }),
|
||||||
|
]));
|
||||||
|
const res = parseDXF(out);
|
||||||
|
const poly = res.elements[0];
|
||||||
|
const pts = (poly!.properties.points ?? []) as Array<{ x: number; y: number }>;
|
||||||
|
expect(pts.length).toBe(4);
|
||||||
|
expect(pts[0].x).toBeCloseTo(-50, 5);
|
||||||
|
expect(pts[0].y).toBeCloseTo(-25, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Task D1 – Ellipse+Bogen-Tool (core-drawing).
|
||||||
|
*
|
||||||
|
* 3-Klick-Interaktion: Klick1=Zentrum, Klick2=RadiusX (Richtung
|
||||||
|
* definiert die Rotation), Klick3=RadiusY. Commit als geschlossene
|
||||||
|
* Polyline (64 Punkte, F5-Parser-Konvention) mit cx/cy/rx/ry/rotation
|
||||||
|
* Metadaten. Ellipsenbogen via startAngle/endAngle-Options → offene
|
||||||
|
* Polyline mit proportionalen Punkten.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ellipseTool } from '../src/plugins/builtin/core-drawing';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function mkCtx(options: Record<string, unknown> = {}) {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const previews: (CADElement | null)[] = [];
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const ctx: any = {
|
||||||
|
doc,
|
||||||
|
options,
|
||||||
|
setStatus: (m: string) => statuses.push(m),
|
||||||
|
setPreview: (el: CADElement | null) => previews.push(el),
|
||||||
|
};
|
||||||
|
return { ctx, doc, previews, statuses };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pe(x: number, y: number): { world: Pt } {
|
||||||
|
return { world: { x, y } } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ellipseTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D1: ellipseTool', () => {
|
||||||
|
it('3 Klicks: Zentrum→RadiusX→RadiusY erzeugt geschlossene Ellipse (64 Punkte)', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
ellipseTool.handlers.down!(pe(100, 0), ctx); // RadiusX=100, Rotation=0
|
||||||
|
ellipseTool.handlers.down!(pe(0, 50), ctx); // RadiusY=50
|
||||||
|
const els = doc.getAllElements();
|
||||||
|
expect(els).toHaveLength(1);
|
||||||
|
const el = els[0];
|
||||||
|
expect(el.type).toBe('polygon');
|
||||||
|
const pts = el.properties.points as Pt[];
|
||||||
|
expect(pts.length).toBe(64);
|
||||||
|
// BBox: rx=100, ry=50
|
||||||
|
const xs = pts.map((p) => p.x);
|
||||||
|
const ys = pts.map((p) => p.y);
|
||||||
|
expect(Math.max(...xs)).toBeCloseTo(100, 0);
|
||||||
|
expect(Math.min(...xs)).toBeCloseTo(-100, 0);
|
||||||
|
expect(Math.max(...ys)).toBeCloseTo(50, 0);
|
||||||
|
expect(Math.min(...ys)).toBeCloseTo(-50, 0);
|
||||||
|
// Metadaten
|
||||||
|
expect(el.properties.cx).toBe(0);
|
||||||
|
expect(el.properties.cy).toBe(0);
|
||||||
|
expect(el.properties.rx).toBe(100);
|
||||||
|
expect(el.properties.ry).toBe(50);
|
||||||
|
expect(el.properties.rotation).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Zugrichtung definiert Rotation: Klick2 nach (0,100) → rotation=90°', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
ellipseTool.handlers.down!(pe(0, 100), ctx); // RadiusX zeigt nach unten
|
||||||
|
ellipseTool.handlers.down!(pe(50, 0), ctx); // RadiusY
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
expect(el.properties.rotation).toBeCloseTo(90, 1);
|
||||||
|
// Rotierte Ellipse: Hauptachse vertikal → maxY ≈ 100
|
||||||
|
const ys = (el.properties.points as Pt[]).map((p) => p.y);
|
||||||
|
expect(Math.max(...ys)).toBeCloseTo(100, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Preview bei move nach Phase 2 zeigt Ellipse', () => {
|
||||||
|
const { ctx, previews } = mkCtx();
|
||||||
|
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
ellipseTool.handlers.move!(pe(80, 0), ctx); // RadiusX-Linie
|
||||||
|
ellipseTool.handlers.down!(pe(80, 0), ctx); // RadiusX fixiert
|
||||||
|
ellipseTool.handlers.move!(pe(0, 40), ctx); // Ellipsen-Preview mit live ry
|
||||||
|
expect(previews.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const last = previews[previews.length - 1];
|
||||||
|
expect(last).not.toBeNull();
|
||||||
|
expect(last!.type).toBe('polygon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Status führt durch die Phasen', () => {
|
||||||
|
const { ctx, statuses } = mkCtx();
|
||||||
|
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
expect(statuses[0]).toContain('Zentrum');
|
||||||
|
ellipseTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
expect(statuses[1]).toContain('Radius');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel resettet die Messung', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
ellipseTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
ellipseTool.handlers.cancel?.(ctx);
|
||||||
|
// Nach cancel startet frisch: nächster Klick = Zentrum (kein Commit)
|
||||||
|
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
expect(doc.getAllElements()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D1: Ellipsenbogen (startAngle/endAngle)', () => {
|
||||||
|
it('Options startAngle=0/endAngle=180 erzeugen OFFENE Polyline (32 Punkte)', () => {
|
||||||
|
const { ctx, doc } = mkCtx({ startAngle: 0, endAngle: 180 });
|
||||||
|
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
ellipseTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
ellipseTool.handlers.down!(pe(0, 50), ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
expect(el.type).toBe('polyline'); // offen
|
||||||
|
const pts = el.properties.points as Pt[];
|
||||||
|
expect(pts.length).toBe(32); // halbe Ellipse = 64/2
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Winkel-Metadaten in properties', () => {
|
||||||
|
const { ctx, doc } = mkCtx({ startAngle: 45, endAngle: 225 });
|
||||||
|
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
ellipseTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
ellipseTool.handlers.down!(pe(0, 50), ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
expect(el.properties.startAngle).toBe(45);
|
||||||
|
expect(el.properties.endAngle).toBe(225);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D1: Manifest', () => {
|
||||||
|
it('id=ellipse, ribbonTab=canvas, tags basic, Version in tools-Array', () => {
|
||||||
|
expect(ellipseTool.manifest.id).toBe('ellipse');
|
||||||
|
expect(ellipseTool.manifest.ribbonTab).toBe('canvas');
|
||||||
|
expect((ellipseTool.manifest.tags ?? [])).toContain('basic');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Task E7 – Event-Elemente-Polish: curtain, spotlight, barrier,
|
||||||
|
* stage-height-Attribut.
|
||||||
|
*
|
||||||
|
* Prüft die Tool-Platzierung (Element-Form + Options), die
|
||||||
|
* Pixi-Drawer-Primitives und die height-Durchreichung für stage.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
curtainTool, spotlightTool, barrierTool,
|
||||||
|
} from '../src/plugins/builtin/event-tools';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { elementToPrimitives } from '../src/render/pixi/elementDrawers';
|
||||||
|
import { SeatingService } from '../src/services/seatingService';
|
||||||
|
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function fireDown(tool: any, world: Pt, options: Record<string, unknown> = {}) {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = {
|
||||||
|
doc,
|
||||||
|
options,
|
||||||
|
setStatus: () => {},
|
||||||
|
setPreview: () => {},
|
||||||
|
} as any;
|
||||||
|
tool.handlers.down({ world } as any, ctx);
|
||||||
|
return { added: doc.getAllElements(), doc };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('E7: curtain', () => {
|
||||||
|
it('curtainTool platziert curtain-Element mit Wellen-Polyline und stageHeight', () => {
|
||||||
|
const { added } = fireDown(curtainTool, { x: 100, y: 50 }, { stageHeight: 120 });
|
||||||
|
expect(added.length).toBe(1);
|
||||||
|
const el = added[0];
|
||||||
|
expect(el.type).toBe('curtain');
|
||||||
|
expect(el.width).toBe(400); // Standard-Breite
|
||||||
|
expect(el.properties.stageHeight).toBe(120);
|
||||||
|
const pts = el.properties.points as Pt[];
|
||||||
|
expect(Array.isArray(pts)).toBe(true);
|
||||||
|
expect(pts.length).toBeGreaterThan(20); // Wellenpunkte
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Drawer: curtain → polyline-Primitive (geschlossen=false)', () => {
|
||||||
|
const { added } = fireDown(curtainTool, { x: 100, y: 50 }, { stageHeight: 120 });
|
||||||
|
const prims = elementToPrimitives(added[0]);
|
||||||
|
expect(prims.length).toBe(1);
|
||||||
|
expect(prims[0].kind).toBe('polyline');
|
||||||
|
expect((prims[0] as any).close).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('E7: spotlight', () => {
|
||||||
|
it('spotlightTool platziert Lichtkegel-Polygon mit angle und color', () => {
|
||||||
|
const { added } = fireDown(spotlightTool, { x: 0, y: 0 }, { angle: 45, color: '#ffcc00' });
|
||||||
|
expect(added.length).toBe(1);
|
||||||
|
const el = added[0];
|
||||||
|
expect(el.type).toBe('spotlight');
|
||||||
|
expect(el.properties.angle).toBe(45);
|
||||||
|
expect(el.properties.color).toBe('#ffcc00');
|
||||||
|
const pts = el.properties.points as Pt[];
|
||||||
|
expect(pts.length).toBe(4); // Kegel: Ursprung + zwei Basis-Ecken + zurück
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Drawer: spotlight → Polygon mit Füllung aus color', () => {
|
||||||
|
const { added } = fireDown(spotlightTool, { x: 0, y: 0 }, { angle: 45, color: '#ffcc00' });
|
||||||
|
const prims = elementToPrimitives(added[0]);
|
||||||
|
expect(prims.length).toBe(1);
|
||||||
|
expect(prims[0].kind).toBe('polyline');
|
||||||
|
expect((prims[0] as any).close).toBe(true);
|
||||||
|
expect((prims[0] as any).stroke.toLowerCase()).toContain('#ffcc00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Kegel-Ausrichtung: angle=0 zeigt nach rechts (Richtung +x)', () => {
|
||||||
|
const { added } = fireDown(spotlightTool, { x: 0, y: 0 }, { angle: 0, color: '#fff' });
|
||||||
|
const pts = added[0].properties.points as Pt[];
|
||||||
|
expect(pts[1].x).toBeGreaterThan(100); // Basis-Ecke weiter rechts als Ursprung
|
||||||
|
expect(pts[2].x).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('E7: barrier', () => {
|
||||||
|
it('barrierTool platziert Kette aus Kreis-Paaren entlang Standardlänge', () => {
|
||||||
|
const { added } = fireDown(barrierTool, { x: 0, y: 0 }, {});
|
||||||
|
expect(added.length).toBe(1);
|
||||||
|
const el = added[0];
|
||||||
|
expect(el.type).toBe('barrier');
|
||||||
|
expect(el.width).toBe(200); // Standard-Länge
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Drawer: barrier → mehrere circle-Primitives (Kettenglieder)', () => {
|
||||||
|
const { added } = fireDown(barrierTool, { x: 0, y: 0 }, {});
|
||||||
|
const prims = elementToPrimitives(added[0]);
|
||||||
|
expect(prims.length).toBeGreaterThan(4);
|
||||||
|
expect(prims.every((p) => p.kind === 'circle')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('E7: stage height', () => {
|
||||||
|
it('createStage nimmt height-Option auf (properties.height)', () => {
|
||||||
|
const stage = new SeatingService().createStage(100, 100, 'layer-0', { width: 400, height: 60 });
|
||||||
|
expect(stage.type).toBe('stage');
|
||||||
|
expect(stage.properties.height).toBe(60);
|
||||||
|
expect(stage.width).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* Task D9 – GripEditing: Vertex-/Center-Grips im select-Tool.
|
||||||
|
*
|
||||||
|
* getGripPoints: element-spezifische Griff-Punkte (line=Endpunkte,
|
||||||
|
* polyline=Vertices, circle/arc=Zentrum, rect=BBox-Ecken).
|
||||||
|
* Drag eines Vertex-Grips verschiebt NUR diesen Punkt in einer
|
||||||
|
* Transaktion (undo-fähig); BBox-Skalierung (C3fin) bleibt parallel
|
||||||
|
* für Elemente ohne Vertex-Grips (rect u.a.).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { selectTool, getGripPoints } from '../src/plugins/builtin/core-modify';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function mkCtx() {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const selectedIds = new Set<string>();
|
||||||
|
const previews: (CADElement | null)[] = [];
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const ctx: any = {
|
||||||
|
doc,
|
||||||
|
options: {},
|
||||||
|
setStatus: (m: string) => statuses.push(m),
|
||||||
|
setPreview: (el: CADElement | null) => previews.push(el),
|
||||||
|
selection: {
|
||||||
|
getIds: () => Array.from(selectedIds),
|
||||||
|
setIds: (ids: string[]) => {
|
||||||
|
selectedIds.clear();
|
||||||
|
for (const id of ids) selectedIds.add(id);
|
||||||
|
},
|
||||||
|
hitTest: (x: number, y: number, tol: number) => {
|
||||||
|
// Einfacher Hit-Test: trifft wenn ein Element-BBox den Punkt enthaelt
|
||||||
|
for (const el of doc.getAllElements()) {
|
||||||
|
const p = el.properties as Record<string, unknown>;
|
||||||
|
if (p.x1 !== undefined) {
|
||||||
|
const minX = Math.min(p.x1 as number, p.x2 as number);
|
||||||
|
const maxX = Math.max(p.x1 as number, p.x2 as number);
|
||||||
|
const minY = Math.min(p.y1 as number, p.y2 as number);
|
||||||
|
const maxY = Math.max(p.y1 as number, p.y2 as number);
|
||||||
|
if (x >= minX - tol && x <= maxX + tol && y >= minY - tol && y <= maxY + tol) return el;
|
||||||
|
}
|
||||||
|
if (x >= el.x - el.width / 2 - tol && x <= el.x + el.width / 2 + tol &&
|
||||||
|
y >= el.y - el.height / 2 - tol && y <= el.y + el.height / 2 + tol) return el;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ctx, doc, selectedIds, previews, statuses };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pe(x: number, y: number, mods: { shift?: boolean; ctrl?: boolean } = {}): { world: Pt; shift: boolean; ctrl: boolean } {
|
||||||
|
return { world: { x, y }, shift: mods.shift ?? false, ctrl: mods.ctrl ?? false } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
selectTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getGripPoints (reine Funktion) ────────────────────────
|
||||||
|
|
||||||
|
describe('D9: getGripPoints', () => {
|
||||||
|
it('line: beide Endpunkte als Grips', () => {
|
||||||
|
const el = {
|
||||||
|
id: 'l1', type: 'line', layerId: 'l', x: 50, y: 50, width: 100, height: 0,
|
||||||
|
properties: { x1: 0, y1: 50, x2: 100, y2: 50 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
const grips = getGripPoints(el);
|
||||||
|
expect(grips.map((g) => g.kind)).toEqual(['vertex', 'vertex']);
|
||||||
|
expect(grips[0].point).toEqual({ x: 0, y: 50 });
|
||||||
|
expect(grips[1].point).toEqual({ x: 100, y: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polyline: alle Vertices als Grips', () => {
|
||||||
|
const el = {
|
||||||
|
id: 'p1', type: 'polyline', layerId: 'l', x: 50, y: 0, width: 100, height: 100,
|
||||||
|
properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }] },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
const grips = getGripPoints(el);
|
||||||
|
expect(grips.length).toBe(3);
|
||||||
|
expect(grips.every((g) => g.kind === 'vertex')).toBe(true);
|
||||||
|
expect(grips[2].point).toEqual({ x: 100, y: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('circle: Zentrum als Center-Grip', () => {
|
||||||
|
const el = {
|
||||||
|
id: 'c1', type: 'circle', layerId: 'l', x: 30, y: 40, width: 20, height: 20,
|
||||||
|
properties: { radius: 10 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
const grips = getGripPoints(el);
|
||||||
|
expect(grips).toHaveLength(1);
|
||||||
|
expect(grips[0].kind).toBe('center');
|
||||||
|
expect(grips[0].point).toEqual({ x: 30, y: 40 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rect: BBox-Ecken als Corner-Grips (C3fin bleibt)', () => {
|
||||||
|
const el = {
|
||||||
|
id: 'r1', type: 'rect', layerId: 'l', x: 50, y: 25, width: 100, height: 50,
|
||||||
|
properties: {},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
const grips = getGripPoints(el);
|
||||||
|
expect(grips.length).toBe(4);
|
||||||
|
expect(grips.every((g) => g.kind === 'corner')).toBe(true);
|
||||||
|
expect(grips[0].point).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(grips[2].point).toEqual({ x: 100, y: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Grips tragen index fuer Drag-Zuordnung', () => {
|
||||||
|
const el = {
|
||||||
|
id: 'p1', type: 'polyline', layerId: 'l', x: 0, y: 0, width: 10, height: 10,
|
||||||
|
properties: { points: [{ x: 0, y: 0 }, { x: 10, y: 10 }] },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
const grips = getGripPoints(el);
|
||||||
|
expect(grips[0].index).toBe(0);
|
||||||
|
expect(grips[1].index).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Drag-Interaktion: Vertex-Zug (undo-faehig) ─────────────
|
||||||
|
|
||||||
|
describe('D9: Vertex-Drag im selectTool', () => {
|
||||||
|
it('Linien-Endpunkt ziehen verschiebt NUR diesen Punkt (1 Undo)', () => {
|
||||||
|
const { ctx, doc, selectedIds } = mkCtx();
|
||||||
|
const line = {
|
||||||
|
id: 'l1', type: 'line', layerId: 'l', x: 50, y: 0, width: 100, height: 0,
|
||||||
|
properties: { x1: 0, y1: 0, x2: 100, y2: 0 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
doc.addElement(line);
|
||||||
|
selectedIds.add('l1');
|
||||||
|
|
||||||
|
// Klick auf Endpunkt (100,0) startet Vertex-Drag
|
||||||
|
selectTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
selectTool.handlers.up!(pe(120, 30), ctx);
|
||||||
|
|
||||||
|
const updated = doc.getElement('l1')!;
|
||||||
|
expect(updated.properties.x2).toBe(120);
|
||||||
|
expect(updated.properties.y2).toBe(30);
|
||||||
|
// Anderer Endpunkt unveraendert
|
||||||
|
expect(updated.properties.x1).toBe(0);
|
||||||
|
expect(updated.properties.y1).toBe(0);
|
||||||
|
// Undo stellt her
|
||||||
|
doc.undo();
|
||||||
|
const restored = doc.getElement('l1')!;
|
||||||
|
expect(restored.properties.x2).toBe(100);
|
||||||
|
expect(restored.properties.y2).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Polyline-Vertex ziehen aktualisiert nur diesen Vertex', () => {
|
||||||
|
const { ctx, doc, selectedIds } = mkCtx();
|
||||||
|
const poly = {
|
||||||
|
id: 'p1', type: 'polyline', layerId: 'l', x: 50, y: 50, width: 100, height: 100,
|
||||||
|
properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }] },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
doc.addElement(poly);
|
||||||
|
selectedIds.add('p1');
|
||||||
|
|
||||||
|
// Mittleren Vertex (100,0) greifen und auf (100,50) ziehen
|
||||||
|
selectTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
selectTool.handlers.up!(pe(100, 50), ctx);
|
||||||
|
|
||||||
|
const pts = doc.getElement('p1')!.properties.points as Pt[];
|
||||||
|
expect(pts[1]).toEqual({ x: 100, y: 50 });
|
||||||
|
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(pts[2]).toEqual({ x: 100, y: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Kreis-Zentrum ziehen verschiebt x/y (Radius bleibt)', () => {
|
||||||
|
const { ctx, doc, selectedIds } = mkCtx();
|
||||||
|
const circle = {
|
||||||
|
id: 'c1', type: 'circle', layerId: 'l', x: 30, y: 40, width: 20, height: 20,
|
||||||
|
properties: { radius: 10 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
doc.addElement(circle);
|
||||||
|
selectedIds.add('c1');
|
||||||
|
|
||||||
|
selectTool.handlers.down!(pe(30, 40), ctx);
|
||||||
|
selectTool.handlers.up!(pe(60, 80), ctx);
|
||||||
|
|
||||||
|
const updated = doc.getElement('c1')!;
|
||||||
|
expect(updated.x).toBe(60);
|
||||||
|
expect(updated.y).toBe(80);
|
||||||
|
expect(updated.properties.radius).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BBox-Corner-Drag bei rect funktioniert weiterhin (Skalierung)', () => {
|
||||||
|
const { ctx, doc, selectedIds } = mkCtx();
|
||||||
|
const rect = {
|
||||||
|
id: 'r1', type: 'rect', layerId: 'l', x: 50, y: 25, width: 100, height: 50,
|
||||||
|
properties: {},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
doc.addElement(rect);
|
||||||
|
selectedIds.add('r1');
|
||||||
|
|
||||||
|
// Ecke unten-rechts (100,50) greifen und auf (150,75) ziehen
|
||||||
|
selectTool.handlers.down!(pe(100, 50), ctx);
|
||||||
|
selectTool.handlers.up!(pe(150, 75), ctx);
|
||||||
|
|
||||||
|
const updated = doc.getElement('r1')!;
|
||||||
|
expect(updated.width).toBeCloseTo(150, 0);
|
||||||
|
expect(updated.height).toBeCloseTo(75, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Vertex-Grip gewinnt gegen BBox-Corner bei Ueberlappung (line-Endpunkt am BBox-Rand)', () => {
|
||||||
|
// line (0,0)-(100,0): Endpunkt (0,0) ist AUCH BBox-Ecke.
|
||||||
|
// Der Drag muss als Vertex (Punkt verschieben) wirken, nicht als Scale.
|
||||||
|
const { ctx, doc, selectedIds } = mkCtx();
|
||||||
|
const line = {
|
||||||
|
id: 'l1', type: 'line', layerId: 'l', x: 50, y: 0, width: 100, height: 0,
|
||||||
|
properties: { x1: 0, y1: 0, x2: 100, y2: 0 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
doc.addElement(line);
|
||||||
|
selectedIds.add('l1');
|
||||||
|
|
||||||
|
selectTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
selectTool.handlers.up!(pe(-30, -20), ctx);
|
||||||
|
|
||||||
|
const updated = doc.getElement('l1')!;
|
||||||
|
// x1/y1 verschoben, x2 unveraendert → Vertex-Drag, kein Scale
|
||||||
|
expect(updated.properties.x1).toBe(-30);
|
||||||
|
expect(updated.properties.y1).toBe(-20);
|
||||||
|
expect(updated.properties.x2).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Preview bei Vertex-Drag zeigt aktualisiertes Element', () => {
|
||||||
|
const { ctx, doc, selectedIds, previews } = mkCtx();
|
||||||
|
const line = {
|
||||||
|
id: 'l1', type: 'line', layerId: 'l', x: 50, y: 0, width: 100, height: 0,
|
||||||
|
properties: { x1: 0, y1: 0, x2: 100, y2: 0 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
doc.addElement(line);
|
||||||
|
selectedIds.add('l1');
|
||||||
|
|
||||||
|
selectTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
selectTool.handlers.move!(pe(90, 40), ctx);
|
||||||
|
expect(previews.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const last = previews[previews.length - 1];
|
||||||
|
expect(last).not.toBeNull();
|
||||||
|
const lastProps = last!.properties as Record<string, unknown>;
|
||||||
|
expect(lastProps.x2).toBe(90);
|
||||||
|
expect(lastProps.y2).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Task H4 – i18n-Grundlage: zentrale strings.ts Maps de/en.
|
||||||
|
*
|
||||||
|
* strings-Modul: t(key) mit de/en-Maps, getLanguage/setLanguage
|
||||||
|
* (localStorage-persistiert, Default de), subscribe + useT-Hook.
|
||||||
|
* Kern-UI-Texte (LayoutBar, Modus-Umschaltung) laufen ueber die
|
||||||
|
* Maps; vollstaendige mechanische Ersetzung aller Komponenten ist
|
||||||
|
* teilautomatisierbares Follow-up (Roadmap-Vermerk).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
t, getLanguage, setLanguage, subscribeLanguage, useT, LANGUAGES,
|
||||||
|
} from '../src/i18n/strings';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
// ––– strings-Modul ––––––––––––––––––––––––
|
||||||
|
|
||||||
|
describe('H4: i18n strings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
setLanguage('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Default-Sprache ist de', () => {
|
||||||
|
expect(getLanguage()).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('t liefert deutsche Kern-UI-Texte', () => {
|
||||||
|
expect(t('layouts.title')).toBe('Layouts');
|
||||||
|
expect(t('layouts.add')).toBe('Layout hinzufügen');
|
||||||
|
expect(t('layouts.empty')).toBe('Keine Layouts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setLanguage(en) wechselt auf englische Texte', () => {
|
||||||
|
setLanguage('en');
|
||||||
|
expect(t('layouts.title')).toBe('Layouts');
|
||||||
|
expect(t('layouts.add')).toBe('Add layout');
|
||||||
|
expect(t('layouts.empty')).toBe('No layouts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Sprache persistiert in localStorage und ueberlebt Reload-Semantik', () => {
|
||||||
|
setLanguage('en');
|
||||||
|
expect(localStorage.getItem('webcad.language')).toBe('en');
|
||||||
|
// erneutes Lesen startet aus persistiertem Zustand
|
||||||
|
expect(getLanguage()).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('LANGUAGES enthaelt genau de und en', () => {
|
||||||
|
expect(LANGUAGES).toEqual(['de', 'en']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('t mit unbekanntem Key liefert den Key zurueck (Fallback, lint-bar)', () => {
|
||||||
|
setLanguage('de');
|
||||||
|
expect(t('gibts.nicht')).toBe('gibts.nicht');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribeLanguage feuert bei Wechsel und unsubscribed', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
const off = subscribeLanguage(cb);
|
||||||
|
setLanguage('en');
|
||||||
|
expect(cb).toHaveBeenCalledWith('en');
|
||||||
|
off();
|
||||||
|
cb.mockClear();
|
||||||
|
setLanguage('de');
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ––– useT-Hook + Komponente ––––––––––––––––––
|
||||||
|
|
||||||
|
describe('H4: useT-Hook rendert LayoutBar zweisprachig', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
setLanguage('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('LayoutBar zeigt deutsche Texte und wechselt live auf englisch', async () => {
|
||||||
|
const LayoutBar = (await import('../src/components/LayoutBar')).default;
|
||||||
|
render(<LayoutBar doc={null} bridge={null} />);
|
||||||
|
expect(screen.getByText('Layouts')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Keine Layouts')).toBeTruthy();
|
||||||
|
expect(screen.getByTitle('Layout hinzufügen')).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setLanguage('en');
|
||||||
|
});
|
||||||
|
// Live-Update via subscribe (nach act flush)
|
||||||
|
expect(screen.getByText('No layouts')).toBeTruthy();
|
||||||
|
expect(screen.getByTitle('Add layout')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Task G2 – LayoutBar UI + Viewport-Widget + ZoomPanController.setView.
|
||||||
|
*
|
||||||
|
* LayoutBar listet Layouts des aktiven Dokuments, zeigt je Layout ein
|
||||||
|
* Minimap-Widget (Modellbereich + Viewport-Rechteck), Klick = zoom-to,
|
||||||
|
* „+“ speichert aktuellen Viewport als neues Layout, Löschen entfernt.
|
||||||
|
* ZoomPanController.setView(center, scale) setzt die Absolute Ansicht.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import LayoutBar, { computeMinimapRects } from '../src/components/LayoutBar';
|
||||||
|
import { ZoomPanController } from '../src/canvas/ZoomPanController';
|
||||||
|
import type { LayoutDefinition } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function createMockCanvas(w = 800, h = 600): HTMLCanvasElement {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeDoc(layouts: LayoutDefinition[]) {
|
||||||
|
return {
|
||||||
|
getAllLayouts: () => [...layouts],
|
||||||
|
addLayout: vi.fn((l: LayoutDefinition) => { layouts.push(l); }),
|
||||||
|
deleteLayout: vi.fn((id: string) => {
|
||||||
|
const i = layouts.findIndex((l) => l.id === id);
|
||||||
|
if (i >= 0) layouts.splice(i, 1);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeBridge(view = { center: { x: 10, y: 20 }, scale: 2 }) {
|
||||||
|
return {
|
||||||
|
zoomTo: vi.fn(),
|
||||||
|
getView: vi.fn(() => view),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const L1: LayoutDefinition = {
|
||||||
|
id: 'layout-1', name: 'A4 Plan',
|
||||||
|
viewport: { center: { x: 100, y: 50 }, scale: 0.5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── ZoomPanController.setView ─────────────────────────────
|
||||||
|
|
||||||
|
describe('G2: ZoomPanController.setView', () => {
|
||||||
|
it('setView(center, scale) zentriert Welt-Punkt exakt, Scale direkt', () => {
|
||||||
|
const zpc = new ZoomPanController(createMockCanvas(800, 600));
|
||||||
|
zpc.setView({ x: 100, y: 50 }, 2);
|
||||||
|
expect(zpc.getScale()).toBe(2);
|
||||||
|
const vp = zpc.getViewport();
|
||||||
|
const cx = (vp.minX + vp.maxX) / 2;
|
||||||
|
const cy = (vp.minY + vp.maxY) / 2;
|
||||||
|
expect(cx).toBeCloseTo(100, 5);
|
||||||
|
expect(cy).toBeCloseTo(50, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setView mit scale 1 bei Origin deckt (0,0) als Zentrum ab', () => {
|
||||||
|
const zpc = new ZoomPanController(createMockCanvas(800, 600));
|
||||||
|
zpc.setView({ x: 0, y: 0 }, 1);
|
||||||
|
const vp = zpc.getViewport();
|
||||||
|
expect((vp.minX + vp.maxX) / 2).toBeCloseTo(0, 5);
|
||||||
|
expect((vp.minY + vp.maxY) / 2).toBeCloseTo(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zweiter setView-Aufruf überschreibt ersten komplett', () => {
|
||||||
|
const zpc = new ZoomPanController(createMockCanvas(800, 600));
|
||||||
|
zpc.setView({ x: 500, y: 500 }, 4);
|
||||||
|
zpc.setView({ x: 0, y: 0 }, 0.5);
|
||||||
|
expect(zpc.getScale()).toBe(0.5);
|
||||||
|
const vp = zpc.getViewport();
|
||||||
|
expect((vp.minX + vp.maxX) / 2).toBeCloseTo(0, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── computeMinimapRects (Viewport-Widget-Geometrie) ───────
|
||||||
|
|
||||||
|
describe('G2: computeMinimapRects', () => {
|
||||||
|
const elements = [
|
||||||
|
{ x: 50, y: 50, width: 100, height: 100 }, // bbox 0..100
|
||||||
|
];
|
||||||
|
|
||||||
|
it('Modellbbox füllt Widget (mit Padding), leer → Volles Widget', () => {
|
||||||
|
const r = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 1 }, 100, 80);
|
||||||
|
expect(r.model.w).toBeGreaterThan(80);
|
||||||
|
expect(r.model.h).toBeGreaterThan(60);
|
||||||
|
const empty = computeMinimapRects([], { center: { x: 0, y: 0 }, scale: 1 }, 100, 80);
|
||||||
|
expect(empty.model.w).toBe(100);
|
||||||
|
expect(empty.model.h).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Viewport-Zentrum liegt im View-Rechteck; höherer Scale = kleinerer Ausschnitt', () => {
|
||||||
|
const r1 = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 1 }, 100, 80);
|
||||||
|
const cx = r1.model.x + (r1.view.x + r1.view.w / 2 - r1.model.x) / r1.model.w * r1.model.w;
|
||||||
|
// View-Zentrum in Widget-Koordinaten:
|
||||||
|
const viewCX = r1.view.x + r1.view.w / 2;
|
||||||
|
const modelCX = r1.model.x + r1.model.w / 2;
|
||||||
|
expect(Math.abs(viewCX - modelCX)).toBeLessThan(1); // 50,50 = BBox-Zentrum
|
||||||
|
const r2 = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 4 }, 100, 80);
|
||||||
|
expect(r2.view.w).toBeLessThan(r1.view.w);
|
||||||
|
expect(r2.view.h).toBeLessThan(r1.view.h);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── LayoutBar UI ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('G2: LayoutBar', () => {
|
||||||
|
it('ohne Doc: Empty-State ohne Crash', () => {
|
||||||
|
render(<LayoutBar doc={null} bridge={null} />);
|
||||||
|
expect(screen.getByText(/Keine Layouts/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listet Layouts mit Name + Minimap-SVG', () => {
|
||||||
|
const doc = fakeDoc([L1]);
|
||||||
|
render(<LayoutBar doc={doc as any} bridge={fakeBridge() as any} />);
|
||||||
|
expect(screen.getByText('A4 Plan')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('layout-minimap-layout-1')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Klick auf Layout-Eintrag ruft bridge.zoomTo mit gespeichertem Viewport', () => {
|
||||||
|
const bridge = fakeBridge();
|
||||||
|
render(<LayoutBar doc={fakeDoc([L1]) as any} bridge={bridge as any} />);
|
||||||
|
fireEvent.click(screen.getByTestId('layout-entry-layout-1'));
|
||||||
|
expect(bridge.zoomTo).toHaveBeenCalledWith(
|
||||||
|
{ x: 100, y: 50 },
|
||||||
|
0.5,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('+ Button speichert aktuellen Viewport als neues Layout', () => {
|
||||||
|
const layouts: LayoutDefinition[] = [];
|
||||||
|
const doc = fakeDoc(layouts);
|
||||||
|
const bridge = fakeBridge();
|
||||||
|
render(<LayoutBar doc={doc as any} bridge={bridge as any} />);
|
||||||
|
fireEvent.click(screen.getByTitle('Layout hinzufügen'));
|
||||||
|
expect(doc.addLayout).toHaveBeenCalledTimes(1);
|
||||||
|
const added = (doc.addLayout as ReturnType<typeof vi.fn>).mock.calls[0][0] as LayoutDefinition;
|
||||||
|
expect(added.viewport.center.x).toBe(10);
|
||||||
|
expect(added.viewport.center.y).toBe(20);
|
||||||
|
expect(added.viewport.scale).toBe(2);
|
||||||
|
expect(added.name).toMatch(/Layout \d/);
|
||||||
|
expect(added.id).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Löschen-Button entfernt Layout über doc.deleteLayout', () => {
|
||||||
|
const doc = fakeDoc([L1]);
|
||||||
|
render(<LayoutBar doc={doc as any} bridge={fakeBridge() as any} />);
|
||||||
|
fireEvent.click(screen.getByTitle('Layout A4 Plan löschen'));
|
||||||
|
expect(doc.deleteLayout).toHaveBeenCalledWith('layout-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Bridge: Klick wirft nicht (no-op)', () => {
|
||||||
|
render(<LayoutBar doc={fakeDoc([L1]) as any} bridge={null} />);
|
||||||
|
expect(() => fireEvent.click(screen.getByTestId('layout-entry-layout-1'))).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Task G1 – Layout-Datenmodell: Yjs Map layoutId → LayoutDefinition.
|
||||||
|
*
|
||||||
|
* LayoutDefinition: { id, name, viewport: { center: {x,y}, scale },
|
||||||
|
* frameBlockRef? }. CRUD über CADDocument, undo-getrackt, Persistence
|
||||||
|
* läuft automatisch über die Yjs-Doc-Serialisierung (drawing json).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
|
||||||
|
function mkDoc(): CADDocument {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
return new CADDocument(ydoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYOUT = {
|
||||||
|
id: 'layout-1',
|
||||||
|
name: 'A4 Plan',
|
||||||
|
viewport: { center: { x: 100, y: 50 }, scale: 0.5 },
|
||||||
|
frameBlockRef: 'frame-a4',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('G1: Layout-CRUD', () => {
|
||||||
|
it('addLayout + getLayout: Layout landet mit allen Feldern in der Map', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
doc.addLayout(LAYOUT);
|
||||||
|
const loaded = doc.getLayout('layout-1');
|
||||||
|
expect(loaded).toBeDefined();
|
||||||
|
expect(loaded!.name).toBe('A4 Plan');
|
||||||
|
expect(loaded!.viewport.center.x).toBe(100);
|
||||||
|
expect(loaded!.viewport.center.y).toBe(50);
|
||||||
|
expect(loaded!.viewport.scale).toBe(0.5);
|
||||||
|
expect(loaded!.frameBlockRef).toBe('frame-a4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAllLayouts liefert Array; leer ohne Layouts', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
expect(doc.getAllLayouts()).toHaveLength(0);
|
||||||
|
doc.addLayout(LAYOUT);
|
||||||
|
doc.addLayout({ ...LAYOUT, id: 'layout-2', name: 'A3 Plan' });
|
||||||
|
const all = doc.getAllLayouts();
|
||||||
|
expect(all).toHaveLength(2);
|
||||||
|
expect(all.map((l) => l.name)).toContain('A3 Plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateLayout ändert name und viewport shallow (id/struktur bleibt)', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
doc.addLayout(LAYOUT);
|
||||||
|
doc.updateLayout('layout-1', { name: 'A4 geändert', viewport: { center: { x: 0, y: 0 }, scale: 2 } });
|
||||||
|
const loaded = doc.getLayout('layout-1');
|
||||||
|
expect(loaded!.name).toBe('A4 geändert');
|
||||||
|
expect(loaded!.viewport.scale).toBe(2);
|
||||||
|
expect(loaded!.frameBlockRef).toBe('frame-a4'); // unverändert erhalten
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteLayout entfernt Layout still (unbekannte ID kein Fehler)', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
doc.addLayout(LAYOUT);
|
||||||
|
doc.deleteLayout('layout-1');
|
||||||
|
expect(doc.getLayout('layout-1')).toBeUndefined();
|
||||||
|
expect(() => doc.deleteLayout('gibts-nicht')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('G1: Undo-Integration', () => {
|
||||||
|
it('Undo entfernt hinzugefügtes Layout wieder', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
doc.addLayout(LAYOUT);
|
||||||
|
expect(doc.getLayout('layout-1')).toBeDefined();
|
||||||
|
doc.undo();
|
||||||
|
expect(doc.getLayout('layout-1')).toBeUndefined();
|
||||||
|
doc.redo();
|
||||||
|
expect(doc.getLayout('layout-1')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Layout-Undo ist EIN Schritt (nicht mit Element-Transaktion vermischt)', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
doc.addLayout(LAYOUT);
|
||||||
|
doc.deleteLayout('layout-1');
|
||||||
|
// Zwei Schritte: undo stellt Löschung wieder her
|
||||||
|
doc.undo();
|
||||||
|
expect(doc.getLayout('layout-1')).toBeDefined();
|
||||||
|
doc.undo();
|
||||||
|
expect(doc.getLayout('layout-1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Task F4 – LibraryProviderExtension Tests.
|
||||||
|
*
|
||||||
|
* Testet die drei Bibliotheksquellen:
|
||||||
|
* - builtin-catalog (statische JSON-Startersets event/architecture/landscape)
|
||||||
|
* - user-library (zeichnungsscoped Blöcke via /api/drawings/:id/blocks)
|
||||||
|
* - global-library (globale Blöcke via /api/global-blocks)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { builtinCatalogProvider } from '../src/plugins/builtin/library/providers';
|
||||||
|
import { makeUserLibraryProvider, makeGlobalLibraryProvider } from '../src/plugins/builtin/library/providers';
|
||||||
|
import { builtinLibraryPlugin } from '../src/plugins/builtin/library';
|
||||||
|
import { pluginRegistry } from '../src/plugins/PluginRegistry';
|
||||||
|
import LibraryProviderPanel from '../src/components/LibraryProviderPanel';
|
||||||
|
|
||||||
|
describe('F4: builtin catalog provider', () => {
|
||||||
|
it('listet genau die 3 Katalog-Ordner', async () => {
|
||||||
|
const folders = await builtinCatalogProvider.listFolders();
|
||||||
|
expect(folders.map(f => f.id).sort()).toEqual(['cat-arch', 'cat-event', 'cat-land']);
|
||||||
|
expect(folders.every(f => f.parentId === null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Startersets: je 15-30 Blöcke mit gültiger Payload und SVG-Thumbnail', async () => {
|
||||||
|
const folders = await builtinCatalogProvider.listFolders();
|
||||||
|
for (const folder of folders) {
|
||||||
|
const blocks = await builtinCatalogProvider.listBlocks(folder.id);
|
||||||
|
expect(blocks.length).toBeGreaterThanOrEqual(15);
|
||||||
|
expect(blocks.length).toBeLessThanOrEqual(30);
|
||||||
|
expect(blocks.every(b => b.folderId === folder.id)).toBe(true);
|
||||||
|
for (const block of blocks) {
|
||||||
|
expect(block.thumbnail).toContain('<svg');
|
||||||
|
expect(block.payload).toBeTruthy();
|
||||||
|
const elements = JSON.parse(block.payload as string);
|
||||||
|
expect(Array.isArray(elements)).toBe(true);
|
||||||
|
expect(elements.length).toBeGreaterThan(0);
|
||||||
|
for (const el of elements) {
|
||||||
|
expect(typeof el.id).toBe('string');
|
||||||
|
expect(typeof el.type).toBe('string');
|
||||||
|
expect('x' in el && 'y' in el).toBe(true);
|
||||||
|
expect(el.properties).toBeTypeOf('object');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listBlocks filtert nach Ordner, getBlock liefert den Block mit Payload', async () => {
|
||||||
|
const eventBlocks = await builtinCatalogProvider.listBlocks('cat-event');
|
||||||
|
expect(eventBlocks.length).toBeGreaterThanOrEqual(15);
|
||||||
|
const first = eventBlocks[0];
|
||||||
|
const fetched = await builtinCatalogProvider.getBlock(first.id);
|
||||||
|
expect(fetched.id).toBe(first.id);
|
||||||
|
expect(fetched.payload).toBe(first.payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F4: Registry-Anbindung', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
pluginRegistry.unregisterV2(builtinLibraryPlugin.manifest.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Plugin registriert Provider, enableV2 macht ihn sichtbar, disableV2 entfernt ihn', () => {
|
||||||
|
pluginRegistry.registerV2(builtinLibraryPlugin);
|
||||||
|
pluginRegistry.enableV2(builtinLibraryPlugin.manifest.id);
|
||||||
|
const providers = pluginRegistry.getLibraryProviders();
|
||||||
|
expect(providers.some(p => p.id === 'builtin-catalog')).toBe(true);
|
||||||
|
|
||||||
|
pluginRegistry.disableV2(builtinLibraryPlugin.manifest.id);
|
||||||
|
expect(pluginRegistry.getLibraryProviders().some(p => p.id === 'builtin-catalog')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F4: user-library Provider', () => {
|
||||||
|
it('mappt zeichnungsscoped Blöcke auf LibBlock mit Thumbnail+Payload', async () => {
|
||||||
|
const provider = makeUserLibraryProvider([
|
||||||
|
{
|
||||||
|
id: 'b1', drawing_id: 'd1', name: 'Bühne A', description: null, category: 'stage',
|
||||||
|
elements_json: JSON.stringify([
|
||||||
|
{ id: 'e1', type: 'rect', layerId: 'layer-0', x: 0, y: 0, width: 100, height: 50, properties: { fill: '#3a3a4a' } },
|
||||||
|
]),
|
||||||
|
thumbnail: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const folders = await provider.listFolders();
|
||||||
|
expect(folders.length).toBe(1);
|
||||||
|
const blocks = await provider.listBlocks(folders[0].id);
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0].name).toBe('Bühne A');
|
||||||
|
expect(blocks[0].thumbnail).toContain('<svg');
|
||||||
|
const els = JSON.parse(blocks[0].payload as string);
|
||||||
|
expect(els[0].id).toBe('e1');
|
||||||
|
expect((await provider.getBlock(blocks[0].id)).name).toBe('Bühne A');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F4: global-library Provider', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lädt Ordner und Blöcke der globalen Bibliothek über die API', async () => {
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [
|
||||||
|
{ id: 'gf1', name: 'Event', parent_id: null, created_at: '2026-01-01' },
|
||||||
|
] })
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [
|
||||||
|
{ id: 'gb1', folder_id: 'gf1', name: 'Stuhl-Reihe', block_data: '[]', svg_data: null, is_favorite: false, created_at: '2026-01-01', updated_at: '2026-01-01' },
|
||||||
|
] });
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const provider = makeGlobalLibraryProvider('tok');
|
||||||
|
const folders = await provider.listFolders();
|
||||||
|
expect(folders).toHaveLength(1);
|
||||||
|
expect(folders[0].label).toBe('Event');
|
||||||
|
const blocks = await provider.listBlocks('gf1');
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0].name).toBe('Stuhl-Reihe');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F4: LibraryProviderPanel UI', () => {
|
||||||
|
it('rendert Provider-Sektion mit Katalog-Ordnern und Blöcken', async () => {
|
||||||
|
render(<LibraryProviderPanel providers={[builtinCatalogProvider]} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByText(/Event-Katalog/).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
// Jede Sektion listet Blöcke
|
||||||
|
expect(screen.getAllByRole('button', { name: /Stuhl/ }).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DragStart legt die Element-Array-Payload in den Datentransfer', async () => {
|
||||||
|
const setData = vi.fn();
|
||||||
|
render(<LibraryProviderPanel providers={[builtinCatalogProvider]} />);
|
||||||
|
const blockBtn = await screen.findByRole('button', { name: /Stuhl/ });
|
||||||
|
fireEvent.dragStart(blockBtn, { dataTransfer: { setData } });
|
||||||
|
expect(setData).toHaveBeenCalledWith('text/global-block-data', expect.any(String));
|
||||||
|
const payload = JSON.parse(setData.mock.calls[0][1] as string);
|
||||||
|
expect(Array.isArray(payload)).toBe(true);
|
||||||
|
|
||||||
|
expect(payload[0].id).toBeTypeOf('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Task H5 – Flächenberechnung Polygon (m²) + measure-area Tool.
|
||||||
|
*
|
||||||
|
* polygonArea: Shoelace-Formel (Absolutwert, CW/CCW egal),
|
||||||
|
* formatArea: Einheiten-Formatierung analog formatDistance,
|
||||||
|
* measureAreaTool: Klick-Sequenz, Live-Fläche im Status, ENTER
|
||||||
|
* committet ab 3 Punkten, cancel setzt zurück.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { polygonArea } from '../src/tools/modification/geometry';
|
||||||
|
import { formatArea } from '../src/utils/format';
|
||||||
|
import { measureAreaTool } from '../src/plugins/builtin/core-measure';
|
||||||
|
import type { CADElement } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
describe('H5: polygonArea (Shoelace)', () => {
|
||||||
|
it('Rechteck 100×50 = 5000 Welt-Einheiten²', () => {
|
||||||
|
const pts = [
|
||||||
|
{ x: 0, y: 0 }, { x: 100, y: 0 },
|
||||||
|
{ x: 100, y: 50 }, { x: 0, y: 50 },
|
||||||
|
];
|
||||||
|
expect(polygonArea(pts)).toBeCloseTo(5000, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Dreieck 100×50/2 = 2500', () => {
|
||||||
|
const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 0, y: 50 }];
|
||||||
|
expect(polygonArea(pts)).toBeCloseTo(2500, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Uhrzeigersinn und gegen Uhrzeigersinn ergeben denselben Absolutwert', () => {
|
||||||
|
const ccw = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }];
|
||||||
|
const cw = [...ccw].reverse();
|
||||||
|
expect(polygonArea(cw)).toBeCloseTo(polygonArea(ccw), 6);
|
||||||
|
expect(polygonArea(cw)).toBeCloseTo(10000, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weniger als 3 Punkte → 0; kollineare Punkte → 0', () => {
|
||||||
|
expect(polygonArea([{ x: 0, y: 0 }, { x: 1, y: 1 }])).toBe(0);
|
||||||
|
expect(polygonArea([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 200, y: 0 }])).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('H5: formatArea', () => {
|
||||||
|
it('mm: 5000 → "5000 mm²" (scaleFactor 1)', () => {
|
||||||
|
expect(formatArea(5000, 'mm', 1)).toBe('5000 mm²');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cm: 5000mm² → "50.0 cm²"', () => {
|
||||||
|
expect(formatArea(5000, 'cm', 1)).toBe('50.0 cm²');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('m: 5000000mm² → "5.00 m²"', () => {
|
||||||
|
expect(formatArea(5000000, 'm', 1)).toBe('5.00 m²');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Flächen skalieren QUADRATISCH: 5 WE² bei scaleFactor 1000 = 5000000 mm²', () => {
|
||||||
|
// 1 Welt-Einheit = 1000mm → 1 WE² = 1.000.000 mm²
|
||||||
|
expect(formatArea(5, 'mm', 1000)).toBe('5000000 mm²');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('H5: measureAreaTool', () => {
|
||||||
|
// Module-State areaPts leakt zwischen Tests — vor jedem Test resetten:
|
||||||
|
beforeEach(() => {
|
||||||
|
measureAreaTool.handlers.cancel?.({ setStatus: () => {} } as never);
|
||||||
|
});
|
||||||
|
function fireSeq(events: Array<{ type: 'down' | 'move' | 'key' | 'cancel'; x?: number; y?: number; key?: string }>, options: Record<string, unknown> = {}) {
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const ctx: any = {
|
||||||
|
options,
|
||||||
|
setStatus: (m: string) => statuses.push(m),
|
||||||
|
setPreview: (_el: CADElement | null) => {},
|
||||||
|
doc: null,
|
||||||
|
};
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.type === 'down') measureAreaTool.handlers.down!({ world: { x: ev.x!, y: ev.y! } } as never, ctx);
|
||||||
|
if (ev.type === 'move') measureAreaTool.handlers.move!({ world: { x: ev.x!, y: ev.y! } } as never, ctx);
|
||||||
|
if (ev.type === 'key' && ev.key) measureAreaTool.handlers.key?.({ key: ev.key } as never, ctx);
|
||||||
|
if (ev.type === 'cancel') measureAreaTool.handlers.cancel?.(ctx);
|
||||||
|
}
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('erster Klick startet Messung, Status fordert weitere Punkte', () => {
|
||||||
|
const s = fireSeq([{ type: 'down', x: 0, y: 0 }]);
|
||||||
|
expect(s[0]).toContain('Fläche');
|
||||||
|
expect(s[s.length - 1]).toContain('klicken');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Live-Fläche im Status ab 3 Punkten bei move', () => {
|
||||||
|
const s = fireSeq([
|
||||||
|
{ type: 'down', x: 0, y: 0 },
|
||||||
|
{ type: 'down', x: 100, y: 0 },
|
||||||
|
{ type: 'down', x: 100, y: 50 },
|
||||||
|
{ type: 'move', x: 0, y: 50 },
|
||||||
|
]);
|
||||||
|
expect(s.some((m) => m.includes('5000'))).toBe(true);
|
||||||
|
expect(s[s.length - 1]).toContain('mm²');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ENTER committet ab 3 Punkten mit Endfläche im Status', () => {
|
||||||
|
const s = fireSeq([
|
||||||
|
{ type: 'down', x: 0, y: 0 },
|
||||||
|
{ type: 'down', x: 100, y: 0 },
|
||||||
|
{ type: 'down', x: 100, y: 100 },
|
||||||
|
{ type: 'key', key: 'Enter' },
|
||||||
|
]);
|
||||||
|
// Dreieck (0,0),(100,0),(100,100): Shoelace = 1/2 · 100 · 100 = 5000
|
||||||
|
expect(s[s.length - 1]).toContain('5000');
|
||||||
|
expect(s[s.length - 1]).toContain('3 Punkte');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ENTER mit weniger als 3 Punkten committet NICHT (Status erklärt)', () => {
|
||||||
|
const s = fireSeq([
|
||||||
|
{ type: 'down', x: 0, y: 0 },
|
||||||
|
{ type: 'down', x: 100, y: 0 },
|
||||||
|
{ type: 'key', key: 'Enter' },
|
||||||
|
]);
|
||||||
|
expect(s[s.length - 1]).toContain('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel setzt Messung zurück', () => {
|
||||||
|
const s = fireSeq([
|
||||||
|
{ type: 'down', x: 0, y: 0 },
|
||||||
|
{ type: 'down', x: 50, y: 50 },
|
||||||
|
{ type: 'cancel' },
|
||||||
|
{ type: 'down', x: 0, y: 0 },
|
||||||
|
]);
|
||||||
|
// Nach cancel startet eine frische Messung (kein altes Polygon)
|
||||||
|
expect(s[s.length - 1]).toContain('klicken');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Manifest: id measure-area, tags basic, ribbonTab canvas', () => {
|
||||||
|
expect(measureAreaTool.manifest.id).toBe('measure-area');
|
||||||
|
expect(measureAreaTool.manifest.ribbonTab).toBe('canvas');
|
||||||
|
expect((measureAreaTool.manifest.tags ?? [])).toContain('basic');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Task D4 – MTEXT-Tool: Mehrzeiliger Text mit/ohne Rahmen.
|
||||||
|
*
|
||||||
|
* Klick platziert mehrzeiligen Text (Inhalt aus Options, Zeilen-
|
||||||
|
* umbrueche), fontSize-Option, optionale Rahmen-Option erzeugt ein
|
||||||
|
* rect-Element in derselben Transaktion (1 Undo-Schritt).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mtextTool } from '../src/plugins/builtin/core-annotate';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function mkCtx(options: Record<string, unknown> = {}) {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const ctx: any = {
|
||||||
|
doc,
|
||||||
|
options,
|
||||||
|
setStatus: (m: string) => statuses.push(m),
|
||||||
|
setPreview: (_el: CADElement | null) => {},
|
||||||
|
};
|
||||||
|
return { ctx, doc, statuses };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pe(x: number, y: number): { world: Pt } {
|
||||||
|
return { world: { x, y } } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('D4: mtextTool', () => {
|
||||||
|
it('platziert mehrzeiligen Text mit Zeilenumbruechen aus den Options', () => {
|
||||||
|
const { ctx, doc } = mkCtx({ text: 'Zeile 1\nZeile 2\nZeile 3', fontSize: 14 });
|
||||||
|
mtextTool.handlers.down!(pe(100, 50), ctx);
|
||||||
|
const els = doc.getAllElements();
|
||||||
|
expect(els).toHaveLength(1);
|
||||||
|
const el = els[0];
|
||||||
|
expect(el.type).toBe('text');
|
||||||
|
expect(el.x).toBe(100);
|
||||||
|
expect(el.y).toBe(50);
|
||||||
|
expect(el.properties.text).toBe('Zeile 1\nZeile 2\nZeile 3');
|
||||||
|
expect(el.properties.fontSize).toBe(14);
|
||||||
|
expect(el.properties.mtext).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Rahmen-Option erzeugt zusaetzliches rect in derselben Transaktion (1 Undo)', () => {
|
||||||
|
const { ctx, doc } = mkCtx({ text: 'Mit Rahmen', frame: true });
|
||||||
|
mtextTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
const els = doc.getAllElements();
|
||||||
|
expect(els).toHaveLength(2);
|
||||||
|
const texts = els.filter((e) => e.type === 'text');
|
||||||
|
const rects = els.filter((e) => e.type === 'rect');
|
||||||
|
expect(texts).toHaveLength(1);
|
||||||
|
expect(rects).toHaveLength(1);
|
||||||
|
expect(rects[0].x).toBeCloseTo(0, 5);
|
||||||
|
expect(rects[0].y).toBeCloseTo(0, 5);
|
||||||
|
doc.undo();
|
||||||
|
expect(doc.getAllElements()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne frame-Option: nur Text, kein rect', () => {
|
||||||
|
const { ctx, doc } = mkCtx({ text: 'Ohne Rahmen' });
|
||||||
|
mtextTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
expect(doc.getAllElements().filter((e) => e.type === 'rect')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Standard-Text wenn Options leer: MTEXT-Platzhalter leer (Editor bearbeitet spaeter)', () => {
|
||||||
|
const { ctx, doc } = mkCtx({});
|
||||||
|
mtextTool.handlers.down!(pe(10, 10), ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
expect(el.properties.text).toBe('');
|
||||||
|
expect(el.properties.fontSize).toBe(12); // Default
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OptionsSchema: text/number/checkbox-Felder', () => {
|
||||||
|
const schema = mtextTool.optionsSchema ?? [];
|
||||||
|
const keys = schema.map((f) => f.key);
|
||||||
|
expect(keys).toContain('text');
|
||||||
|
expect(keys).toContain('fontSize');
|
||||||
|
expect(keys).toContain('frame');
|
||||||
|
const textField = schema.find((f) => f.key === 'text');
|
||||||
|
expect(textField!.type).toBe('text');
|
||||||
|
const frameField = schema.find((f) => f.key === 'frame');
|
||||||
|
expect(frameField!.type).toBe('checkbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Rahmenhoehe waechst mit Zeilenzahl', () => {
|
||||||
|
const { ctx: ctx1, doc: doc1 } = mkCtx({ text: 'eine Zeile', frame: true });
|
||||||
|
mtextTool.handlers.down!(pe(0, 0), ctx1);
|
||||||
|
const { ctx: ctx3, doc: doc3 } = mkCtx({ text: 'eins\nzwei\ndrei', frame: true });
|
||||||
|
mtextTool.handlers.down!(pe(0, 0), ctx3);
|
||||||
|
const rect1 = doc1.getAllElements().find((e) => e.type === 'rect')!;
|
||||||
|
const rect3 = doc3.getAllElements().find((e) => e.type === 'rect')!;
|
||||||
|
expect(rect3.height).toBeGreaterThan(rect1.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Manifest: id=mtext, tags basic', () => {
|
||||||
|
expect(mtextTool.manifest.id).toBe('mtext');
|
||||||
|
expect((mtextTool.manifest.tags ?? [])).toContain('basic');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Task G4 – PDF-Export je Layout im Ausgabemaßstab.
|
||||||
|
*
|
||||||
|
* exportLayoutPDF rendert den Layout-Viewport (center/scale) auf eine A4-
|
||||||
|
* Seite (landscape), skaliert Weltkoordinaten in den Ausgabemaßstab,
|
||||||
|
* berücksichtigt Linienbreiten aus Layern (lineType) und unterstützt
|
||||||
|
* eine monochrome Option. Titelblock (frameBlockRef) wird mitgezeichnet.
|
||||||
|
* Rückgabe: PDF-Bytes, per PDFDocument.load verifizierbar.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import { exportLayoutPDF } from '../src/services/pdfExport';
|
||||||
|
import type { CADElement, LayoutDefinition } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function line(id: string, x1: number, y1: number, x2: number, y2: number, layerId = 'layer-0'): CADElement {
|
||||||
|
return {
|
||||||
|
id, type: 'line', layerId,
|
||||||
|
x: Math.min(x1, x2), y: Math.min(y1, y2),
|
||||||
|
width: Math.abs(x2 - x1), height: Math.abs(y2 - y1),
|
||||||
|
properties: { x1, y1, x2, y2, stroke: '#ff0000' },
|
||||||
|
} as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkDoc(els: CADElement[], layouts: LayoutDefinition[]): CADDocument {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
for (const e of els) doc.addElement(e);
|
||||||
|
for (const l of layouts) doc.addLayout(l);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYOUT: LayoutDefinition = {
|
||||||
|
id: 'layout-1', name: 'A4 Plan',
|
||||||
|
viewport: { center: { x: 50, y: 50 }, scale: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('G4: exportLayoutPDF', () => {
|
||||||
|
it('liefert valides PDF mit genau einer A4-landscape-Seite', async () => {
|
||||||
|
const doc = mkDoc([line('l1', 0, 0, 100, 100)], [LAYOUT]);
|
||||||
|
const bytes = await exportLayoutPDF(doc, 'layout-1');
|
||||||
|
expect(bytes.byteLength).toBeGreaterThan(500);
|
||||||
|
const pdf = await PDFDocument.load(bytes);
|
||||||
|
expect(pdf.getPageCount()).toBe(1);
|
||||||
|
const { width, height } = pdf.getPage(0).getSize();
|
||||||
|
expect(width).toBe(842); // A4 landscape pt
|
||||||
|
expect(height).toBe(595);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Titelblock-Elemente (frameBlockRef) werden mitgezeichnet', async () => {
|
||||||
|
const doc = mkDoc([line('l1', 0, 0, 100, 0)], [LAYOUT]);
|
||||||
|
// Titelblock-Definition anlegen und referenzieren
|
||||||
|
const blockId = 'title-1';
|
||||||
|
doc.addBlock({
|
||||||
|
id: blockId, name: 'Titelblock', description: '', category: 'frame',
|
||||||
|
elements: [line('tb1', 0, 0, 50, 0)],
|
||||||
|
});
|
||||||
|
doc.updateLayout('layout-1', { frameBlockRef: blockId });
|
||||||
|
const bytes = await exportLayoutPDF(doc, 'layout-1');
|
||||||
|
const pdf = await PDFDocument.load(bytes);
|
||||||
|
expect(pdf.getPageCount()).toBe(1);
|
||||||
|
// Strukturelle Prüfung: Export läuft fehlerfrei mit Frame-Block
|
||||||
|
expect(bytes.byteLength).toBeGreaterThan(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('monochrome Option exportiert ohne Farbinformationen zu verlieren', async () => {
|
||||||
|
const doc = mkDoc([line('l1', 0, 0, 100, 100)], [LAYOUT]);
|
||||||
|
const bytes = await exportLayoutPDF(doc, 'layout-1', { monochrome: true });
|
||||||
|
const pdf = await PDFDocument.load(bytes);
|
||||||
|
expect(pdf.getPageCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leeres Layout (keine Elemente im Viewport) exportiert trotzdem eine Seite', async () => {
|
||||||
|
const doc = mkDoc([], [LAYOUT]);
|
||||||
|
const bytes = await exportLayoutPDF(doc, 'layout-1');
|
||||||
|
const pdf = await PDFDocument.load(bytes);
|
||||||
|
expect(pdf.getPageCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Layout ohne existierende ID → throw', async () => {
|
||||||
|
const doc = mkDoc([], []);
|
||||||
|
await expect(exportLayoutPDF(doc, 'gibts-nicht')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsichtbare Layer werden übersprungen (Export läuft fehlerfrei)', async () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
ydoc.data.layers.set('layer-0', {
|
||||||
|
id: 'layer-0', name: '0', visible: false, locked: false,
|
||||||
|
color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
|
||||||
|
});
|
||||||
|
doc.addElement(line('l1', 0, 0, 100, 100));
|
||||||
|
doc.addLayout(LAYOUT);
|
||||||
|
const bytes = await exportLayoutPDF(doc, 'layout-1');
|
||||||
|
const pdf = await PDFDocument.load(bytes);
|
||||||
|
expect(pdf.getPageCount()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -370,14 +370,16 @@ describe('A4.9 pilot: event tools', () => {
|
|||||||
const ctx = makeCtx({ doc });
|
const ctx = makeCtx({ doc });
|
||||||
const block = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-block')!;
|
const block = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-block')!;
|
||||||
|
|
||||||
block.handlers.down!(pe(0, 0), ctx); // Legacy-Default: rows=3, cols=8
|
// E1: Drag-Interaktion (down + up) mit Distanz 200×100, spacing 50
|
||||||
|
block.handlers.down!(pe(0, 0), ctx);
|
||||||
|
block.handlers.up!(pe(200, 100), ctx); // 4 cols × 2 rows = 8 Stühle
|
||||||
const all = doc.getAllElements();
|
const all = doc.getAllElements();
|
||||||
expect(all.length).toBeGreaterThan(1); // Multi-Element-Satz
|
expect(all.length).toBeGreaterThan(1); // Multi-Element-Satz
|
||||||
expect(all.every((e) => e.type === 'chair')).toBe(true);
|
expect(all.every((e) => e.type === 'chair')).toBe(true);
|
||||||
|
|
||||||
// BUG-1-Regressionsschutz: alle Reihen im Block haben eigene rowIds
|
// BUG-1-Regressionsschutz: alle Reihen im Block haben eigene rowIds
|
||||||
const rowIds = new Set(all.map((e) => e.properties.rowId));
|
const rowIds = new Set(all.map((e) => e.properties.rowId));
|
||||||
expect(rowIds.size).toBeGreaterThanOrEqual(3);
|
expect(rowIds.size).toBe(2); // Drag 100mm bei spacing 50 → 2 Reihen
|
||||||
|
|
||||||
// Kern-Akzeptanz: EIN undo entfernt den GANZEN Block
|
// Kern-Akzeptanz: EIN undo entfernt den GANZEN Block
|
||||||
expect(doc.undo()).toBe(true);
|
expect(doc.undo()).toBe(true);
|
||||||
@@ -390,7 +392,9 @@ describe('A4.9 pilot: event tools', () => {
|
|||||||
const ctx = makeCtx({ doc });
|
const ctx = makeCtx({ doc });
|
||||||
const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!;
|
const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!;
|
||||||
|
|
||||||
|
// E1: Drag-Interaktion (down + up statt nur down)
|
||||||
row.handlers.down!(pe(0, 0), ctx);
|
row.handlers.down!(pe(0, 0), ctx);
|
||||||
|
row.handlers.up!(pe(200, 0), ctx); // Länge 200, spacing 50 → 4 Stühle
|
||||||
const all = doc.getAllElements();
|
const all = doc.getAllElements();
|
||||||
expect(all.length).toBeGreaterThanOrEqual(2);
|
expect(all.length).toBeGreaterThanOrEqual(2);
|
||||||
const rowIds = new Set(all.map((e) => e.properties.rowId));
|
const rowIds = new Set(all.map((e) => e.properties.rowId));
|
||||||
@@ -922,3 +926,67 @@ describe('C3fin interaktiv: handle-drag on select tool', () => {
|
|||||||
expect(selIds).toEqual(['r3']);
|
expect(selIds).toEqual(['r3']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('E1: seating-row drag', () => {
|
||||||
|
it('berechnet count aus Laenge und committet in EINER Transaktion', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc, options: { spacing: 50 } });
|
||||||
|
const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!;
|
||||||
|
|
||||||
|
// Start bei (0,0), Ende bei (300,0): Länge 300, spacing 50 → 6 Stühle
|
||||||
|
row.handlers.down!(pe(0, 0), ctx);
|
||||||
|
row.handlers.move!(pe(150, 0), ctx); // Live-Preview
|
||||||
|
expect(ctx.previews.length).toBe(1);
|
||||||
|
row.handlers.up!(pe(300, 0), ctx);
|
||||||
|
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(6);
|
||||||
|
expect(all.every((e) => e.type === 'chair')).toBe(true);
|
||||||
|
// Eine rowId für alle:
|
||||||
|
const rowIds = new Set(all.map((e) => e.properties.rowId));
|
||||||
|
expect(rowIds.size).toBe(1);
|
||||||
|
|
||||||
|
// EIN Undo entfernt die ganze Reihe:
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel verwirft die laufende Reihe', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!;
|
||||||
|
|
||||||
|
row.handlers.down!(pe(0, 0), ctx);
|
||||||
|
row.handlers.cancel!(ctx);
|
||||||
|
row.handlers.up!(pe(100, 0), ctx); // nach cancel: kein dragStart
|
||||||
|
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('E1: seating-block drag', () => {
|
||||||
|
it('berechnet rows×cols aus Distanz und committet Multi-Element-Set', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc, options: { colSpacing: 50, rowSpacing: 50 } });
|
||||||
|
const block = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-block')!;
|
||||||
|
|
||||||
|
// Start (0,0), Ende (150,100): w=150/50=3 cols, h=100/50=2 rows → 6 Stühle
|
||||||
|
block.handlers.down!(pe(0, 0), ctx);
|
||||||
|
block.handlers.up!(pe(150, 100), ctx);
|
||||||
|
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(6);
|
||||||
|
expect(all.every((e) => e.type === 'chair')).toBe(true);
|
||||||
|
|
||||||
|
// BUG-1-Regressionsschutz: rowIds pro Reihe eindeutig
|
||||||
|
const rowIds = new Set(all.map((e) => e.properties.rowId));
|
||||||
|
expect(rowIds.size).toBe(2);
|
||||||
|
|
||||||
|
// EIN Undo entfernt den ganzen Block:
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Task G5 – Browser-Print: @media print Stylesheet + Print-Service.
|
||||||
|
*
|
||||||
|
* printService bereitet den Body auf den Druck vor (print-layout-Klasse,
|
||||||
|
* Print-Modus ohne UI-Chrome), printLayout() triggert window.print nach
|
||||||
|
* Vorbereitung und räumt danach ab. Die CSS-Datei muss einen @media print
|
||||||
|
* Block mit den Kernregeln (UI ausblenden, Canvas füllen, @page)
|
||||||
|
* enthalten — verifiziert als Datei-Assertion (jsdom kann kein print-CSS
|
||||||
|
* rendern).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { preparePrintLayout, cleanupPrintLayout, printLayout } from '../src/services/printService';
|
||||||
|
|
||||||
|
describe('G5: printService', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPrintLayout();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preparePrintLayout setzt print-layout-Klasse am body', () => {
|
||||||
|
document.body.classList.remove('print-layout');
|
||||||
|
preparePrintLayout();
|
||||||
|
expect(document.body.classList.contains('print-layout')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanupPrintLayout entfernt die Klasse wieder', () => {
|
||||||
|
preparePrintLayout();
|
||||||
|
cleanupPrintLayout();
|
||||||
|
expect(document.body.classList.contains('print-layout')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('printLayout ruft window.print nach Vorbereitung und räumt ab (async)', async () => {
|
||||||
|
const printSpy = vi.spyOn(window, 'print').mockImplementation(() => {});
|
||||||
|
await printLayout();
|
||||||
|
expect(printSpy).toHaveBeenCalledTimes(1);
|
||||||
|
// nach dem Druck ist wieder aufgeräumt
|
||||||
|
expect(document.body.classList.contains('print-layout')).toBe(false);
|
||||||
|
printSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('G5: Print-Stylesheet-Regeln (Datei-Assertion)', () => {
|
||||||
|
const css = readFileSync(join(__dirname, '..', 'src', 'styles.css'), 'utf-8');
|
||||||
|
const printBlock = css.slice(css.indexOf('@media print'));
|
||||||
|
|
||||||
|
it('enthält einen @media print Block mit @page A4 landscape', () => {
|
||||||
|
expect(css).toContain('@media print');
|
||||||
|
expect(printBlock).toMatch(/@page\s*\{[^}]*size:\s*A4 landscape/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blendet UI-Chrome im Druck aus (screen-only Regel)', () => {
|
||||||
|
expect(printBlock).toContain('.screen-only');
|
||||||
|
expect(printBlock).toMatch(/\.screen-only\s*\{[^}]*display:\s*none/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Canvas/Druckbereich füllt die Seite (print-area Regel)', () => {
|
||||||
|
expect(printBlock).toContain('.print-area');
|
||||||
|
expect(printBlock).toMatch(/\.print-area\s*\{[^}]*width:\s*100%/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('print-layout-Modus erzwingt Farbdruck-Hintergründe (print-color-adjust)', () => {
|
||||||
|
expect(printBlock).toMatch(/-webkit-print-color-adjust:\s*exact/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Task H2 – Projekt-Templates: JSON-Presets für Branchen-Szenarien.
|
||||||
|
*
|
||||||
|
* Sechs Presets (halle/openair/saal/messe/buero/garten), jedes mit
|
||||||
|
* gridSize/unit, Layer-Set, enabled plugins und library folder seed.
|
||||||
|
* applyProjectTemplate überträgt die Layer in ein CADDocument
|
||||||
|
* (bestehende Layer werden ersetzt — Template = Startzustand).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import {
|
||||||
|
PROJECT_TEMPLATES,
|
||||||
|
getProjectTemplate,
|
||||||
|
applyProjectTemplate,
|
||||||
|
type ProjectTemplate,
|
||||||
|
} from '../src/services/projectTemplates';
|
||||||
|
|
||||||
|
const EXPECTED_IDS = ['halle', 'openair', 'saal', 'messe', 'buero', 'garten'];
|
||||||
|
|
||||||
|
describe('H2: Projekt-Templates', () => {
|
||||||
|
it('enthält genau die 6 Branchen-Presets mit eindeutigen IDs', () => {
|
||||||
|
expect(PROJECT_TEMPLATES.map((t) => t.id).sort()).toEqual([...EXPECTED_IDS].sort());
|
||||||
|
expect(new Set(PROJECT_TEMPLATES.map((t) => t.id)).size).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jedes Preset hat Name, gridSize, unit, Layer (>=2), enabledPlugins und libraryFolderSeed', () => {
|
||||||
|
for (const tpl of PROJECT_TEMPLATES) {
|
||||||
|
expect(tpl.name.length).toBeGreaterThan(2);
|
||||||
|
expect(tpl.gridSize).toBeGreaterThan(0);
|
||||||
|
expect(['mm', 'cm', 'm']).toContain(tpl.unit);
|
||||||
|
expect(tpl.layers.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(tpl.enabledPlugins.length).toBeGreaterThan(0);
|
||||||
|
expect(tpl.libraryFolderSeed.length).toBeGreaterThan(2);
|
||||||
|
// Layer haben gültige CADLayer-Struktur mit eindeutigen IDs
|
||||||
|
const layerIds = new Set(tpl.layers.map((l) => l.id));
|
||||||
|
expect(layerIds.size).toBe(tpl.layers.length);
|
||||||
|
expect(tpl.layers.every((l) => l.name.length > 0 && typeof l.visible === 'boolean')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Szenario-spezifika: halle/openair/saal aktivieren event-tools, garten landscape', () => {
|
||||||
|
const halle = getProjectTemplate('halle')!;
|
||||||
|
expect(halle.enabledPlugins).toContain('event-tools');
|
||||||
|
const openair = getProjectTemplate('openair')!;
|
||||||
|
expect(openair.enabledPlugins).toContain('event-tools');
|
||||||
|
const saal = getProjectTemplate('saal')!;
|
||||||
|
expect(saal.enabledPlugins).toContain('event-tools');
|
||||||
|
const garten = getProjectTemplate('garten')!;
|
||||||
|
expect(garten.libraryFolderSeed.toLowerCase()).toContain('garten');
|
||||||
|
const buero = getProjectTemplate('buero')!;
|
||||||
|
expect(buero.unit).toBe('mm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getProjectTemplate liefert undefined für unbekannte ID', () => {
|
||||||
|
expect(getProjectTemplate('gibts-nicht')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('H2: applyProjectTemplate', () => {
|
||||||
|
it('setzt die Template-Layer im CADDocument (ersetzt bestehende)', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
// Bestehenden Layer simulieren
|
||||||
|
ydoc.data.layers.set('alt', {
|
||||||
|
id: 'alt', name: 'Alt', visible: true, locked: false,
|
||||||
|
color: '#fff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
|
||||||
|
});
|
||||||
|
const tpl = getProjectTemplate('halle')!;
|
||||||
|
applyProjectTemplate(doc, tpl);
|
||||||
|
const layers = doc.getAllLayers();
|
||||||
|
expect(layers.map((l) => l.id)).toEqual(tpl.layers.map((l) => l.id));
|
||||||
|
expect(layers.find((l) => l.id === 'alt')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Template-Layer sind danach über CADDocument lesbar', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const tpl = getProjectTemplate('buero')!;
|
||||||
|
applyProjectTemplate(doc, tpl);
|
||||||
|
const first = doc.getLayer(tpl.layers[0].id);
|
||||||
|
expect(first).toBeDefined();
|
||||||
|
expect(first!.name).toBe(tpl.layers[0].name);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -82,3 +82,29 @@ describe('countSeats', () => {
|
|||||||
expect(Object.keys(result.byRow).length).toBe(1);
|
expect(Object.keys(result.byRow).length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('exportSeatListCSV', () => {
|
||||||
|
const svc = new SeatingService();
|
||||||
|
|
||||||
|
it('erzeugt CSV mit Header und Stuhl-Zeilen', () => {
|
||||||
|
const els = svc.createSeatingRow(0, 0, 'layer-1', { count: 3 });
|
||||||
|
const csv = svc.exportSeatListCSV(els);
|
||||||
|
const lines = csv.split('\n');
|
||||||
|
expect(lines[0]).toBe('type,row_id,block_id,x,y');
|
||||||
|
expect(lines.length).toBe(4); // Header + 3 Stühle
|
||||||
|
expect(lines[1]).toContain('chair');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert Nicht-Stuhl-Elemente', () => {
|
||||||
|
const chairs = svc.createSeatingRow(0, 0, 'layer-1', { count: 2 });
|
||||||
|
const other: CADElement = {
|
||||||
|
id: 'x1', type: 'rect', layerId: 'l', x: 0, y: 0, width: 1, height: 1, properties: {},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
const csv = svc.exportSeatListCSV([...chairs, other]);
|
||||||
|
expect(csv.split('\n').length).toBe(3); // Header + 2 Stühle
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leere Eingabe ergibt nur Header', () => {
|
||||||
|
expect(svc.exportSeatListCSV([])).toBe('type,row_id,block_id,x,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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Task D2 – Spline-Tool (CV-basiert): Kontrollpunkt-Klick-Sequenz,
|
||||||
|
* ENTER committet ab 3 CVs. Render als interpolierte Polyline mit
|
||||||
|
* Catmull-Rom-Glättung (Kurve läuft DURCH alle CVs), CVs in
|
||||||
|
* properties.controlPoints gespeichert (DXF-SPLINE-Export-ready).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { splineTool } from '../src/plugins/builtin/core-drawing';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function mkCtx(options: Record<string, unknown> = {}) {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const previews: (CADElement | null)[] = [];
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const ctx: any = {
|
||||||
|
doc,
|
||||||
|
options,
|
||||||
|
setStatus: (m: string) => statuses.push(m),
|
||||||
|
setPreview: (el: CADElement | null) => previews.push(el),
|
||||||
|
};
|
||||||
|
return { ctx, doc, previews, statuses };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pe(x: number, y: number): { world: Pt } {
|
||||||
|
return { world: { x, y } } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
splineTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D2: splineTool', () => {
|
||||||
|
it('4 CVs + ENTER erzeugt EINE geglaettete Polyline mit mehr Punkten als CVs', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
const cvs = [[0, 0], [100, 100], [200, 0], [300, 100]];
|
||||||
|
for (const [x, y] of cvs) splineTool.handlers.down!(pe(x, y), ctx);
|
||||||
|
const committed = splineTool.handlers.key!({ key: 'Enter' } as never, ctx);
|
||||||
|
expect(committed).toBe(true);
|
||||||
|
const els = doc.getAllElements();
|
||||||
|
expect(els).toHaveLength(1);
|
||||||
|
const el = els[0];
|
||||||
|
expect(el.type).toBe('polyline');
|
||||||
|
const pts = el.properties.points as Pt[];
|
||||||
|
expect(pts.length).toBeGreaterThan(8); // geglaettet, nicht nur CVs
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Kurve interpoliert DURCH die CVs: Start/Ende exakt, Zwischen-CVs liegen auf der Kurve', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
for (const [x, y] of [[0, 0], [100, 100], [200, 0], [300, 100]]) {
|
||||||
|
splineTool.handlers.down!(pe(x, y), ctx);
|
||||||
|
}
|
||||||
|
splineTool.handlers.key!({ key: 'Enter' } as never, ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
const pts = el.properties.points as Pt[];
|
||||||
|
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
const last = pts[pts.length - 1];
|
||||||
|
expect(last.x).toBeCloseTo(300, 5);
|
||||||
|
expect(last.y).toBeCloseTo(100, 5);
|
||||||
|
// Zwischenpunkt (100,100) muss auf der Kurve liegen (Catmull-Rom interpoliert):
|
||||||
|
const near = pts.find((p) => Math.abs(p.x - 100) < 1.5 && Math.abs(p.y - 100) < 1.5);
|
||||||
|
expect(near).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CVs werden in properties.controlPoints gespeichert', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
for (const [x, y] of [[0, 0], [50, 80], [120, 40]]) {
|
||||||
|
splineTool.handlers.down!(pe(x, y), ctx);
|
||||||
|
}
|
||||||
|
splineTool.handlers.key!({ key: 'Enter' } as never, ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
const cvs = el.properties.controlPoints as Pt[];
|
||||||
|
expect(cvs).toHaveLength(3);
|
||||||
|
expect(cvs[1]).toEqual({ x: 50, y: 80 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Glättung: Kurve weicht von der CV-Sehnen-Linie ab (keine Polyline durch CVs)', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
// S-artige CVs: (0,0) (0,100) (100,100) (100,0) — Catmull-Rom schwingt um die Ecken
|
||||||
|
for (const [x, y] of [[0, 0], [0, 100], [100, 100], [100, 0]]) {
|
||||||
|
splineTool.handlers.down!(pe(x, y), ctx);
|
||||||
|
}
|
||||||
|
splineTool.handlers.key!({ key: 'Enter' } as never, ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
const pts = el.properties.points as Pt[];
|
||||||
|
// Es muss Punkte geben, die ausserhalb der BBox der Sehnen-Verbindung liegen
|
||||||
|
// (Glattung schwingt): minY < 0 oder maxY > 100 an irgendeiner Stelle
|
||||||
|
const ys = pts.map((p) => p.y);
|
||||||
|
const swingsBelow = Math.min(...ys) < -0.5;
|
||||||
|
const swingsAbove = Math.max(...ys) > 100.5;
|
||||||
|
expect(swingsBelow || swingsAbove).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ENTER mit weniger als 3 CVs committet NICHT (Status erklaert Mindestzahl)', () => {
|
||||||
|
const { ctx, doc, statuses } = mkCtx();
|
||||||
|
splineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
splineTool.handlers.down!(pe(50, 50), ctx);
|
||||||
|
const consumed = splineTool.handlers.key!({ key: 'Enter' } as never, ctx);
|
||||||
|
expect(doc.getAllElements()).toHaveLength(0);
|
||||||
|
expect(statuses[statuses.length - 1]).toContain('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Nicht-ENTER-Keys werden nicht konsumiert (return false)', () => {
|
||||||
|
const { ctx } = mkCtx();
|
||||||
|
splineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
const consumed = splineTool.handlers.key!({ key: 'a' } as never, ctx);
|
||||||
|
expect(consumed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Preview bei move ab 1 CV', () => {
|
||||||
|
const { ctx, previews } = mkCtx();
|
||||||
|
splineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
splineTool.handlers.move!(pe(50, 50), ctx);
|
||||||
|
expect(previews.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const last = previews[previews.length - 1];
|
||||||
|
expect(last).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel resettet: naechste Sitzung startet frisch', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
splineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
splineTool.handlers.down!(pe(50, 50), ctx);
|
||||||
|
splineTool.handlers.cancel?.(ctx);
|
||||||
|
splineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
splineTool.handlers.down!(pe(50, 50), ctx);
|
||||||
|
splineTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
splineTool.handlers.key!({ key: 'Enter' } as never, ctx);
|
||||||
|
// 3 CVs der NEUEN Sitzung — controlPoints == 3 (alte 2 nicht gemischt)
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
expect((el.properties.controlPoints as Pt[]).length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Status zaehlt CVs mit', () => {
|
||||||
|
const { ctx, statuses } = mkCtx();
|
||||||
|
splineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
expect(statuses[0]).toContain('1');
|
||||||
|
expect(statuses[0]).toContain('Spline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Manifest: id=spline, ribbonTab=canvas, tags basic', () => {
|
||||||
|
expect(splineTool.manifest.id).toBe('spline');
|
||||||
|
expect(splineTool.manifest.ribbonTab).toBe('canvas');
|
||||||
|
expect((splineTool.manifest.tags ?? [])).toContain('basic');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Task F8 – SVG-Import als Block: Geometrie-Konvertierung.
|
||||||
|
*
|
||||||
|
* Path-Parser (M/L/H/V/C/S/Q/T/A/Z, relative + absolute), Bézier-Flattening,
|
||||||
|
* Arc→Polyline, Konventions-Fixes (rect=Zentrum, circle=cx/cy direkt),
|
||||||
|
* ellipse→Polyline statt verstümmeltem Kreis, sowie Block-Payload-Erzeugung
|
||||||
|
* (SVG → Element-Array + Thumbnail) für die globale Bibliothek.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { importSVG, importSVGAsBlockPayload } from '../src/services/importService';
|
||||||
|
|
||||||
|
describe('F8: SVG path → Polyline-Approximation', () => {
|
||||||
|
it('M + L + Z erzeugt geschlossene Polyline mit exakten Punkten', () => {
|
||||||
|
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 L 100 0 L 100 100 Z" stroke="#fff" fill="none"/></svg>');
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
const poly = res.elements.find((e) => e.type === 'polygon');
|
||||||
|
expect(poly).toBeDefined();
|
||||||
|
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
|
||||||
|
expect(pts.length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(pts[1]).toEqual({ x: 100, y: 0 });
|
||||||
|
expect(pts[2]).toEqual({ x: 100, y: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kleine Befehle sind relativ (m/l): Startpunkt wirkt', () => {
|
||||||
|
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="m 10 10 l 20 0 l 0 20 z"/></svg>');
|
||||||
|
const poly = res.elements.find((e) => e.type === 'polygon');
|
||||||
|
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
|
||||||
|
expect(pts[0]).toEqual({ x: 10, y: 10 });
|
||||||
|
expect(pts[1]).toEqual({ x: 30, y: 10 });
|
||||||
|
expect(pts[2]).toEqual({ x: 30, y: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kubischer Bézier (C) wird geflattet: Kurvenpunkte + Endpunkt exakt', () => {
|
||||||
|
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 C 50 100 100 0 100 100"/></svg>');
|
||||||
|
const poly = res.elements.find((e) => e.type === 'polyline');
|
||||||
|
expect(poly).toBeDefined();
|
||||||
|
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
|
||||||
|
expect(pts.length).toBeGreaterThan(10); // geflattet, nicht nur Endpunkte
|
||||||
|
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(pts[pts.length - 1]).toEqual({ x: 100, y: 100 });
|
||||||
|
// Mittelpunkt der Kubik-Bézier bei t=0.5: exakt berechenbar
|
||||||
|
expect(pts[Math.floor(pts.length / 2)].y).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('H/V-Befehle (horizontal/vertical) funktionieren', () => {
|
||||||
|
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 H 50 V 30 L 10 30 Z"/></svg>');
|
||||||
|
const poly = res.elements.find((e) => e.type === 'polygon');
|
||||||
|
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
|
||||||
|
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(pts[1]).toEqual({ x: 50, y: 0 });
|
||||||
|
expect(pts[2]).toEqual({ x: 50, y: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Arc (A) wird approximiert: Halbkreis über (100,0) mit korrektem Bogenverlauf', () => {
|
||||||
|
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 A 50 50 0 0 1 100 0"/></svg>');
|
||||||
|
const poly = res.elements.find((e) => e.type === 'polyline');
|
||||||
|
expect(poly).toBeDefined();
|
||||||
|
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
|
||||||
|
expect(pts.length).toBeGreaterThan(8);
|
||||||
|
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(pts[pts.length - 1]).toEqual({ x: 100, y: 0 });
|
||||||
|
// Scheitel des Halbkreises: (50, -50) bei sweep=1 (y-up im Bogen oben)
|
||||||
|
const maxY = Math.max(...pts.map((p) => p.y));
|
||||||
|
const minY = Math.min(...pts.map((p) => p.y));
|
||||||
|
expect(minY).toBeLessThan(-40); // Bogen wölbt sich nach oben (negativ)
|
||||||
|
expect(maxY).toBeCloseTo(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Mehrfach-Subpaths ergeben mehrere Elemente', () => {
|
||||||
|
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 L 10 0 M 100 100 L 110 100"/></svg>');
|
||||||
|
const polys = res.elements.filter((e) => e.type === 'polyline');
|
||||||
|
expect(polys.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F8: Konventions-Fixes', () => {
|
||||||
|
it('rect: x/y = Zentrum (App-Konvention, nicht Ecke)', () => {
|
||||||
|
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="100" height="50"/></svg>');
|
||||||
|
const rect = res.elements.find((e) => e.type === 'rect');
|
||||||
|
expect(rect).toBeDefined();
|
||||||
|
expect(rect!.x).toBe(50);
|
||||||
|
expect(rect!.y).toBe(25);
|
||||||
|
expect(rect!.width).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('circle: el.x/el.y = Zentrum direkt (Renderer-Konvention)', () => {
|
||||||
|
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><circle cx="30" cy="40" r="10"/></svg>');
|
||||||
|
const circ = res.elements.find((e) => e.type === 'circle');
|
||||||
|
expect(circ).toBeDefined();
|
||||||
|
expect(circ!.x).toBe(30);
|
||||||
|
expect(circ!.y).toBe(40);
|
||||||
|
expect(circ!.properties.radius).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ellipse → geschlossenes Polygon mit rx/ry korrekt (nicht verstümmelter Kreis)', () => {
|
||||||
|
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><ellipse cx="50" cy="50" rx="100" ry="40"/></svg>');
|
||||||
|
const poly = res.elements.find((e) => e.type === 'polygon');
|
||||||
|
expect(poly).toBeDefined();
|
||||||
|
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
|
||||||
|
expect(pts.length).toBeGreaterThanOrEqual(32);
|
||||||
|
expect(Math.max(...pts.map((p) => p.x))).toBeCloseTo(150, 5);
|
||||||
|
expect(Math.min(...pts.map((p) => p.x))).toBeCloseTo(-50, 5);
|
||||||
|
expect(Math.max(...pts.map((p) => p.y))).toBeCloseTo(90, 5);
|
||||||
|
expect(Math.min(...pts.map((p) => p.y))).toBeCloseTo(10, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('F8: SVG als Block-Payload', () => {
|
||||||
|
it('importSVGAsBlockPayload liefert Element-Array als JSON-String + svg als Thumbnail', () => {
|
||||||
|
const payload = importSVGAsBlockPayload('<svg xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="80" height="40"/></svg>');
|
||||||
|
expect(payload.blockData).toBeTruthy();
|
||||||
|
const els = JSON.parse(payload.blockData);
|
||||||
|
expect(Array.isArray(els)).toBe(true);
|
||||||
|
expect(els.length).toBe(1);
|
||||||
|
expect(els[0].type).toBe('rect');
|
||||||
|
expect(payload.svg).toContain('<svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leeres SVG ohne Geometrie liefert null blockData (Caller nutzt {type:svg}-Fallback)', () => {
|
||||||
|
const payload = importSVGAsBlockPayload('<svg xmlns="http://www.w3.org/2000/svg"><defs/></svg>');
|
||||||
|
expect(payload.blockData).toBeNull();
|
||||||
|
expect(payload.svg).toContain('<svg');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Task G3 – Titelblock: Blockdefinition mit attributierten Texten
|
||||||
|
* (Projekt/Datum/Maßstab/Autor) + auto-insert als Layout-Frame.
|
||||||
|
*
|
||||||
|
* Die Text-Elemente tragen tag/attdef-Metadaten (F5/F6-Konvention),
|
||||||
|
* sodass der Titelblock als DXF ATTDEF/ATTRIB roundtripped und das
|
||||||
|
* spätere PropertiesPanel (D12) das Formular daraus bauen kann.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import {
|
||||||
|
createTitleBlockElements,
|
||||||
|
insertTitleBlockForLayout,
|
||||||
|
TITLE_BLOCK_NAME,
|
||||||
|
type TitleBlockMeta,
|
||||||
|
} from '../src/services/titleBlockService';
|
||||||
|
import type { LayoutDefinition } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
const META: TitleBlockMeta = {
|
||||||
|
project: 'Musterhalle A4',
|
||||||
|
date: '2026-08-29',
|
||||||
|
scale: '1:100',
|
||||||
|
author: 'L. Admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mkDoc(): CADDocument {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
return new CADDocument(ydoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYOUT: LayoutDefinition = {
|
||||||
|
id: 'layout-1',
|
||||||
|
name: 'A4 Plan',
|
||||||
|
viewport: { center: { x: 0, y: 0 }, scale: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('G3: createTitleBlockElements', () => {
|
||||||
|
it('Rahmen + Titel + 4 attributierte Texte mit Standard-Tags', () => {
|
||||||
|
const els = createTitleBlockElements(META);
|
||||||
|
const rects = els.filter((e) => e.type === 'rect');
|
||||||
|
const texts = els.filter((e) => e.type === 'text');
|
||||||
|
expect(rects.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(texts.length).toBe(5); // Titel + PROJEKT + DATUM + MASSSTAB + AUTOR
|
||||||
|
const tags = texts.map((t) => t.properties.tag).filter(Boolean);
|
||||||
|
expect(tags).toEqual(['PROJEKT', 'DATUM', 'MASSSTAB', 'AUTOR']);
|
||||||
|
// Nur die attributierten Texte tragen attdef (Titel-Label bewusst nicht):
|
||||||
|
const attrTexts = texts.filter((t) => t.properties.tag);
|
||||||
|
expect(attrTexts.every((t) => t.properties.attdef === true)).toBe(true);
|
||||||
|
// Werte stehen im Text
|
||||||
|
const proj = texts.find((t) => t.properties.tag === 'PROJEKT');
|
||||||
|
expect(String(proj!.properties.text)).toContain('Musterhalle A4');
|
||||||
|
const autor = texts.find((t) => t.properties.tag === 'AUTOR');
|
||||||
|
expect(String(autor!.properties.text)).toContain('L. Admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Texte folgen der tag=Wert-Konvention des F5-ATTDEF-Parsers', () => {
|
||||||
|
const els = createTitleBlockElements(META);
|
||||||
|
const scale = els.find((e) => e.properties.tag === 'MASSSTAB')!;
|
||||||
|
expect(String(scale.properties.text)).toBe('MASSSTAB=1:100');
|
||||||
|
expect(scale.properties.prompt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leere Meta erzeugt Füllzeichen statt undefined', () => {
|
||||||
|
const els = createTitleBlockElements({});
|
||||||
|
const proj = els.find((e) => e.properties.tag === 'PROJEKT')!;
|
||||||
|
expect(String(proj.properties.text)).toBe('PROJEKT=—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('G3: insertTitleBlockForLayout (auto-insert als Frame)', () => {
|
||||||
|
it('erzeugt Blockdefinition im Doc und setzt frameBlockRef im Layout', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
doc.addLayout(LAYOUT);
|
||||||
|
const blockId = insertTitleBlockForLayout(doc, 'layout-1', META);
|
||||||
|
expect(typeof blockId).toBe('string');
|
||||||
|
const layout = doc.getLayout('layout-1');
|
||||||
|
expect(layout!.frameBlockRef).toBe(blockId);
|
||||||
|
const block = doc.getBlock(blockId);
|
||||||
|
expect(block).toBeDefined();
|
||||||
|
expect(block!.name).toBe(TITLE_BLOCK_NAME);
|
||||||
|
expect(block!.category).toBe('frame');
|
||||||
|
expect(block!.elements.length).toBe(6); // 1 rect + 5 text
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vorhandener frameBlockRef wird ersetzt (kein Duplikat-Block je Re-Insert)', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
doc.addLayout(LAYOUT);
|
||||||
|
const first = insertTitleBlockForLayout(doc, 'layout-1', META);
|
||||||
|
const second = insertTitleBlockForLayout(doc, 'layout-1', { ...META, project: 'Neu' });
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(doc.getLayout('layout-1')!.frameBlockRef).toBe(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Layout ohne frameBlockRef bleibt unverändert bei fehlendem Layout (throws)', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
expect(() => insertTitleBlockForLayout(doc, 'gibts-nicht', META)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Task H1 – simple/pro Mode.
|
||||||
|
*
|
||||||
|
* uiModeService: persistierter UI-Modus ('simple' | 'pro', Default 'pro'),
|
||||||
|
* localStorage-Persistenz, Subscribe für Live-Updates.
|
||||||
|
* RibbonBar filtert im simple-Modus auf tools mit tags 'basic'.
|
||||||
|
* CommandLine ist pro-only (im simple-Modus versteckt).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
getUIMode, setUIMode, subscribeUIMode, UIMode,
|
||||||
|
} from '../src/services/uiModeService';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
// ─── uiModeService ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('H1: uiModeService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Default ist pro (Vollständige Werkzeugpalette)', () => {
|
||||||
|
expect(getUIMode()).toBe('pro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setUIMode simple persistiert in localStorage', () => {
|
||||||
|
setUIMode('simple');
|
||||||
|
expect(getUIMode()).toBe('simple');
|
||||||
|
expect(localStorage.getItem('webcad.uiMode')).toBe('simple');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Persistiert über Reload hinweg (localStorage gesetzt vor erstem get)', () => {
|
||||||
|
localStorage.setItem('webcad.uiMode', 'simple');
|
||||||
|
expect(getUIMode()).toBe('simple');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribeUIMode feuert bei Wechsel und unsubscribed korrekt', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
const off = subscribeUIMode(cb);
|
||||||
|
setUIMode('simple');
|
||||||
|
expect(cb).toHaveBeenCalledWith('simple');
|
||||||
|
setUIMode('pro');
|
||||||
|
expect(cb).toHaveBeenCalledWith('pro');
|
||||||
|
off();
|
||||||
|
cb.mockClear();
|
||||||
|
setUIMode('simple');
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
// Aufräumen für weitere Tests
|
||||||
|
setUIMode('pro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ungültiger localStorage-Wert fällt auf pro zurück', () => {
|
||||||
|
localStorage.setItem('webcad.uiMode', 'gibts-nicht');
|
||||||
|
expect(getUIMode()).toBe('pro');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── RibbonBar simple-Filter ─────────────────────────────
|
||||||
|
|
||||||
|
describe('H1: RibbonBar simple-Filter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
setUIMode('pro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pro-Modus: alle V2-Tools sichtbar (basic + pro-Tags)', async () => {
|
||||||
|
const { registerBuiltinPlugins, pluginRegistry } = await import('../src/plugins');
|
||||||
|
registerBuiltinPlugins();
|
||||||
|
pluginRegistry.initDefaults();
|
||||||
|
const RibbonBar = (await import('../src/components/RibbonBar')).default;
|
||||||
|
render(<RibbonBar activeTab="canvas" onTabChange={() => {}} onAction={() => {}} />);
|
||||||
|
const all = screen.getAllByRole('button').length;
|
||||||
|
expect(all).toBeGreaterThan(10);
|
||||||
|
cleanup();
|
||||||
|
setUIMode('simple');
|
||||||
|
render(<RibbonBar activeTab="canvas" onTabChange={() => {}} onAction={() => {}} />);
|
||||||
|
const simpleCount = screen.getAllByRole('button').length;
|
||||||
|
expect(simpleCount).toBeLessThan(all);
|
||||||
|
expect(simpleCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('simple-Modus: pro-Tools (rotate/scale/mirror) fehlen, basic (line) bleibt', async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
setUIMode('simple');
|
||||||
|
const { registerBuiltinPlugins, pluginRegistry } = await import('../src/plugins');
|
||||||
|
registerBuiltinPlugins();
|
||||||
|
pluginRegistry.initDefaults();
|
||||||
|
const RibbonBar = (await import('../src/components/RibbonBar')).default;
|
||||||
|
render(<RibbonBar activeTab="canvas" onTabChange={() => {}} onAction={() => {}} />);
|
||||||
|
const html = document.body.innerHTML;
|
||||||
|
expect(html).toContain('data-v2tool="line"');
|
||||||
|
expect(html).not.toContain('data-v2tool="rotate"');
|
||||||
|
expect(html).not.toContain('data-v2tool="mirror"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CommandLine-Komponente: pro-only rendering via useUIMode', async () => {
|
||||||
|
const { useUIMode } = await import('../src/services/uiModeService');
|
||||||
|
const CommandLine = (await import('../src/components/CommandLine')).default;
|
||||||
|
setUIMode('pro');
|
||||||
|
const { container: proContainer } = render(<CommandLine history={[]} onCommand={() => {}} />);
|
||||||
|
expect(proContainer.querySelector('.cmdline')).toBeTruthy();
|
||||||
|
cleanup();
|
||||||
|
setUIMode('simple');
|
||||||
|
const { container: simpleContainer } = render(<CommandLine history={[]} onCommand={() => {}} />);
|
||||||
|
expect(simpleContainer.querySelector('.cmdline')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Topbar-Toggle wechselt Mode', async () => {
|
||||||
|
const Topbar = (await import('../src/components/Topbar')).default;
|
||||||
|
setUIMode('pro');
|
||||||
|
render(<Topbar projectName="Testprojekt" savedStatus="Gespeichert" onUndo={() => {}} onRedo={() => {}} onThemeToggle={() => {}} theme="dark" />);
|
||||||
|
const btn = screen.getByTitle(/Modus.*wechseln|Einfach.*Modus|Profi-Modus/i);
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(getUIMode()).toBe('simple');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user