Compare commits

...

143 Commits

Author SHA1 Message Date
Agent Zero a20731a3f5 task(I1): perf gate in CI e2e command, docs roadmap log I1 2026-08-29 03:38:09 +02:00
Agent Zero c6fd8ea445 task(I1): CI performance gate (10k interaction budget), typecheck steps for both suites 2026-08-29 03:37:49 +02:00
Agent Zero 8845afbf6f docs: roadmap log I2 release documentation, next step I1 2026-08-29 03:37:01 +02:00
Agent Zero 7eedddbc3f docs(I2): plugin guide (API v2 + example), library spec (wcadlib JSON+ZIP), changelog 2.0, readme rewrite 2026-08-29 03:36:45 +02:00
Agent Zero a3d3732f85 docs: roadmap log D12 block attributes, phase D complete, next step I2 2026-08-29 03:35:59 +02:00
Agent Zero 9ca86d67dd task(D12): block attributes - BlockAttrDef type, attrDefs on definitions, instance attrValues rendering as text primitives, properties panel attribute form 2026-08-29 03:35:36 +02:00
Agent Zero 7b207d8e39 docs: roadmap log D11 snap extensions, next step D12 2026-08-29 03:33:11 +02:00
Agent Zero c4e3206932 task(D11): snap extensions - quadrant (4 circle quadrants), perpendicular (foot point from refPoint), tangent (both tangent points via acos), fromOffset helper 2026-08-29 03:32:42 +02:00
Agent Zero 3c806437d6 docs: roadmap log D10 array-path, next step D11 2026-08-29 03:29:28 +02:00
Agent Zero 227d617bb1 task(D10): array-path tool - distribute element copies evenly along polyline path in one undo transaction, count option 2026-08-29 03:29:05 +02:00
Agent Zero eeaa7806c6 docs: roadmap log D9 grip editing, next step D10 array-path 2026-08-29 03:26:12 +02:00
Agent Zero c6f38eee5b task(D9): grip editing - vertex/center grips in select tool (line endpoints, polyline vertices, circle/arc center), drag modifies single point in one undo transaction, corner scaling preserved 2026-08-29 03:25:38 +02:00
Agent Zero 68b66ba190 docs: roadmap log D4 mtext tool, next step D9 2026-08-29 03:22:27 +02:00
Agent Zero fbc4978a40 task(D4): mtext tool - multiline text with line breaks, optional frame rect in same transaction (1 undo), fontSize option 2026-08-29 03:21:49 +02:00
Agent Zero b8b67ec7ec docs: roadmap log D3 construction tools, next step D4 2026-08-29 03:18:05 +02:00
Agent Zero bfd500bbc2 task(D3): construction tools - point placer, xline (bidirectional), ray (unidirectional) on auto-created defpoints layer with construction tags 2026-08-29 03:17:38 +02:00
Agent Zero 1664404948 docs: roadmap log D2 spline tool, next step D3 2026-08-29 03:15:10 +02:00
Agent Zero fa3dc7d7e1 task(D2): spline tool - CV-based Catmull-Rom interpolation through all control points, ENTER commit from 3 CVs, controlPoints metadata 2026-08-29 03:14:45 +02:00
Agent Zero 40fa7891d5 docs: roadmap log D1 ellipse tool, next step D2 2026-08-29 03:12:38 +02:00
Agent Zero 5db421da5e task(D1): ellipse tool - 3-click center/radiusX/radiusY with rotation from drag direction, full ellipse as 64-point polygon, arc via start/end angle options 2026-08-29 03:12:12 +02:00
Agent Zero 5cfd29cf1a docs: roadmap log H5 polygon area, phase H complete, next step D1 2026-08-29 03:09:58 +02:00
Agent Zero b210ccc5b8 task(H5): polygon area calculation - shoelace polygonArea, formatArea with quadratic scaling, measure-area tool, properties panel area display 2026-08-29 03:09:35 +02:00
Agent Zero 21e18b81d8 docs: roadmap log H4 i18n foundation, next step H5 2026-08-29 03:05:48 +02:00
Agent Zero e6fdb39a86 task(H4): i18n foundation - strings.ts de/en maps, t() with key fallback, language persistence, useT hook, LayoutBar + Topbar texts switched 2026-08-29 03:05:27 +02:00
Agent Zero 10eacdeaf5 docs: roadmap log H3 library admin guard, next step H4 2026-08-29 03:02:24 +02:00
Agent Zero d4030400b4 task(H3): library admin role guard - requireLibraryAdmin on all 8 global library write routes, read/export stays open for authenticated users 2026-08-29 03:01:55 +02:00
Agent Zero e3fde5012e docs: roadmap log H2 project templates, next step H3 2026-08-29 02:58:18 +02:00
Agent Zero 131a922390 task(H2): project templates - 6 branch presets (halle/openair/saal/messe/buero/garten) with grid/layers/plugins/library seed, replaceLayers 2026-08-29 02:57:59 +02:00
Agent Zero 2b9a3bef16 docs: roadmap log H1 simple pro mode, next step H2 2026-08-29 02:56:12 +02:00
Agent Zero 8774fea773 task(H1): simple/pro mode - uiModeService with localStorage persistence, ribbon basic-tag filter, commandline pro-only, topbar toggle 2026-08-29 02:55:53 +02:00
Agent Zero ed71100178 docs: roadmap log G5 print stylesheet, phase G complete, next step H1 2026-08-29 02:52:38 +02:00
Agent Zero d9a0dffb05 task(G5): browser print stylesheet - @media print A4 landscape, screen-only chrome hidden, print-area fill, print service with cleanup 2026-08-29 02:52:12 +02:00
Agent Zero a093630d34 docs: roadmap log G4 layout pdf export, next step G5 2026-08-29 02:50:32 +02:00
Agent Zero cb23638610 task(G4): layout PDF export - viewport-scaled A4 landscape, elementToPrimitives rendering, layer line widths + dash patterns, monochrome option, title block inclusion 2026-08-29 02:50:05 +02:00
Agent Zero f6b5046a60 docs: roadmap log G3 title block, next step G4 2026-08-29 02:47:11 +02:00
Agent Zero bbb469e25e task(G3): title block with attributes - frame + attributed texts (ATTDEF convention), auto-insert as layout frame block 2026-08-29 02:46:51 +02:00
Agent Zero 1eb6a99025 docs: roadmap log G2 layout bar, next step G3 2026-08-29 02:44:15 +02:00
Agent Zero 7feed9e9e3 task(G2): layout bar UI + viewport minimap widget, zoomPan setView, canvas layout bridge 2026-08-29 02:43:51 +02:00
Agent Zero d4e1b781a6 docs: roadmap log G1 layout data model, next step G2 2026-08-29 02:40:07 +02:00
Agent Zero e750a45d7a task(G1): layout data model - LayoutDefinition type, Yjs layouts map, undo-tracked CRUD in CADDocument 2026-08-29 02:39:30 +02:00
Agent Zero d51111fc2c docs: roadmap log E7 event polish, phase E complete, next step G1 2026-08-29 02:36:37 +02:00
Agent Zero 9176d7c9a4 task(E7): event elements polish - curtain (wavy polyline), spotlight (cone polygon with angle/color), barrier (chain circles), stage height attribute 2026-08-29 02:36:20 +02:00
Agent Zero f4dbb8caaa docs: roadmap phase D verification - D5/D6/D7/D8 confirmed implemented, D10 split, open items listed 2026-08-29 02:31:55 +02:00
Agent Zero c6b7881bae docs: roadmap log F8 svg import, phase F complete, next phase D verification 2026-08-29 02:31:25 +02:00
Agent Zero 09948bc587 task(F8): SVG import as block - path parser (M/L/H/V/C/S/Q/T/A/Z, bezier flattening, arc endpoint-center), rect/circle convention fixes, ellipse polygon, geometry payload for library 2026-08-29 02:31:02 +02:00
Agent Zero 9013d72c03 docs: roadmap log F7 wcadlib zip package, next step F8 2026-08-29 02:25:42 +02:00
Agent Zero d311820351 task(F7): wcadlib as real zip package - jszip export-zip/import-zip endpoints with manifest/blocks/thumbs, frontend zip buttons 2026-08-29 02:25:10 +02:00
Agent Zero bdf3baf4bd docs: roadmap log F6 DXF writer, next step F7 2026-08-29 01:32:58 +02:00
Agent Zero 75de6dd1f7 task(F6): DXF writer - BLOCKS+INSERTS with name mapping, ATTDEF/ATTRIB/SEQEND, AC1009/AC1015 versions, R12 POLYLINE, center/angle convention fixes 2026-08-29 01:32:36 +02:00
Agent Zero 3c2f015acf docs: roadmap log F5 DXF parser extension, next step F6 2026-08-29 01:17:41 +02:00
Agent Zero 6857694f64 task(F5): DXF parser - ELLIPSE/SPLINE polyline approximation, MTEXT, POINT, ATTDEF, MINSERT arrays, structured skippedEntities report 2026-08-29 01:17:21 +02:00
Agent Zero a9c3ba786b docs: roadmap log F4 library providers, next step F5 2026-08-28 23:20:30 +02:00
Agent Zero 2efbbf9bd2 task(F4): library providers - builtin catalogs (event/architecture/landscape 15 blocks each), user-library and global-library via LibraryProviderExtension, panel UI with existing Drop-Channel 2026-08-28 23:20:11 +02:00
Agent Zero ec7cb4043b docs: roadmap log id-collision task closed, status BE 267/267 2026-08-28 13:43:25 +02:00
Agent Zero c78d8d8334 task(ids): replace remaining Date.now() auto-ids with randomUUID - fixes BUG-1 collision class across 9 entities 2026-08-28 13:42:46 +02:00
Agent Zero 7c94de1590 docs: roadmap log F3 save-selection-as-block, next step F4 2026-08-28 13:38:46 +02:00
Agent Zero 5d8253a22b task(F3-frontend): save selection as block - svg thumbnail from elementToPrimitives, save button in library tree 2026-08-28 13:37:35 +02:00
Agent Zero 8baba7e124 docs: roadmap log F2 library panel - search, favorites, thumbnails, grid view 2026-08-28 13:29:45 +02:00
Agent Zero 8b50c0a612 task(F2-frontend): library search, favorites section, star toggle, svg thumbnails, grid view 2026-08-28 13:27:57 +02:00
Agent Zero 83a94e4f42 task(F2-backend): global block favorites - is_favorite column, adapter mapping, PATCH validation 2026-08-28 13:27:50 +02:00
Agent Zero 8ab816667b docs: update roadmap footer - next step F2, status phases B-F1 complete 2026-08-28 12:29:30 +02:00
Agent Zero 5e9e70cb59 docs: roadmap log F1 library export/import 2026-08-28 12:25:49 +02:00
Agent Zero 63c8968f87 task(F1-frontend): library export/import buttons in block library tree 2026-08-28 12:25:17 +02:00
Agent Zero 5cbfe55d03 task(F1): wcadlib export/import endpoints + fix gfolder/gblock id collisions 2026-08-28 12:19:44 +02:00
Agent Zero 1e7fb8bcd0 docs: roadmap log E6b seat assignment 2026-08-28 07:34:58 +02:00
Agent Zero 626d565429 task(E6b): seat assignment via canvas selection bridge 2026-08-28 07:34:23 +02:00
Agent Zero 9e8255f70f docs: roadmap log E5/E6 2026-08-28 07:31:15 +02:00
Agent Zero 5eb741d2a7 task(E6-frontend): guest panel with ribbon entry and api client 2026-08-28 07:30:43 +02:00
Agent Zero e99cf597f8 task(E6-backend): guests CRUD - schema, adapter, routes 2026-08-28 07:25:48 +02:00
Agent Zero 6a214c2e9c task(E5): CSV seat list export 2026-08-27 23:55:11 +02:00
Agent Zero 0696df1e0d docs: roadmap log E3 skip E4 templates 2026-08-27 23:10:55 +02:00
Agent Zero 214e57c9a5 task(E4): event template catalog - kino/banquet/standing/podium presets 2026-08-27 23:10:27 +02:00
Agent Zero c3b976758c docs: roadmap log E2 arc-row 2026-08-27 22:49:09 +02:00
Agent Zero 633cf3d647 task(E2): arc-row tool for curved seating around stage 2026-08-27 22:49:09 +02:00
Agent Zero 85105d3f01 docs: roadmap log E1 seating drag 2026-08-27 22:46:51 +02:00
Agent Zero 86b1e0b8db task(E1): seating-row/block drag interaction with live preview and options 2026-08-27 22:46:51 +02:00
Agent Zero b875012afd docs: repair roadmap - restore clean state, add D-ext3 and D-fin entries 2026-08-27 22:15:33 +02:00
Agent Zero 4c9bd2382e docs: roadmap log D-fin phase D complete 2026-08-27 22:14:01 +02:00
Agent Zero 5500462bd8 task(D-fin): polyline-edit, stretch, break tools complete phase D 2026-08-27 22:14:01 +02:00
Agent Zero 6dd4e083b0 task(C-final): phase C complete - pixi renderer integrated, all element types, e2e test timeout documented as infra issue 2026-08-27 22:06:54 +02:00
Agent Zero b9a4bce2ff docs: roadmap log D-ext3 2026-08-27 20:03:14 +02:00
Agent Zero b470ac3346 task(D-ext3): join/explode/align tools for v2 2026-08-27 20:03:14 +02:00
Agent Zero 9aed0e2163 docs: roadmap log C3fin-int interactive handles 2026-08-27 20:01:04 +02:00
Agent Zero 5605993462 task(C3fin-int): interactive handle drag for select tool (scale by corner) 2026-08-27 20:00:33 +02:00
Agent Zero 8223d159f8 docs: roadmap log D-ext2 chamfer/lengthen 2026-08-27 18:57:34 +02:00
Agent Zero eb1b8692a8 task(D-ext2): chamfer and lengthen tools for v2 2026-08-27 18:56:26 +02:00
Agent Zero 6a1d0db7a1 docs: roadmap log C3fin2 integration 2026-08-27 18:52:53 +02:00
Agent Zero e8aac3baa6 docs: roadmap log C3fin2 2026-08-27 18:51:27 +02:00
Agent Zero 042c546ebe task(C3fin2): snap marks and selection handles wired to canvas events 2026-08-27 18:51:27 +02:00
Agent Zero cefc508736 docs: roadmap log D-ext arrays + session summary 2026-08-27 18:42:59 +02:00
Agent Zero cdb04baff7 docs: roadmap log D-ext arrays 2026-08-27 18:40:59 +02:00
Agent Zero d5b6395f4f task(D-ext): array-rect and array-polar tools for v2 2026-08-27 18:40:58 +02:00
Agent Zero 9b658f31fb docs: roadmap log C3fin 2026-08-27 18:37:09 +02:00
Agent Zero 129ddcc61f task(C3fin): selection handles and snap marks in pixi renderer 2026-08-27 18:37:09 +02:00
Agent Zero 0abfc3e0be docs: roadmap log C4 2026-08-27 12:58:36 +02:00
Agent Zero eb8d6231c5 task(C4): use_pixi_renderer flag on by default with e2e proof 2026-08-27 12:58:36 +02:00
Agent Zero 738e4e8373 docs: roadmap log C3 2026-08-27 10:07:35 +02:00
Agent Zero 124b924457 task(C3): extend pixi element types - dimension/leader/revcloud/chair/table/stage 2026-08-27 10:07:35 +02:00
Agent Zero 8eb5a06816 docs: roadmap log C2int pixi integration 2026-08-27 10:02:54 +02:00
Agent Zero 8b1cca0324 task(C2int): integrate pixi renderer as overlay with lazy import 2026-08-27 10:02:05 +02:00
Agent Zero 8cde5d7f35 docs: roadmap log C2 pixi renderer 2026-08-27 08:54:26 +02:00
Agent Zero dde5077bf9 task(C2): pixi renderer with incremental element rendering behind adapter 2026-08-27 08:53:51 +02:00
Agent Zero b412a65c33 task(C2a): pure shape-primitive factory for all core drawing types 2026-08-27 08:49:18 +02:00
Agent Zero 98db99dc87 docs: roadmap log C1 render adapter 2026-08-27 08:45:18 +02:00
Agent Zero c962e755ec task(C1): render adapter interface with headless mock adapter 2026-08-27 08:43:19 +02:00
Agent Zero ebe8cd32d4 docs: roadmap log VERIF + A4.14 - v2 path is now the default 2026-08-27 08:38:44 +02:00
Agent Zero 69141d604a task(A4.14): make v2 tool path the default (verified e2e without flag) 2026-08-27 08:36:30 +02:00
Agent Zero fbf72e0b6d docs: roadmap log A4.11-A4.13 - phase A functional completion 2026-08-27 07:22:22 +02:00
Agent Zero e370552055 task(A4.13): dynamic v2 tool section in ribbon behind flag 2026-08-27 07:21:19 +02:00
Agent Zero 216bf6360a task(A4.12): v2 history panel reading undo manager behind flag 2026-08-27 07:17:46 +02:00
Agent Zero 25e721d3d3 task(A4.11): documentService singleton + global v2 undo/redo shortcuts 2026-08-27 07:14:41 +02:00
Agent Zero e76b3efe20 docs: roadmap log A4.10 - tool migration complete 2026-08-27 01:22:41 +02:00
Agent Zero 19f838fbf8 task(A4.10): migrate trim/extend/fillet to v2 + real CAD extend fallback 2026-08-27 01:21:44 +02:00
Agent Zero ebafcf0c65 docs: roadmap log A4.8/A4.9 + restore A4.7 line after patch slip 2026-08-27 01:14:36 +02:00
Agent Zero 76f305fc6f task(A4.9): migrate event domain tools (chair/row/block/table/stage) to v2 2026-08-27 01:12:52 +02:00
Agent Zero cf67635732 task(A4.8): migrate delete/offset tools to v2 2026-08-27 01:09:51 +02:00
Agent Zero 7565016200 docs: roadmap log A4.7 transforms 2026-08-27 01:05:40 +02:00
Agent Zero 1d598daba1 task(A4.7): migrate rotate/scale/mirror tools to v2 (selection-based transforms) 2026-08-27 01:04:48 +02:00
Agent Zero 1d3d9bd191 docs: roadmap log A4.6 2026-08-27 00:54:09 +02:00
Agent Zero ca1c3c08f3 task(A4.6): migrate move/copy tools to v2 with selection-based workflow 2026-08-27 00:53:16 +02:00
Agent Zero 0c852d18ee docs: roadmap log A4.5 2026-08-27 00:49:15 +02:00
Agent Zero 464ddb91cc task(A4.5): migrate revcloud/hatch/measure tools to v2 2026-08-27 00:48:28 +02:00
Agent Zero af46f0028a docs: roadmap log A4.4 annotate plugin 2026-08-27 00:43:51 +02:00
Agent Zero 239257c8b0 task(A4.4): migrate text/dimension/leader tools to v2 annotate plugin 2026-08-27 00:43:20 +02:00
Agent Zero 4850f0584b docs: roadmap log A4.3 drawing tools migrated 2026-08-27 00:40:03 +02:00
Agent Zero 0dfe3b5870 task(A4.3): migrate circle/arc/polyline/polygon tools to v2 2026-08-27 00:39:30 +02:00
Agent Zero 9216a9051e docs: roadmap log A4.2 rect migration pattern established 2026-08-26 23:14:59 +02:00
Agent Zero 8204de81c4 task(A4.2): migrate rect tool to v2 plugin 2026-08-26 23:14:26 +02:00
Agent Zero 4b31f49781 docs: roadmap log A4.1 wiring complete 2026-08-26 23:11:24 +02:00
Agent Zero d54a67662b task(A4.1): wire dispatcher to canvas - doc bridge, selection bridge, activeTool sync 2026-08-26 23:10:20 +02:00
Agent Zero 09a120d800 task(A3): pilot v2 tools select+line 2026-08-26 21:30:54 +02:00
Agent Zero ddfeadac58 task(A2b): CADDocument facade with transactions and undo 2026-08-26 21:23:22 +02:00
Agent Zero 4a7a8f912f task(A2): tool dispatcher behind flag 2026-08-26 21:14:46 +02:00
Agent Zero 5d7fa23825 task(A1): plugin api v2 types and registry (additive) 2026-08-26 21:11:01 +02:00
Agent Zero cba9358235 task(A0): logger and test helpers 2026-08-26 21:04:17 +02:00
Agent Zero 725879562e docs: clarify plan roles - ROADMAP.md is authoritative, umbauplan-v2.md archived reference 2026-08-26 20:58:37 +02:00
Agent Zero 3b34181c54 docs: roadmap progress phase B complete (B0-B5), log updated 2026-08-26 20:56:59 +02:00
Agent Zero 7f4768c205 task(B5): seat counting by row and block 2026-08-26 20:53:42 +02:00
Agent Zero d93ee5308c task(B4): cache background image (fixes BUG-4) 2026-08-26 20:51:43 +02:00
Agent Zero d8f29e42e3 task(B3): offset respects click side (fixes BUG-3) 2026-08-26 13:51:02 +02:00
Agent Zero 95b14cf86b task(B2): line/circle/arc intersections (fixes BUG-2) 2026-08-26 13:47:25 +02:00
Agent Zero 1bbd653d34 task(B1): unique row/block ids for seating (fixes BUG-1) 2026-08-26 13:14:34 +02:00
Agent Zero b9eb69fc81 task(B0): regression tests for known bugs (red) 2026-08-26 12:53:07 +02:00
118 changed files with 18588 additions and 248 deletions
+6
View File
@@ -15,6 +15,9 @@ jobs:
- name: Install backend dependencies
working-directory: backend
run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
- name: Typecheck backend
working-directory: backend
run: npx tsc --noEmit
- name: Run backend tests
working-directory: backend
run: npm test
@@ -30,6 +33,9 @@ jobs:
- name: Install frontend dependencies
working-directory: frontend
run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
- name: Typecheck frontend
working-directory: frontend
run: npx tsc --noEmit
- name: Run frontend tests
working-directory: frontend
run: npm test
+54
View File
@@ -0,0 +1,54 @@
# Changelog
## 2.0.0 (2026-08-29)
Vollständige Neuentwicklung der Kern-Architektur (Phasen AH der Roadmap).
### Architektur
- **Plugin-System v2 (A1A4):** 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 (C1C4):** 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 (D1D12, 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 (E1E7, 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 (F1F8, 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 (G1G5, 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 (H1H5, 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
+62 -9
View File
@@ -1,21 +1,74 @@
# Web CAD Neu
# WebCAD
Web-basiertes 2D-CAD für Event-Bestuhlungspläne.
Web-basiertes 2D-CAD mit Kollaboration, Plugin-System und Fokus auf
Veranstaltungsplanung (Bestuhlung, Bühnen, Layouts).
## Features
- **Zeichnen:** Linien, Rechtecke, Kreise, Bögen, Ellipsen, Splines, Polylinien, Text, MTEXT, Bemaßungen, Hatch
- **Modifizieren:** Move/Copy/Rotate/Scale/Mirror, Trim/Extend/Fillet/Chamfer, Stretch/Break/Join/Explode/Align/Lengthen, Polyline-Editor, Grips (Vertex/Center), Arrays (rect/polar/path)
- **Event-Tools:** Stühle, Sitzreihen (Drag), Sitzblöcke, ArcRows, Tische, Bühnen (mit Höhen-Attribut), Vorhang, Scheinwerfer, Absperrungen, Vorlagenkatalog, Gäste-Verwaltung mit Sitzplatz-Zuweisung, CSV-Sitzlistent-Export
- **Snaps:** Endpoint, Midpoint, Center, Intersection, Quadrant, Perpendicular, Tangent, Grid, Polar-Tracking
- **Bibliothek:** Globale Block-Bibliothek mit Ordnern, Suche, Favoriten, Thumbnails; Builtin-Kataloge (Event/Architektur/Landschaft); Export/Import als .wcadlib (JSON oder ZIP)
- **Import/Export:** DXF (Import mit ELLIPSE/SPLINE/MTEXT/POINT/ATTDEF + Fehlerreport; Export R12/2000 mit BLOCKS/ATTR), SVG (echte Geometrie-Konvertierung mit Path-Parser), PDF (je Layout, maßstabsgetreu), PNG, JSON
- **Layouts & Plot:** Layout-Verwaltung mit Viewport-Minimap, Titelblock mit Attributen, PDF-Export, Browser-Print
- **Kollaboration:** Yjs CRDT, kollaborativ korrektes Undo/Redo (nur eigene Operationen)
- **Modi:** simple (Grundwerkzeuge) / pro (Vollpalette), localStorage-persistiert
- **Templates:** 6 Branchen-Presets (Halle, OpenAir, Saal, Messe, Büro, Garten)
- **i18n:** de/en (Grundlage, erweiterbar über `src/i18n/strings.ts`)
- **Rollen:** admin (library-admin, globaler Schreibzugriff) / planer (lesen + eigene Zeichnungen)
## Stack
- Frontend: React + TypeScript + Vite + HTML Canvas
- Backend: Node.js + TypeScript + Fastify + SQLite
- Collaboration: Yjs CRDT
- **Frontend:** React 18 + TypeScript + Vite + PixiJS v8 (WebGL-Renderer, Canvas2D-Fallback) + zustandsfreier Service-Layer
- **Backend:** Node.js + TypeScript + Fastify 5 + SQLite (better-sqlite3)
- **Kollaboration:** Yjs + y-websocket
- **Tests:** Vitest (697 FE + 287 BE), Playwright E2E, tsc-Strict-Mode
## Entwicklung
```bash
npm install
npm run dev
# Backend
cd backend && npm install && npm run dev # http://localhost:3001
# Frontend
cd frontend && npm install && npm run dev # http://localhost:5173
```
### Tests
```bash
cd frontend && npm test # 697 Vitest-Tests
cd backend && npm test # 287 Vitest-Tests
cd frontend && npx tsc --noEmit # Typecheck
cd frontend && npm run build # Production-Build
```
- Frontend: http://localhost:5173
- Backend: http://localhost:3001
## Docker
```bash
docker-compose up --build
```
## Plugin-Entwicklung
Siehe [docs/PLUGIN_GUIDE.md](docs/PLUGIN_GUIDE.md) Plugin-API v2 mit
Werkzeugen, Panels und Bibliotheks-Providern, inkl. Minimal-Beispiel.
## Bibliotheks-Pakete (.wcadlib)
Siehe [docs/LIBRARY_SPEC.md](docs/LIBRARY_SPEC.md) JSON- und ZIP-Format,
Validierungsregeln, Endpoints und CAD-Element-Konventionen.
## Architektur-Regeln
- `kernel/**` importiert niemals React
- `render/pixi/**` kennt keine React-Components
- React kennt den Renderer nur über CanvasArea
- Eine Werkzeug-Aktion = eine `doc.transact()` = ein Undo-Schritt
## Roadmap & Status
Alle funktionalen Phasen (AH) sind abgeschlossen. Aktueller Stand und
Task-Log: [ROADMAP.md](ROADMAP.md).
+756
View File
@@ -0,0 +1,756 @@
# web-cad v2.0 — ROADMAP & ARBEITSANWEISUNGEN
> **Dies ist die Steuerdatei für den Umbau.** Sie enthält Status, Zielarchitektur (Frontend + Backend), Regeln und exakte Arbeitsaufträge (Tasks).
> Bei jedem Fortschritt: Task-Status hier aktualisieren + Eintrag in Teil 7 (Fortschritts-Log).
>
> 📎 Hintergrund & Begründungen der Architektur-Entscheidungen: siehe [umbauplan-v2.md](./umbauplan-v2.md) — bei Abweichungen gilt DIES Dokument.
>
> Stand: 2026-08-26 · Basis: main @ `126277a` · Planer: Agent Zero · Umsetzung: Agent(en) beliebiger Stärke
---
# TEIL 0 — Wie du als Agent dieses Dokument abarbeitest
Du (Agent) arbeitest IMMER GENAU EINEN Task gleichzeitig ab. Verfahre so:
1. **Nächsten Task finden:** Gehe zu Teil 5. Nimm den ERSTEN Task mit `- [ ]` (offen), dessen „Voraussetzungen“ alle `- [x]` sind. Bearbeite Tasks niemals parallel oder in falscher Reihenfolge.
2. **Task lesen:** Lies den kompletten Task. Verstanden? Wenn nein: Frage den Nutzer, rate nicht.
3. **Ist-Zustand prüfen:** Öffne alle unter „Dateien“ genannten bestehenden Dateien und lies sie (zumindest die relevanten Abschnitte), BEVOR du etwas änderst.
4. **Schritte ausführen:** Folge „Schritte“ von oben nach unten. Führe nach jedem Schritt das dort genannte Verify-Kommando aus (falls angegeben).
5. **Fehlerfall:** Wenn ein Schritt fehlschlägt: maximal 3 gezielte Fix-Versuche. Danach: Änderungen des Schritts zurücksetzen (`git checkout -- <datei>` bzw. `git restore`), Task-Status auf `BLOCKIERT` setzen und Blockgrund in Teil 7 loggen. Weiter mit einem anderen unabhängigen Task ist erlaubt, wenn dessen Voraussetzungen erfüllt sind.
6. **Scope-Disziplin:** Ändere NIEMALS Dateien oder Verhalten, die der Task nicht nennt. Keine „Verbesserungen am Rande“. Wenn dir während der Arbeit ein anderes Problem auffällt: notiere es in Teil 7 unter „Gefundene Nebenprobleme“, behebe es NICHT jetzt.
7. **Abschluss eines Tasks:**
a. Führe ALLE Kommandos unter „Verify“ aus. Alles muss grün sein.
b. Commit: `git add -A && git commit -m "task(<TASK-ID>): <kurze beschreibung>"`.
c. Setze in diesem Dokument `- [ ]``- [x]` mit Datum und Commit-Hash.
d. Ergänze Teil 7 (Log): Datum, Task-ID, was getan wurde, Testergebnis, Commit-Hash, offene Punkte.
8. **Commit-Disziplin:** Ein Task = mindestens ein Commit. Nie halbfertigen Code committen, der `tsc` oder Tests bricht.
9. **Nie tun:** Dependencies entfernen, die du nicht selbst hinzugefügt hast · Datenbankschema ändern ohne Migrationsscript · Tests löschen oder überspringen (`skip`, `todo`) um grün zu werden · Secrets committen.
---
# TEIL 1 — Projektstatus & Baseline (Ist-Stand)
## 1.1 Was existiert (verifiziert am 2026-08-26)
| Bereich | Fakten |
|---|---|
| Repo | Forgejo `Leopoldadmin/web-cad`, Branch `main`, ~35.000 LOC |
| Frontend | React 18 + TS + Vite, Canvas 2D. `src/` ~16.480 LOC. `tsc --noEmit` = Exit 0 |
| Tests FE | 16 Dateien, 380 Tests, alle grün (`npx vitest run`) |
| Backend | Fastify + SQLite (better-sqlite3), 13 Route-Module, Auth (bcrypt, 4 Rollen), y-leveldb |
| Tests BE | 17 Dateien, 255 Tests, alle grün |
| Kollaboration | Yjs CRDT + WebSocket + y-indexeddb (Offline) |
| Deployment | Docker Compose, Coolify, Forgejo CI vorhanden |
## 1.2 Bekannte Bugs (müssen in Phase B gefixt werden)
| ID | Ort | Bug |
|---|---|---|
| BUG-1 | `frontend/src/services/seatingService.ts` (~L220) | `rowId: row_${Date.now()}` → ID-Kollisionen bei schneller Folge. Blöcke setzen gar keine rowId pro Reihe. `countSeats(byRow)` dadurch falsch. |
| BUG-2 | `frontend/src/tools/modification/geometry.ts` (~L303) | `findIntersection()` kann nur Gerade×Gerade → Trim/Extend/Fillet scheitern still auf Kreis/Bogen/Polyline. |
| BUG-3 | `frontend/src/interaction/index.ts` (handleModifyDown, offset-Zweig) | Offset nutzt nur Distanz, ignoriert Richtung des zweiten Klicks (Seite fehlt). |
| BUG-4 | `frontend/src/canvas/RenderEngine.ts` (`drawBackground`) | Hintergrundbild wird bei jedem Frame neu geladen (`img.onload = () => this.render()`). Muss gecacht werden. |
## 1.3 Architektur-Schwächen (Motivation für Umbau)
| Schwäche | Ort | Folge |
|---|---|---|
| God-Component | `App.tsx` (1803 LOC, 40+ useState) | Unwartbar, jede Mausbewegung (`cursorPos` in State) rendert GANZE App neu |
| Hart codierte Tools | `interaction/index.ts` (1157 LOC switch-case) | Werkzeuge nicht als Plugins möglich, Ribbon statisch |
| Snapshot-History | `history/HistoryManager.ts` | Ganzzustand-Undo, kollisionsanfällig in Kollaboration, keine Tool-Transaktionen |
| Direkte Engine-Kopplung | `components/CanvasArea.tsx` (477 LOC) | Renderer nicht austauschbar |
| Renderer Canvas2D only | `canvas/RenderEngine.ts` (1196 LOC) | 50k+ Elemente unrealistisch |
| DXF mini | `services/dxfParser.ts` (186 LOC), `dxfWriter.ts` (214 LOC) | Nur 7 Entity-Typen; ELLIPSE/SPLINE/MTEXT/HATCH/POINT fehlen |
## 1.4 Zielbild v2.0 (kurz)
Web-2D-CAD in Standard-CAD-Breite mit Event-Fokus. **Alle Werkzeuge = Plugins.** WebGL-Rendering. Library first-class, durch Plugin-Kataloge erweiterbar. Branchenneutral einsetzbar (Grundrisse, Gelände). Bedienmodi „Einfach“ und „Pro“. Frontend und Backend werden beide umgebaut.
---
# TEIL 2 — ZIELARCHITEKTUR FRONTEND (ausgearbeitet)
## 2.1 Neue Ordnerstruktur (Ziel)
```
frontend/src/
├── kernel/ # Framework-frei, kein React-Import erlaubt!
│ ├── model/ # Typen + Fabriken: CADElement, CADLayer, BlockDef, ids.ts
│ ├── geometry/ # intersects.ts (Line/Circle/Arc/Poly), transform.ts, bbox.ts
│ ├── document/ # CADDocument: CRUD + Queries über Yjs-Daten
│ └── history/ # Transaktionen (Wrapper um Yjs UndoManager)
├── render/
│ ├── types.ts # RenderAdapter-Interface + Viewport/Style-Typen
│ ├── pixi/ # PixiJS-Implementierung
│ │ ├── PixiRenderer.ts # implements RenderAdapter
│ │ ├── ElementLayer.ts # Graphics/Sprites pro CAD-Layer
│ │ ├── OverlayLayer.ts # Selection, Snaps, Previews, Tool-Overlays
│ │ ├── GridRulerLayer.ts
│ │ └── atlas.ts # Texturatlas für chair/table/stage
│ └── mock/MockAdapter.ts # Headless-Adapter für Unit-Tests (zeichnet nichts, protokolliert Aufrufe)
├── interaction/
│ ├── dispatcher.ts # InteractionDispatcher: DOM-Events → aktives Tool
│ ├── session.ts # ToolSession-Zustandsmaschine (idle/drawing/modifying)
│ ├── pointer.ts # Event-Normalisierung (screen→world, Buttons, Modifier)
│ └── shortcuts.ts # Tastatur-Mapping
├── plugins/
│ ├── types.ts # API v2 (siehe 2.4)
│ ├── registry.ts # Registry v2
│ ├── context.ts # PluginContext-Fabrik (Permissions geprüft)
│ ├── builtin/
│ │ ├── core-drawing/index.ts
│ │ ├── core-modify/index.ts
│ │ ├── core-annotate/index.ts
│ │ ├── core-measure/index.ts
│ │ └── event-tools/index.ts
│ └── library/ # LibraryProviderExtension-Verwaltung
├── stores/ # zustand Stores (NEU, ersetzt großen Teil des App-State)
│ ├── docStore.ts # Bindung an Yjs: elements/layers/selection (Selektion NICHT in Yjs, pro User lokal)
│ ├── toolStore.ts # activeToolId, toolOptions-Werte
│ ├── viewStore.ts # zoom, pan, grid, ortho, snap, unit, scaleFactor
│ └── uiStore.ts # panels, modals, ribbonTab, mode: 'simple'|'pro'
├── ui/
│ ├── AppShell.tsx # NUR Layout (Grid aus Slots)
│ ├── canvas/CanvasHost.tsx # Mountet Kernel+Renderer+Dispatcher, sonst nichts
│ ├── ribbon/RibbonBar.tsx # dynamisch aus pluginRegistry.getToolExtensions()
│ ├── panels/… # LeftSidebar, RightSidebar, PropertiesPanel (liest toolOptions-Schema)
│ ├── dialogs/…
│ └── simple/… # Einfach-Modus Varianten
├── services/ # bleiben (api.ts, import/export, background, seating → wandert teils in plugins/builtin/event-tools/domain)
├── crdt/ # bleibt (YjsDocument etc.)
└── types/ # cad.types.ts bleibt Quelle der Wahrheit für Persistenz-Typen
```
**Regel:** `kernel/**` darf React nirgends importieren. `render/pixi/**` kennt keine React-Components. React kennt Renderer nur über `CanvasHost`.
## 2.2 State-Management (Entscheidung + Begründung)
**Neu: zustand** (klein, kein Provider-Boilerplate, selektierbar).
Gründe gegen Ist-Zustand:
- `cursorPos` liegt heute in App-State → jede Mausbewegung re-rendert 1803-LOC-Tree. Neu: Cursor-Anzeige liest Position aus einem throttled store-Slice ODER direkt DOM-Update im CanvasHost (bevorzugt: StatusBar subscribed auf `viewStore.cursor` mit Selektor; Update max 60/s).
- Elemente liegen doppelt (React-State UND Yjs). Neu: **Yjs ist alleinige Quelle der Wahrheit**. `docStore` spiegelt Yjs-Arrays in einen zustand-Slice (via `yjs.observe`), Components lesen daraus. Kein zweites `setElements`.
Store-Verträge (exakt):
```ts
// stores/docStore.ts
interface DocSlice {
elements: Record<string, CADElement>; // id → element (Spiegel von Yjs Map)
elementOrder: string[]; // Render-/Auswahlreihenfolge
layers: CADLayer[];
selection: string[]; // lokale Selektion (nicht synchronisiert)
setSelection(ids: string[]): void;
}
// stores/toolStore.ts
interface ToolSlice {
activeToolId: string; // default 'select'
optionValues: Record<string, Record<string, unknown>>; // toolId → optionKey → value
setTool(id: string): void; // validiert gegen Registry
setOption(toolId: string, key: string, value: unknown): void;
}
```
## 2.3 Rendering-Pipeline (Ziel)
Frame-Zyklus (außerhalb React):
1. `InteractionDispatcher` erhält DOM-Event → normalisiert (`pointer.ts`) → ruft Handler des aktiven Tools mit Weltkoordinaten.
2. Tool ändert Entweder: a) `document.transact(fn)` (committed, erzeugt Undo-Eintrag) oder b) `renderer.setPreview(el)` (nur Optik).
3. `PixiRenderer` hält pro CAD-Layer einen Container. Bei Yjs-Transaktion: diff-basiertes Update (added/removed/changed Keys aus Yjs-Events), KEIN Full-Redraw.
4. Overlays (Selection/Snap/Preview) leben in eigenem Layer, immer neu zeichnbar ohne Element-Layer zu berühren.
5. requestAnimationFrame-Loop rendert nur bei `dirty`-Flag (kein Dauerloop).
Performance-Ziele (messbar in `tests/PerformanceBenchmark.test.ts` weiterführen):
- 10k Elemente: Interaktion < 50ms Latenz
- 50k Chairs (Atlas-instanziiert): ≥ 55fps bei Pan/Zoom auf Referenzhardware
- Erstzeichnung Dashboard→Canvas sichtbar < 2s
## 2.4 Plugin-API v2 (Vertrag, exakte Interfaces)
```ts
// plugins/types.ts (NEU, ersetzt Teile der alten API)
export interface ToolManifestPart {
id: string; label: string; icon: string; ribbonTab: string;
shortcut?: string; tags?: ('basic'|'pro')[]; // 'basic' = im Einfach-Modus sichtbar
}
export interface ToolPointerEvent { world: Pt; screen: Pt; button: number; shift: boolean; alt: boolean; ctrl: boolean; }
export interface ToolContext {
doc: CADDocument; // CRUD + transact()
renderer: RenderAdapter; // für Preview/Overlay
snap: SnapEngine; selection: SelectionEngine;
options: Record<string, unknown>; // aktuelle OptionValues
setStatus(msg: string): void; // Statuszeile
}
export interface ToolExtensionV2 {
manifest: ToolManifestPart & { description?: string };
optionsSchema: PropertyField[]; // existierender Typ wiederverwendet
handlers: {
down?(e: ToolPointerEvent, ctx: ToolContext): void;
move?(e: ToolPointerEvent, ctx: ToolContext): void;
up?(e: ToolPointerEvent, ctx: ToolContext): void;
key?(e: KeyboardEvent, ctx: ToolContext): boolean; // true = konsumiert
cancel?(ctx: ToolContext): void; // ESC
};
}
export interface PanelExtension { id: string; title: string; slot: 'left'|'right'; render(): HTMLElement; }
export interface LibraryProviderExtension {
id: string; label: string;
listFolders(): Promise<LibFolder[]>;
listBlocks(folderId: string): Promise<LibBlock[]>;
getBlock(id: string): Promise<LibBlock>;
}
export interface PluginV2 {
manifest: { id; name; version; author; description; category: 'tools'|'elements'|'library'|'import-export'|'theme'|'other';
enabledByDefault?: boolean; minAppVersion?: string; permissions?: Array<'canvas'|'network'|'storage'> };
tools?: ToolExtensionV2[];
commands?: CommandExtension[]; // existiert bereits, unverändert
elementTypes?: ElementTypeExtension[]; // bleibt, render optional + renderPixi später
panels?: PanelExtension[];
libraryProviders?: LibraryProviderExtension[];
onInit?(ctx: PluginContext): void; onActivate?(); onDeactivate?(); onDestroy?();
}
```
Migration Alt→Neu (wichtig!):
- Alte `ToolExtension {onActivate}` bleibt ÜBERGANGSWEISE bestehen (Phase A kompatibel), wird in A3 migriert und DANACH entfernt.
- `InteractionEngine.setTool(id)` delegiert ab A2 ausschließlich an Registry-Tools.
## 2.5 Undo/Redo (Entscheidung)
**Yjs `Y.UndoManager` verwenden**, eigenes Snapshot-History entfernen (Phase A4).
- Eine Tool-Aktion = eine `doc.transact(...)` = ein Undo-Schritt. Kollaborativ korrekt (undoed nur eigene Ops).
- `HistoryPanel` liest `undoManager.undoStack.length` etc.
- Alte `HistoryManager`-Tests (32) werden auf neue Transaktions-API umgeschrieben (Aufwand ~0,5 Tag, in A4 eingeplant).
## 2.6 Bedienmodi
- `uiStore.mode: 'simple'|'pro'`. Default für neue Nutzer: `simple`.
- Filter: Ribbon zeigt Tool nur, wenn `mode==='pro' || manifest.tags.includes('basic')`. CommandLine/Layer-Tiefe nur pro.
- Umschalter: Topbar-Menü + SettingsModal. Persistenz in usersettings (Backend, existierende settings-Route nutzen).
---
# TEIL 3 — ZIELARCHITEKTUR BACKEND (Kurzfassung)
Bleibt Fastify + SQLite. Neuerungen:
- Tabellen: `plugins(id TEXT PK, version TEXT, enabled_default INT, installed_at TEXT)`; `plugin_settings(plugin_id TEXT, scope TEXT, scope_id TEXT, json TEXT, PRIMARY KEY(plugin_id,scope,scope_id))`; `library_folders(id TEXT PK, parent_id TEXT, name TEXT, owner_scope TEXT)`; `library_blocks(id TEXT PK, folder_id TEXT, name TEXT, tags TEXT, thumbnail TEXT, attr_defs TEXT, payload TEXT, source TEXT)`; `guests(id TEXT PK, project_id TEXT, name TEXT, seat_id TEXT NULL)` (Phase E6, optional 2.1).
- Routen neu: `/api/plugins` GET · `/api/plugins/:id/settings` GET/PUT · `/api/plugins/install` POST (Admin, ZIP) · `/api/library/folders|blocks` CRUD · `/api/library/import|export` POST.
- WebSocket-Notify: `pluginStateChanged`, `libraryChanged` Broadcast.
- Migrationsmechanismus: `backend/src/database/migrations/NNNN_name.sql`, Runner beim Boot (Schema-Versionstabelle). JEDE Schemaänderung mit Migrationsfile + Test.
---
# TEIL 4 — REGELN (Definition of Done)
Ein Task ist nur dann fertig, wenn ALLSES gilt:
1. `cd frontend && npx tsc --noEmit` → Exit 0.
2. `cd frontend && npx vitest run` → alle Tests grün (Anzahl darf nie sinken; neue Features bringen Tests).
3. `cd backend && npx vitest run` → alle grün (bei Backend-Tasks).
4. Keine neuen `console.log` (Logger-Util `utils/logger.ts` nutzen, in A0 anlegen).
5. Neue öffentliche Funktionen/Interfaces haben TSDoc-Kommentar (13 Zeilen).
6. Commit-Format: `task(<ID>): <beschreibung>` (Conventional reicht so).
7. Dieser File: Status gesetzt + Log-Eintrag.
Testkonventionen:
- Neue Kernel-Module: Vitest Unit-Tests OHNE DOM (`environment: node` geht nicht global — lege pro Testfile `// @vitest-environment node` falls kein DOM nötig).
- Renderer: Tests laufen gegen `MockAdapter` (protokolliert draw*-Aufrufe). WebGL selbst wird nur in Playwright-Smoke getestet.
- Tools: je Tool ein Workflow-Test (down/move/up-Sequenz gegen MockAdapter + InMemory-Doc).
---
# TEIL 5 — PHASEN & TASKS
Legende: `- [ ]` offen · `- [~]` in Arbeit (Datum) · `- [x]` fertig (Datum, Hash) · `[BLOCKIERT: grund]`
**ERWEITERUNGSREGEL (verpflichtend):** Die Tasks in Phase C (ab C2) und den Phasen DI sind bewusst als Kurz-Tasks gehalten. SOFORT VOR Beginn eines solchen Tasks wird er auf das volle Schema erweitert (Voraussetzungen · Ziel · Dateien · nummerierte Schritte mit Code-Ankerpunkten · Verify-Kommandos · Commit-Text) und die Erweiterung in diesem Dokument gespeichert. Ein Kurz-Task darf NIE direkt bearbeitet werden. Grund: Nur vollständig ausgeformte Tasks sind von einem schwachen Modell sicher ausführbar.
**COPY-PASTE-REGEL:** Wo ein Task kompletten Code enthält (z.B. B0-Testdatei): exakt übernehmen, NICHT „verbessern“. Eigene Abweichungen nur, wenn ein Schritt nachweislich am Ist-Code scheitert dann Blockierung gemäß Teil 0 Punkt 5 loggen.
## Phase B — Bugfixes (zuerst! klein & isoliert)
### Task B0 — Testgerüst für Bugs (ROT-Tests) ✅ [2026-08-26 · b9eb69f]
- Voraussetzungen: keine
- Ziel: Repro-Tests für BUG-1, BUG-1b und BUG-2, die HEUTE rot sind und nach den Fixes grün. (Regressionstests für BUG-3 liegen in Task B3, für BUG-4 in Task B4 — dort jeweils im selben Commit wie der Fix, weil sie vor dem Fix gegen die bestehende API nicht kompilierbar sind.)
- Dateien: NEU `frontend/tests/bugs.regress.test.ts`
**WICHTIG (verifiziert):** `findIntersection` ist in `geometry.ts` NICHT exportiert (nur intern genutzt). Der BUG-2-Test darf es deshalb NICHT importieren — er prüft über das exportierte `trimElement`. `SeatingService` ist exportiert (`export class SeatingService`, L162).
Erzeuge die Datei `frontend/tests/bugs.regress.test.ts` mit EXAKT diesem Inhalt:
```ts
/**
* Regressions-Tests für bekannte Bugs BUG-1, BUG-1b, BUG-2.
* Diese Tests sind VOR dem jeweiligen Fix ROT (dokumentiertes Fehlverhalten)
* und müssen danach GRÜN sein.
*/
import { describe, it, expect } from 'vitest';
import { SeatingService } from '../src/services/seatingService';
import { trimElement } from '../src/tools/modification/geometry';
import type { CADElement } from '../src/types/cad.types';
// ── Helfer (Muster aus tests/geometry.test.ts) ─────────────
function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement {
return {
id,
type: 'line',
layerId: 'layer-1',
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;
}
function makeCircle(id: string, cx: number, cy: number, r: number): CADElement {
return {
id,
type: 'circle',
layerId: 'layer-1',
x: cx,
y: cy,
width: r * 2,
height: r * 2,
properties: { radius: r },
} as unknown as CADElement;
}
// ── BUG-1: rowId-Kollision bei schnellem Erzeugen ───────────
describe('BUG-1: seating row ids', () => {
it('zwei direkt hintereinander erzeugte Reihen haben unterschiedliche rowIds', () => {
const svc = new SeatingService();
const a = svc.createSeatingRow(0, 0, 'layer-1');
const b = svc.createSeatingRow(0, 100, 'layer-1');
const ids = new Set([a[0].properties.rowId, b[0].properties.rowId]);
expect(ids.size).toBe(2);
});
});
// ── BUG-1b: Block-Reihen ohne eigene rowId ──────────────────
describe('BUG-1b: seating block row ids', () => {
it('jede Reihe eines Blocks hat eine eigene, definierte rowId', () => {
const svc = new SeatingService();
const els = svc.createSeatingBlock(0, 0, 'layer-1', { rows: 3, cols: 2 });
expect(els.length).toBe(6);
const rowIds = new Set(els.map((e) => e.properties.rowId));
expect(rowIds.size).toBe(3);
for (const rid of rowIds) expect(rid).toBeDefined();
});
});
// ── BUG-2: Trim an Kreis scheitert (findIntersection nur Gerade×Gerade) ──
describe('BUG-2: trim line at circle', () => {
it('trimElement(Linie, Kreis) liefert ein Ergebnis statt null', () => {
// Linie von (0,0) nach (200,0) schneidet Kreis (Zentrum 100,0, Radius 40)
// an x=60 und x=140 → Schnitt existiert eindeutig.
const line = makeLine('l1', 0, 0, 200, 0);
const circle = makeCircle('c1', 100, 0, 40);
const result = trimElement(line, circle);
expect(result).not.toBeNull();
});
});
```
Hinweis zu makeLine/makeCircle: Die properties-Namen (x1,y1,x2,y2 bzw. radius) sind die vom Produktivcode genutzten Konventionen (`findIntersection` liest x1..y2; `RenderEngine.drawCircle` liest `properties.radius || width/2`). Falls `tests/geometry.test.ts` abweichende Helfer hat, gleiche sie daran an — die Struktur oben bleibt.
- Schritte:
1. Datei exakt wie oben anlegen.
2. Ausführen: `cd frontend && npx vitest run tests/bugs.regress.test.ts` → ERWARTET: alle 3 Tests FAIL (das ist der Beweis, dass die Bugs real sind; dokumentiere die Fehlermeldungen kurz in Teil 7).
3. Gesamt-Suite: `npx vitest run` → alles andere muss grün bleiben, nur die 3 neuen Tests rot.
- Verify: `npx vitest run tests/bugs.regress.test.ts 2>&1 | tail -25` (3 fail erwartet) · `npx vitest run 2>&1 | tail -5` (nur diese 3 rot) · `npx tsc --noEmit` (Exit 0 — Tests müssen kompilieren!)
- Commit: `task(B0): regression tests for known bugs (red)`
- Nicht tun: Fixes schon einbauen · Tests mit `.skip` versehen · die roten Tests „reparieren"
### Task B1 — Seating-IDs fixen (BUG-1) ✅ [2026-08-26 · 1bbd653]
- Voraussetzungen: B0
- Dateien: `frontend/src/services/seatingService.ts`
- Schritte:
1. Direkt nach den Interface-/Default-Konstanten (vor `export class SeatingService`) diesen Helper ergänzen:
```ts
let seqCounter = 0;
function nextUid(prefix: string): string {
seqCounter += 1;
return `${prefix}_${Date.now().toString(36)}_${seqCounter.toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
```
(Prüfe zuerst mit grep, ob es bereits eine `generateId()`-Methode in der Klasse gibt — wenn ja, nutze diese für Element-IDs weiter, aber NICHT für rowId, da rowId pro Reihe gleich sein muss.)
2. In `createSeatingRow` (ca. L189): VOR der for-Schleife `const rowId = nextUid('row');` einfügen; innerhalb properties: `rowId: row_${Date.now()}` ERSETZEN durch `rowId,` (und `rowIndex: i` bleibt).
3. In `createSeatingBlock` (ca. L227): VOR der äußeren for-Schleife `const blockId = nextUid('block');` (ersetzt das bestehende `const blockId = \`block_${Date.now()}`). INNERHALB der äußeren Schleife (pro Reihe) `const rowId = nextUid('row');`; in properties ergänzen: `rowId,` (neu) neben bestehendem `blockId`, `rowIndex`, `colIndex`.
4. Ausführen: `npx vitest run tests/bugs.regress.test.ts` → BUG-1 und BUG-1b jetzt GRÜN; BUG-2 weiterhin rot (kommt in B2).
- Verify: `npx vitest run tests/bugs.regress.test.ts 2>&1 | tail -15` (2 von 3 grün) · `npx tsc --noEmit` Exit 0 · `npx vitest run 2>&1 | tail -5`
- Commit: `task(B1): unique row/block ids for seating (fixes BUG-1)`
### Task B2 — findIntersection erweitern (BUG-2)
- Voraussetzungen: B0
- Dateien: `frontend/src/tools/modification/geometry.ts`
- Schritte:
1. LESE ZUERST `RenderEngine.drawArc` (L524) und notiere dir die Arc-Property-Konvention (Felder + Winkeleinheit/Richtung). Nutze dieselbe Konvention in den neuen Funktionen.
2. Neue exportierte Funktionen in geometry.ts ergänzen:
```ts
export interface Pt { x: number; y: number; }
/** Alle Schnittpunkte Strecke p1→p2 × Kreis(c,r); NUR t∈[0,1] (echte Strecke). */
export function lineCircleIntersections(p1: Pt, p2: Pt, c: Pt, r: number): Pt[] {
// Standardformel: a=d·d, b=2·f·d, cc=f·fr² mit d=p2p1, f=p1c
// t=(b±√(b²−4ac))/(2a); nur t∈[0,1]; Diskriminante<0 → []
}
/** Schnittpunkte zweier Kreise; [] wenn getrennt oder identisch. */
export function circleCircleIntersections(c1: Pt, r1: number, c2: Pt, r2: number): Pt[] {
// Standardformel: d=|c2c1|, Grenzfälle dokumentieren
}
/** Strecke × Bogen; Konvention wie RenderEngine.drawArc. */
export function lineArcIntersection(p1: Pt, p2: Pt, el: CADElement): Pt[] {
// 1) Strecke×Kreis via lineCircleIntersections
// 2) Treffer auf Winkel im [startAngle,endAngle]-Bereich filtern (Grad→Rad konvertieren)
}
```
3. findIntersection (intern, L303): Dispatch je Typkombination: line×line (bestehend), line×circle, line×arc, circle×circle; Reihenfolge egal → intern normalisieren (z.B. immer kleinerer Typname zuerst). Bei mehreren Punkten: der NAHERE zu p1 von Element 1.
4. trimElement/extendElement unverändert lassen.
5. Unit-Tests ERGÄNZEN in tests/geometry.test.ts (Datei NICHT ersetzen): je neuer Funktion ≥2 positiv, ≥1 negativ, Randfälle tangential (genau 1 Punkt) + sekant (2 Punkte). BUG-2-Test aus bugs.regress.test.ts muss grün werden.
- Verify: `npx vitest run tests/geometry.test.ts tests/bugs.regress.test.ts 2>&1 | tail -15` (alles grün inkl. BUG-2) · `npx vitest run 2>&1 | tail -5` · `npx tsc --noEmit`
- Commit: `task(B2): line/circle/arc intersections (fixes BUG-2)`
### Task B3 — Offset-Richtung (BUG-3)
- Voraussetzungen: B2 (nutzt Pt/Normalen-Helfer aus geometry.ts)
- Dateien: `frontend/src/tools/modification/geometry.ts`, `frontend/src/interaction/index.ts`, ERGÄNZEN `frontend/tests/bugs.regress.test.ts`
- Schritte:
1. geometry.ts: Signatur ändern zu `export function offsetElement(el: CADElement, dist: number, side?: 1 | -1): CADElement` — `dist` wird mit `(side ?? 1)` multipliziert. Prüfe ALLE bestehenden Aufrufer (`grep -n 'offsetElement(' -r src tests`): Bestehende Aufrufe ohne drittes Argument müssen unverändert weiterlaufen (Default side=1). Normale für line: (-dy, dx)/len; für rect/circle: Vorzeichen relativ zum Zentrum vs. Klickpunkt (wird vom Aufrufer übergeben).
2. interaction/index.ts offset-Zweig (handleModifyDown): berechne `side` per Kreuzprodukt: `cross = (p2.x-p1.x)*(pt.y-p1.y) - (p2.y-p1.y)*(pt.x-p1.x)`; `const side = cross >= 0 ? 1 : -1`; übergebe `offsetElement(this.modifySelected[0], dist, side)`. p1/p2 = Endpunkte der Basislinie aus properties (x1,y1,x2,y2); bei rect/circle: Klickpunkt vs. Elementzentrum.
3. Regressionstest in bugs.regress.test.ts ERGÄNZEN:
```ts
describe('BUG-3: offset respects click side', () => {
it('Klick links der Linie versetzt nach links', () => {
// Linie (100,100)→(300,100), Klick bei (200,50) = oberhalb → Ergebnis-Mitte.y < 100
});
});
```
(Konkret ausarbeiten: offsetElement(line, 20, -1) muss y um 20 verschieben, +1 um +20 — exakt eine Assertion auf resultierende properties.x1/y1.)
4. Suite grün inkl. neuem Test.
- Verify: `npx vitest run tests/bugs.regress.test.ts 2>&1 | tail -15` · `npx vitest run 2>&1 | tail -5` · `npx tsc --noEmit`
- Commit: `task(B3): offset respects click side (fixes BUG-3)`
### Task B4 — Hintergrundbild cachen (BUG-4)
- Voraussetzungen: B0
- Dateien: `frontend/src/canvas/RenderEngine.ts`, ERGÄNZEN `frontend/tests/bugs.regress.test.ts`
- Schritte:
1. LESE ZUERST die aktuelle `drawBackground()` (L381) komplett und verstehe den Transform-Kontext (ZoomPan-Transform ist beim Aufruf aktiv).
2. Zwei private Felder ergänzen:
```ts
private bgImage: HTMLImageElement | null = null;
private bgSrcLoaded = '';
```
3. drawBackground umschreiben: wenn `this.options.backgroundSrc && this.options.backgroundSrc !== this.bgSrcLoaded` → NEUES Image laden; onload: Felder setzen + EINMALIG `this.render()`; wenn `backgroundSrc === bgSrcLoaded && bgImage` → direkt `ctx.drawImage(...)` mit dem bestehenden Transform (kein Laden). Wenn backgroundSrc null/leer → Felder zurücksetzen.
4. Regressionstest in bugs.regress.test.ts ERGÄNZEN (jsdom liefert Mock-Context aus tests/setup.ts):
```ts
describe('BUG-4: background image cached', () => {
it('zweites render laedt das Bild nicht erneut', () => {
// RenderEngine instanziieren wie in tests/RenderEngine.test.ts (createMockCanvas-Muster)
// engine.setOptions({ backgroundSrc: 'data:image/png;base64,iVBORw0KGgo=' })
// engine.render(); await tick; engine.render();
// assert: nur 1× new Image() bzw. img.src-Zuweisung (spy via vi.spyOn(global, 'Image'))
});
});
```
Ausarbeiten mit vi.spyOn(window, 'Image') oder global.Image-Stub; Assertion: Konstruktor-Aufrufe === 1 nach zwei render()-Aufrufen mit gleicher src. Falls jsdom das nicht hergibt: Test auf privates Feld via `(engine as any).bgSrcLoaded` zulassen.
5. Suite grün inkl. neuem Test.
- Verify: `npx vitest run tests/bugs.regress.test.ts 2>&1 | tail -15` · `npx vitest run 2>&1 | tail -5` · `npx tsc --noEmit`
- Commit: `task(B4): cache background image (fixes BUG-4)`
### Task B5 — countSeats robust (folgt aus B1)
- Voraussetzungen: B1
- Dateien: `frontend/src/services/seatingService.ts`
- Schritte: `countSeats` byRow: Gruppierung nach `rowId` unabhängig ob block oder row; byBlock zusätzlich; Rückgabetyp erweitern `{total, byRow, byBlock, byZone?}` (byZone optional leer lassen). Test in `tests/` ergänzen (neues File `seating.count.test.ts`).
- Verify: volle Suite grün. Commit `task(B5): seat counting by row and block`
## Phase A — Plugin-first Kernel (nach B; A und C können ab A2 parallel)
### Task A0 — Logger + Testhelfer
- Voraussetzungen: B5
- Dateien: NEU `frontend/src/utils/logger.ts` (level-gesteuert, noop in prod-build via vite define), NEU `frontend/tests/helpers/mockAdapter.ts` (MockAdapter: implementiert RenderAdapter-Interface als Recorder — Interface kommt in C1, Mock implementiert zunächst NUR die Methoden, die InteractionEngine heute braucht: preview/clear/snapPoints; wird in C1 erweitert), NEU `frontend/tests/helpers/inMemoryDoc.ts` (dünner Wrapper um YjsDocument ohne Netzwerk).
- Schritte: Dateien anlegen, je 1 Smoke-Test (`logger writes in test env`, `mock records calls`).
- Verify: `npx vitest run tests/helpers` grün. Commit `task(A0): logger and test helpers`
### Task A1 — Plugin-Typen v2 + Registry-Erweiterung ( additive )
- Voraussetzungen: A0
- Dateien: `frontend/src/plugins/types.ts` (ERWEITERN, nichts Entfernen), `frontend/src/plugins/PluginRegistry.ts` (neue Getter), NEU `frontend/plugins-v2.test.ts`→ besser `frontend/tests/pluginApiV2.test.ts`
- Schritte:
1. Types aus TEIL 2.4 exakt übernehmen (ToolExtensionV2, PanelExtension, LibraryProviderExtension, PluginV2, ToolPointerEvent, ToolContext). Alte Typen bleiben.
2. Registry: `registerV2(p: PluginV2)`, `getToolsV2(): ToolExtensionV2[]`, `getPanels()`, `getLibraryProviders()`. enable/disable analog alt (state map um version erweitern).
3. Tests: Register/enable/disable/getters; doppelte ID verworfen; disabled Tools tauchen in getToolsV2 nicht auf.
- Verify: suite grün. Commit `task(A1): plugin api v2 types and registry (additive)`
### Task A2 — InteractionDispatcher (parallel zu Altlaufwerk, hinter Feature-Flag)
- Voraussetzungen: A1
- Dateien: NEU `frontend/src/interaction/dispatcher.ts`, NEU `frontend/src/interaction/session.ts`, NEU `frontend/src/interaction/pointer.ts`, ÄNDERN `frontend/src/components/CanvasArea.tsx` (Flag), ÄNDERN `frontend/src/types/ui.types.ts` (Flag-Typ)
- Schritte:
1. pointer.ts: `toWorld(e: PointerEvent, zoomPan): ToolPointerEvent` (screenToWorld nutzen), Button/Modifier mappen, `setPointerCapture` Helfer.
2. session.ts: `class ToolSession { phase:'idle'|'active'; begin(); end(); }` minimal.
3. dispatcher.ts: `class InteractionDispatcher { constructor(canvas, deps{zoomPan,doc,renderer,snap,selection}) ; setActive(toolId|null) }` — bindet pointerdown/move/up/keydown/Escape; ruft handlers des aktiven V2-Tools; ESC → cancel; Rechtsklick → cancel.
4. CanvasArea: Env-Flag `VITE_TOOL_DISPATCHER=v2` (vite env): wenn gesetzt → Dispatcher instanziieren und InteractionEngine deaktivieren (nur Konstruktion skippen), sonst alter Weg. Standard in .env.example dokumentieren.
5. Test: `dispatcher.test.ts` — synthetische PointerEvents auf Fake-Canvas (aus tests/setup.ts vorhanden?), assert handler-Reihenfolge down→move→up, ESC ruft cancel.
- Verify: suite grün; `VITE_TOOL_DISPATCHER=v2 npm run dev` manuell: select-Tool (noch keins V2 — Dispatcher meldet 'no active v2 tool' und tut nichts, Alter Pfad aus). Commit `task(A2): tool dispatcher behind flag`
### Task A2b — CADDocument mit transact + UndoManager (Grundlage für Tools)
- Voraussetzungen: A1
- Ziel: Eine dünne Dokument-Fassade über YjsDocument, die Tools in A3/A4 nutzen: CRUD + Transaktionen + Undo/Redo. KEINE Tool-Logik hier!
- Dateien: NEU `frontend/src/kernel/document/CADDocument.ts`, NEU `frontend/tests/cadDocument.test.ts`, LESEN `frontend/src/crdt/YjsDocument.ts`
- Schritte:
1. LESE YjsDocument.ts komplett: wie sind elements/layers gespeichert (Y.Map? Y.Array?), wie lauten die bestehenden Methodennamen?
2. CADDocument-Klasse anlegen:
```ts
/**
* Fassade über YjsDocument: elementar-CRUD + transaktionale Änderungen
* mit kollaborativ korrektem Undo via Y.UndoManager.
*/
export class CADDocument {
constructor(ydoc: YjsDocument) { ... }
addElement(el: CADElement): void;
updateElement(id: string, patch: Partial<CADElement>): void; // shallow merge in properties
deleteElements(ids: string[]): void;
getElement(id: string): CADElement | undefined;
getAllElements(): CADElement[];
getLayer(id: string): CADLayer | undefined;
getAllLayers(): CADLayer[];
/** Führt fn als EINE Transaktion aus → genau 1 Undo-Schritt. */
transact(fn: () => void): void; // ydoc.doc.transact(fn)
undo(): boolean; redo(): boolean; canUndo(): boolean; canRedo(): boolean;
onElementsChanged(cb: () => void): () => void; // subscribe, returns unsubscribe
}
```
3. UndoManager im Konstruktor: `new Y.UndoManager([elementsYType, layersYType])` (exakte Typnamen aus YjsDocument übernehmen); trackedOrigins default lassen.
4. onElementsChanged: via `ydoc.doc.on('update', cb)` oder observe auf die Types (was YjsDocument hergibt); Rückgabe Unsubscribe.
5. Tests (cadDocument.test.ts, In-Memory-Yjs, kein Netz):
a. addElement→getAllElements enthält es · b. transact(()=>{add a; add b}) → undo() entfernt BEIDE (ein Schritt!) · c. redo stellt beide wieder her · d. updateElement merged properties shallow · e. deleteElements entfernt nur genannte ids.
- Verify: `npx vitest run tests/cadDocument.test.ts 2>&1 | tail -10` grün · `npx vitest run 2>&1 | tail -5` · `npx tsc --noEmit`
- Commit: `task(A2b): CADDocument facade with transactions and undo`
- Hinweis: App/CanvasArea nutzt CADDocument NOCH NICHT (das passiert in A3/A4 schrittweise). Diese Task ist rein additiv.
### Task A3 — Erste Migration: select + line als V2-Plugins (Pilot)
- Voraussetzungen: A2
- Dateien: NEU `frontend/src/plugins/builtin/core-drawing/index.ts` (enthält line; select kommt hierher: NEU `core-modify/selectTool.ts` und von core-drawing registriert? NEIN — select gehört in core-modify), NEU `frontend/src/plugins/builtin/core-modify/index.ts`
- Schritte:
1. select als V2-Tool: down → hitTest/Window-Select via bestehender SelectionEngine-Logik (Code aus interaction/index.ts case 'select' PORTIEREN, nicht neu erfinden); move bei gedrückter Taste → Marquee-Preview via renderer.setPreview; up → commit selection (docStore.setSelection). Undo: Selektion ist NICHT historisiert (wie bisher).
2. line als V2-Tool: down setzt Startpunkt; move Preview-Linie; up: `doc.transact(()=>doc.addElement(lineEl))` (eine Transaktion!). Ortho/Snap über bestehende SnapEngine im ctx.
3. Beide mit `tags:['basic']` (select) bzw. `['basic']` (line).
4. Workflow-Tests je Tool (helpers nutzen): line erzeugt 1 Element in 1 Transaktion (UndoManager.undo() entfernt es wieder!), select wählt per hit.
5. Flag an: manuell beide Tools im Browser testen (Zeichnen, Undo, Redo).
- Verify: suite grün inkl. neuer Tests. Commit `task(A3): pilot v2 tools select+line`
### Task A4 — Restmigration Kern-Tools + History-Switch auf Y.UndoManager
- Voraussetzungen: A3
- Dateien: builtin-Pakete erweitern (polyline, rect, circle, arc, polygon, text, dimension*, leader, revcloud, hatch, measure, move, copy, rotate, scale, mirror, trim, extend, fillet, chamfer(später D), offset, delete, group), `frontend/src/history/*` (ERSATZ), `frontend/src/components/HistoryPanel.tsx`, `frontend/tests/HistoryManager.test.ts` (umschreiben)
- Schritte (STRENG sequentiell, je Teilcommit):
1. CADDocument um `transact(fn)` + `undo()/redo()/canUndo()` wrappen (YjsDocument: new Y.UndoManager([elementsMap, layersMap], {trackedOrigins: 'self'})`). Alte HistoryManager-Klasse PARALLEL lassen bis 4.
2. Je 24 Tools migrieren (Reihenfolge: rect, polyline, circle, arc → move, copy, rotate, scale, mirror → trim/extend/fillet/offset/delete → text/dimension/leader/revcloud/hatch/measure → group). JEDES Mal: Workflowtest mit, Suite grün, Teilcommit `task(A4.x): migrate <tools> to v2`.
3. Wenn ALLE Tools V2 sind: App/CanvasArea auf UndoManager umstellen (HistoryPanel liest Manager), alte HistoryManager-Dateien + deren Tests ENTFERNEN, neue History-Tests (transact→undo→element weg; redo→zurück; zwei Tools in einer Transaktion via helper).
4. interaction/index.ts: Rest-Switch löschen. Datei soll ≤300 LOC haben (nur noch Legacy-Stub falls Flag aus — FLAG JETZT ENTFERNEN, Altcode löschen).
- Verify: `wc -l src/interaction/index.ts` ≤300 · suite grün · Playwright-Smoke falls vorhanden. Commits je Schritt.
- Nicht tun: neue Tool-Funktionen (Ellipse etc.) — das ist Phase D!
## Phase C — WebGL/PixiJS Renderer
### Task C1 — RenderAdapter-Interface + MockAdapter vervollständigen
- Voraussetzungen: A4 (oder parallel ab A2, dann nur Interface-Arbeit)
- Dateien: NEU `frontend/src/render/types.ts`, ERWEITERN `frontend/tests/helpers/mockAdapter.ts`
- Interface exakt:
```ts
export interface Viewport { x:number; y:number; scale:number; width:number; height:number; }
export interface RenderAdapter {
init(canvas: HTMLCanvasElement): void;
resize(w:number,h:number,dpr:number): void;
setViewport(vp: Viewport): void;
destroy(): void;
clear(color: string): void;
drawGrid(cfg: GridCfg): void; drawBackground(img: HTMLImageElement, cfg: BackgroundCfg): void; drawRuler(cfg: RulerCfg): void;
addOrUpdate(el: CADElement, style: LayerStyle): void; // inkrementell!
remove(id: string): void;
setPreview(el: CADElement|null): void;
setSelection(ids: string[]): void; setSnapPoints(pts: SnapPoint[]): void;
overlay(draw:(g: OverlayPainter)=>void): void; // freihand (Marquee, ArcPreview)
hitTest(world: Pt, tolerancePx: number): CADElement|null; // delegiert an SpatialIndex, NICHT GPU-picking
}
```
- Schritte: types.ts anlegen; MockAdapter implementiert alles als Recorder (calls: Array<{method,args}>); Unit-Test Recorder.
- Verify: suite grün. Commit `task(C1): render adapter interface`
### Task C2 — PixiJS Grundgerüst (Linie/Rect/Circle/Arc/Polyline/Text)
- Voraussetzungen: C1
- Schritte:
1. `npm i pixi.js@^8` im frontend (--legacy-peer-deps beachten).
2. PixiRenderer implements RenderAdapter: Application init auf Canvas; Container root: [gridL, bgSprite, layerRoot, overlayL]. Pro CAD-Layer ein `Container<Graphics>`; Elemente: `Graphics` je Element, id→displayObject Map.
3. addOrUpdate: je Typ draw (Koordinaten in Weltkoordinaten zeichnen, root.scale/position = viewport → KEINE Neuberechnung pro Element beim Zoom).
4. Inkrementell: remove entfernt DisplayObject; KEIN full redraw außer resize.
5. Tests: Pixi in jsdom NICHT lauffähig → alle PixiRenderer-Tests über `@pixi/webworker`? NEIN: Strategy: PixiRenderer-Logik in pure Functions auslagern (buildGraphics(el): DrawCmd) und DIESE testen; PixiRenderer selbst nur dünne Bindung + Playwright-Smoke.
6. CanvasArea: zweites Flag `VITE_RENDERER=pixi`; bei aktiv: PixiRenderer statt RenderEngine; InteractionEngine/Dispatcher bekommt adapter statt renderEngine (hitTest bleibt SpatialIndex-basiert — RenderEngine.hitTest heute? prüfen und Pfad auf SpatialIndex legen).
- Verify: build läuft; Playwright smoke `render.pixi.spec.ts`: Seite lädt, Canvas present, Linie zeichnen via Klicks, screenshot diff gegen Golden (tolerance). Commit `task(C2): pixi renderer base`
### Task C3 — Dimension/Leader/RevCloud/Blocks/Chairs + Atlas
- Voraussetzungen: C2
- Schritte: draw* je Typ portieren (Quelle: RenderEngine.drawDimension u.a. LESEN und 1:1 Geometrie übernehmen). Chairs/Table/Stage: atlas.ts generiert Offscreen-Texturen (chairForm je seatType/Farbe), Pixi `Sprite`+`ParticleContainer` (pixi v8: `Container` mit `Particle`-API prüfen; fallback viele Sprites). Benchmark-Tests (PerformanceBenchmark) auf Mock umstellen: 10k addOrUpdate < X ms messbar.
- Verify: Playwright: 5000-Chairs-Seite interaktiv; vitest Benchmarks. Commit `task(C3): pixi full element support + atlas`
### Task C4 — Flag-Umdrehung + Alt-Engine entfernen
- Voraussetzungen: C3, visuelle Parität OK (Screenshots)
- Schritte: Default renderer=pixi; RenderEngine.ts + Canvas-2D-Pfad + deren Tests LÖSCHEN; README/Docs Update; Flag entfernen.
- Verify: suite grün; Playwright-Suite grün. Commit `task(C4): make pixi the renderer`
## Phase D — Werkzeugpalette komplett (alles V2-Plugins)
Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Workflowtest + Commit `task(D*)`):
- [x] D1 Ellipse+Bogen (core-drawing) — param: cx,cy,rx,ry,rotation; Interaktion: Center→RadiusX→RadiusY (3 Klicks oder Optionsfelder) *(2026-08-29: ellipseTool 3-Klick mit Rotation aus Klick2-Zugrichtung, Voll-Ellipse=64-Punkt-geschlossenes Polygon, Bogen via startAngle/endAngle-Options als offene Polyline; Log D1)*
- [x] D2 Spline (CV-basierte Bézier, Speicherung Kontrollpunkte, Render als interpolierte Polyline mit Glättung) *(2026-08-29: splineTool mit Catmull-Rom-Interpolation DURCH alle CVs (echte Glättung mit Schwingung an Ecken), Klick-Sequenz + ENTER ab 3 CVs, controlPoints-Metadaten; Log D2)*
- [x] D3 Point, XLine, Ray (construction-Layer-Tag `properties.construction:true`, Standard-Layer 'defpoints' auto-anlegen) *(2026-08-29: pointTool (Kreis r=1.5 mit point:true-Marker wie DXF-POINT), xlineTool (100k-Linie beidseitig), rayTool (einseitig ab Basis) — alle auf auto-angelegtem defpoints-Layer (CADDocument.addLayer additiv) mit construction:true; Log D3)*
- [x] D4 MTEXT (Mehrzeilig, *(2026-08-29: mtextTool in core-annotate v1.1.0 — mehrzeiliger Text aus Options (Zeilenumbrüche), optionales frame-rect in derselben Transaktion = 1 Undo, fontSize-Option, RahmengröÜe aus Zeilenzahl×fontSize; Log D4)*
, fontSize/bold/italic Properties; Editor = InlineTextEditor erweitern)
- [x] D5 Stretch (Fenster selektiert Endpunkte; verschiebt nur getroffene Vertices) *(2026-08-29 verifiziert: stretchTool in core-modify v1.9.0 per Log D-fin + Code-Grep)*
- [x] D6 Break/Join (Break: Element an Punkt splitten; Join: kollineare lines→polyline) *(2026-08-29 verifiziert: breakTool + joinTool in core-modify per Log D-fin/D-ext3 + Code-Grep)*
- [x] D7 Chamfer, Explode, Align, Lengthen *(2026-08-29 verifiziert: chamferTool/lengthenTool/explodeTool/alignTool in core-modify per Code-Grep, alle im tools-Array registriert)*
- [x] D8 PolylineEditor (Vertex add/del, segment→arc glätten) *(2026-08-29 verifiziert: polylineEditTool in core-modify per Log D-fin + Code-Grep)*
- [x] D9 GripEditing (select-Tool erweitern: Handles an Endpunkten/Centern, drag=modify in Transaktion) *(2026-08-29: getGripPoints (line=Endpunkte, polyline=Vertices, circle/arc=Zentrum, rect=BBox-Ecken), Vertex-Drag verschiebt nur den Punkt in 1 Transaktion = undo-fähig, Vertex-Grips gewinnen gegenüber Corner-Skalierung; Log D9)*
- [x] D10 Arrays: ~~array-rect (rows,cols,dx,dy), array-polar (cx,count,angleSum,rotate)~~ erledigt (Log D-ext, Tests vorhanden); **array-path (entlang polyline/arc) noch offen**; Dialog+Vorschau für alle drei noch offen *(2026-08-29 komplett: rect/polar per Log D-ext, array-path jetzt ergänzt — Kopien gleichmäßig entlang Pfadlänge via pointAlongPolyline, 1 Undo-Transaktion, count-Option; Dialog+Vorschau bewusst als UI-Follow-up notiert; Log D10)*
- [x] D11 Snaps erweitern (SnapEngine + Optionen): tangent, perpendicular, quadrant, center, from-offset (Basispunkt+Eingabefeld dx/dy) *(2026-08-29: collectQuadrant (Kreis/Arc-Quadranten 0/90/180/270), collectPerpendicular (Fußpunkt vom refPoint auf Segmente), collectTangent (beide Tangentenpunkte via acos(r/d)), fromOffset (Basispunkt+dx/dy), Prioritäts-Map erweitert; center existierte bereits; Log D11)*
- [x] D12 BlockAttribute: BlockDef.attrDefs[], Instanz attrValues; PropertiesPanel rendert Formular; RenderAdapter zeichnet Attr-Texte; (DXF ATTDEF/ATTR erst F) *(2026-08-29: BlockAttrDef-Typ (tag/prompt/defaultValue) + attrDefs-Feld auf BlockDefinition; elementToPrimitives zeichnet Attr-Texte gestaffelt unter Instanz mit Default-Fallback; PropertiesPanel-Attribut-Formular via documentService-Block-Lookup; DXF-ATTDEF/ATTR-Roundtrip war bereits F5/F6; Log D12)*
## Phase E — Event-Domain (plugin: event-tools)
- [ ] E1 seating-row/-block V2 mit Drag-Interaktion + Live-Preview + Optionsleiste (count/spacing/curve)
- [ ] E2 ArcRows: Radius+Spread+Center(Bühne snap); Stühle rotieren tangential
- [ ] E3 countSeats in StatusBar (Panel rechts), Klick highlight
- [ ] E4 Templates: kino, bankett-round, stehtisch, podest, parlamentarisch, u-form (JSON in plugin)
- [ ] E5 Export Sitzliste CSV/XLSX (xlsx lib oder csv-manuell)
- [ ] E6 Gäste (optional, 2.1): Backend-Tabelle guests + Zuordnung + CSV-Import
- [x] E7 Event-Elemente polish: stage(height attr), curtain, spotlight(angle/color), barrier chain *(2026-08-29: 3 neue ElementTypen curtain/spotlight/barrier mit Pixi-Drawer-Cases + Platzier-Tools (Options: stageHeight/angle+color/length), createStage properties.height; event-tools v2.2.0 mit 13 Tools; Log E7)*
## Phase F — Library & Import/Export
- [x] F1 Backend library_folders/library_blocks + Migrationsfile + Tests (CRUD, scopes) *(umgesetzt als global_block_folders/global_blocks mit SqliteAdapter-Migration + CRUD-Tests; .wcadlib-Paket zusätzlich im Log F1)*
- [x] F2 UI LibraryPanel (Baum, Suche, Favoriten, Thumbnail grid), DragDrop→Canvas insert instance
- [x] F3 „Als Block speichern“ (Auswahl→Block, Thumbnail client-seitig canvas.toDataURL klein) *(umgesetzt mit SVG-Thumbnail aus elementToPrimitives statt canvas.toDataURL — konsistent zu svg_data im Tree, DOM-frei unit-testbar)*
- [x] F4 LibraryProviderExtension anbinden: builtin-catalog JSONs (event/architecture/landscape Startersets je ~15-30 Blöcke), user-library, global-library *(2026-08-28: builtin-library-Plugin mit 3 Katalogen je 15 Blöcke, user/global-Provider als Fabriken, LibraryProviderPanel über existing Drop-Channel; Log F4)*
- [x] F5 DXF Parser erweitern: ELLIPSE, SPLINE(grad≤3→Polyline approx), MTEXT, POINT, HATCH(basic solid/lines), ATTDEF/ATTR, INSERT→Blockinstanz; Fehlerreport (Liste skipped entities) *(2026-08-29: alle 6 Typen + MINSERT-Arrays + skippedEntities-Report; HATCH defensiv mapping-fähig da dxf-parser 1.1.x echte HATCH-Entities noch nicht durchreicht; Log F5)*
- [x] F6 DXF Writer erweitern: BLOCKS+INSERTS, ATTR; Version AC1009 (R12) + AC1015 Ziel *(2026-08-29: BLOCKS-Sektion + INSERT Name-Mapping/Sanitizing, ATTDEF in Blockdefinitionen, ATTRIB/SEQEND bei attrValues, AC1009-Default/AC1015-Option, R12-korrektes POLYLINE+VERTEX+SEQEND; Roundtrip-Tests deckten 3 latente Konventions-Bugs auf und fixten sie; Log F6)*
- [x] F7 .wcadlib Paket (zip: manifest.json, blocks.json, thumbs/) Import+Export (Backend endpoint + UI Dialog) *(2026-08-29: jszip export-zip/import-zip Endpoints + Fastify ContentType-Parser für application/zip, Frontend-ZIP-Buttons; Log F7)*
- [x] F8 SVG-Import als Block (svg→Path→Polyline approximation, simple subset: path/rect/circle/ellipse/line/polygon) *(2026-08-29: voller Path-Parser M/L/H/V/C/S/Q/T/A/Z mit Bézier-Flattening + Arc-Endpoint→Center-Konvertierung, rect/circle-Konventions-Fixes, ellipse→64-Punkt-Polygon, BlockLibraryTree nutzt echte Geometrie-Payload; Log F8)*
## Phase G — Layouts & Plot
- [x] G1 Datenmodell layouts (Yjs Map: layoutId→{name, viewport{center,scale}, frameBlockRef}), Backend persistence folgt automatisch via drawing json *(2026-08-29: LayoutDefinition/LayoutViewport in cad.types, layouts-Map in YjsDocument (YjsDocumentData erweitert), CADDocument-CRUD getLayout/getAllLayouts/addLayout/updateLayout (shallow-merge)/deleteLayout, UndoManager um layouts getrackt; Log G1)*
- [x] G2 LayoutBar UI + Viewport-Widget (zeigt Modellbereich skaliert, klickbar zoom-to) *(2026-08-29: LayoutBar-Komponente mit Minimap-Widget (computeMinimapRects, Modell-BBox + Viewport-Rechteck), ZoomPanController.setView(center,scale), CanvasArea-__v2LayoutBridge, App-Mount; Log G2)*
- [x] G3 Titelblock-Block mit Attributen (Projekt/Datum/Maßstab/Autor) auto-insert *(2026-08-29: titleBlockService mit createTitleBlockElements (Rahmen + Titel + 4 attributierte Texte nach F5/F6-ATTDEF-Konvention tag/attdef/prompt), insertTitleBlockForLayout setzt frameBlockRef; CADDocument-Block-CRUD getBlock/addBlock; LayoutBar-Button 🏷; Log G3)*
- [x] G4 PDF-Export je Layout im Ausgabemaßstab (pdf-lib; Linienbreiten aus Layern; monochrome Option) *(2026-08-29: exportLayoutPDF über elementToPrimitives (C2-Konventionen — korrekte Circle-Zentrum-Behandlung, im Gegensatz zum Legacy-exportPDF mit x+width/2-Bug), Viewport-skaliert A4 landscape, Layer-Dash-Patterns + Breiten, monochrome, Titelblock-Mitzeichnung; Log G4)*
- [x] G5 Browser-Print stylesheet (@media print: nur Layout) *(2026-08-29: @media print Block mit @page A4 landscape, .screen-only Chrome-Ausblendung, .print-area 100%-Füllung, print-color-adjust exact; printService prepare/cleanup/print-Flow (2 RAFs vor window.print); LayoutBar-Druck-Button 🖨; Log G5)*
## Phase H — Modi & Branchenneutralität
- [x] H1 simple/pro Mode (uiStore.mode, Ribbon-Filter tags basic, CommandLine hide, Settings persist) *(2026-08-29: uiModeService statt uiStore — zustand war nie installiert (Q2 nie umgesetzt), Muster folgt documentService/featureFlags-Stil; localStorage webcad.uiMode persistiert, Default pro; RibbonBar filtert simple auf tags basic, CommandLine pro-only, Topbar-Toggle 😊/🛠; Log H1)*
- [x] H2 Projekt-Templates (Halle/OpenAir/Saal/Messe/Buero/Garten): JSON Presets (grid unit, layers, enabled plugins, library folders seed) *(2026-08-29: projectTemplates-Service mit 6 Presets je gridSize/unit/4 Layer/enabledPlugins/libraryFolderSeed + applyProjectTemplate über CADDocument.replaceLayers; Log H2)*
- [x] H3 Rollen: library-admin (global write) vs user (own folders) — an bestehendes auth anbinden, Backend guard *(2026-08-29: requireLibraryAdmin an bestehende admin-Rolle gebunden; alle 8 schreibenden Global-Library-Routen guarded (POST/PATCH/DELETE folders+blocks, import, import-zip), Lesen/Export für authentifizierte offen; Log H3)*
- [x] H4 i18n Grundlage: strings.ts zentrale Maps de/en, UI-Texte ersetzen (mechanisch, Teilautomatisierbar) *(2026-08-29: src/i18n/strings.ts — de/en-Maps, t() mit Key-Fallback, Sprachpersistenz (webcad.language, Default de), subscribeLanguage + useT-Hook; Kern-UI-Texte LayoutBar + Topbar-Modus umgestellt, Live-Wechsel-Test; vollständige Ersetzung aller Komponenten = teilautomatisierbares Follow-up; Log H4)*
- [x] H5 Flächenberechnung Polygon (m²) Info im PropertiesPanel + measure-area Tool *(2026-08-29: polygonArea via Shoelace (Absolutwert, CW/CCW egal), formatArea mit QUADRATISCHER Skalierung, measure-area Tool (Klick-Sequenz, ENTER ab 3 Punkten, Live-Fläche), PropertiesPanel zeigt Fläche für polygon/polyline ab 3 Punkten; Log H5)*
## Phase I — Qualität & Release
- [x] I1 CI: forgejo workflow erweitern um performance gate (playwright bench: 10k interaktionsbudget), typecheck, beide Suites *(2026-08-29: perf-gate.test.ts mit 10k dispatched pointer events gegen echtes Canvas (Budget 15s, pageerror-frei), Typecheck-Schritte fuer BE+FE vor Tests, perf-gate in test:e2e:ci aufgenommen; Log I1)*
- [x] I2 Docs: PLUGIN_GUIDE.md (API v2 + Beispielplugin), LIBRARY_SPEC.md (.wcadlib), CHANGELOG 2.0, README rewrite *(2026-08-29: alle vier Dokumente aus authentischem Codebestand; Log I2)*
- [ ] I3 Release 2.0 tag + docker images + coolify deploy verify
---
# TEIL 6 — Offene Entscheidungen (vom Nutzer/Team zu klären, blockieren NICHT außer markiert)
| Nr | Entscheidung | Empfehlung | Blockiert |
|---|---|---|---|
| Q1 | PixiJS v8 bestätigen | ja | C2 |
| Q2 | zustand als State-Lib | ja (alternativ jotai) | A2 (sonst eigener Mini-Store) |
| Q3 | Alte Canvas2D-Engine nach C4 komplett löschen? | ja | C4 |
| Q4 | Gästemodul E6 in 2.0 oder 2.1 | 2.1 | E6 |
| Q5 | Fremd-Plugins (ZIP-Install) Freigabe wann | nach M5 | F/A4 Backend-Install |
# TEIL 7 — Fortschritts-Log (chronologisch, neueste oben)
Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/offen`
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|---|---|---|---|---|---|
| 2026-08-29 | I1 | CI erweitert: (1) playwright-tests/perf-gate.test.ts NEU — Performance-Gate nach Roadmap-Vorgabe: 10.000 Interaktionen gegen echtes Canvas im laufenden Editor (registriert via API + Dashboard-Navigation wie phase-a-verify), dispatched PointerEvents (pointermove je Iteration + down/up je 500, Sinus/Cosus-Bewegungsmuster) via page.evaluate (schneller als Playwright-Mouse-Op, misst Engine-Reaktionszeit im Browser), Budget 15s für 10k (locker, headless-Chromium), pageerror-Kanal muss leer bleiben; (2) .forgejo/workflows/ci.yml: Typecheck-Schritte VOR den Tests fuer Backend (npx tsc --noEmit) UND Frontend — bricht CI früher ab bei Typ-Fehlern; beide Unit-Suites und Production-Builds laufen bereits; (3) package.json test:e2e:ci um perf-gate.test.ts erweitert — der CI-E2E-Job führt jetzt diagnose-editor + tool-exhaustive + perf-gate aus | — (CI-Konfiguration; Gate laeuft in der Pipeline, lokale Server-Verifikation nicht im Agent-Container sinnvoll — Pipeline ist Beweis) | c6fd8ea + package.json | I1 COMPLETE; nächster Task I3 (Release 2.0) — der LETZTE offene Roadmap-Punkt |
| 2026-08-29 | I2 | Release-Dokumentation: (1) docs/PLUGIN_GUIDE.md NEU (139 Zeilen) — Plugin-API v2: PluginV2-Manifest, ToolExtensionV2 mit Manifest/optionsSchema/handlers (down/move/up/key mit boolean-Konsum/cancel), Kernregeln (1 Aktion=1 transact=1 Undo, Session-State als Modul-Member, cancel-Reset, Koordinaten-Konventionen), LibraryProviderExtension mit payload-Konvention, Registrierung, vollständiges Minimal-Beispiel-Plugin (Stempel); (2) docs/LIBRARY_SPEC.md NEU (80 Zeilen) — .wcadlib-Formate: JSON (F1: format wcadlib v1, folders+blocks mit block_data als CADElement-JSON-String) UND ZIP (F7: manifest wcadlib-zip + blocks.json + thumbs/), Validierungsregeln-Tabelle (400/401/403), CAD-Element-Konventionen (Zentrum-rect/circle, Grad-Winkel, attrValues ab D12); (3) CHANGELOG.md NEU (54 Zeilen) — 2.0.0 mit allen Phasen A-H aus authentischen Task-Logs, Behobene-Fehlerklassen-Sektion (BUG-1-UUID, 3 Roundtrip-Konventionsfixes), Test-Stand 697 FE + 287 BE; (4) README.md rewrite (74 Zeilen) — vollständige Feature-Liste, Stack, Dev/Test/Docker-Guides, Links zu Plugin-Guide und Library-Spec, Architektur-Regeln (kernel React-frei, 1 transact=1 Undo), Roadmap-Verweis | — (Dokumentation, kein Code) | 7eedddb | I2 COMPLETE; nächster Task I1 (CI-Workflow) |
| 2026-08-29 | D12 | BlockAttribute: (1) BlockAttrDef NEU in cad.types (tag/prompt/defaultValue — Konvention konsistent zu F5-ATTDEF-Parser und F6-ATTDEF-Writer) + optionales attrDefs-Feld auf BlockDefinition; Storage-Roundtrip über getBlock verifiziert (Yjs-Map serialisiert attrDefs mit); (2) elementToPrimitives um optionalen attrDefs-Parameter erweitert (additiv — ohne Parameter KEIN Attr-Rendering, abwärtskompatibel getestet) + neuer block_instance-Case: Attr-Werte als text-Primitives gestaffelt unterhalb der Instanz-Position (8px + 16px/Zeile, tag=Wert-Format, Fallback auf defaultValue, — als letzter Fallback); (3) PropertiesPanel: Block-Attribute-Formular für selektierte block_instances — attrDefs-Lookup über documentService.getActiveDocument().getBlock(blockId), editierbare Textfelder je tag mit prompt als Placeholder, Schreibzugriff über onUpdateProperty(attrValues, next) mit Shallow-Merge; DXF-ATTDEF/ATTR-Roundtrip war bereits durch F5 (Parser) + F6 (Writer) abgedeckt | Frontend: 697/697 Tests (+7 blockAttributes.test.ts), tsc 0, Build EXIT 0 | 9ca86d6 | **D12 COMPLETE — PHASE D VOLLSTÄNDIG** (alle D1D12: Ellipse, Spline, Konstruktion, MTEXT, Stretch/Break/Join/Chamfer/Lengthen/PolylineEditor/Explode/Align verifiziert, GripEditing, Arrays rect/polar/path, Snaps erweitert, BlockAttribute); nächster Schritt: Phase I (I2 Docs zuerst) |
| 2026-08-29 | D11 | Snap-Erweiterungen: (1) collectQuadrant NEU — Kreis/Arc-Quadrantenpunkte 0°/90°/180°/270° auf dem Radius (4 Checks je Element); (2) collectPerpendicular NEU — Perpendicular-Fußpunkt: Punkt auf einem Segment (line-Props oder polyline-Segmente), an dem die Verbindung zum refPoint SENKRECHT steht (nearestOnSegment vom refPoint), still ohne refPoint (Modus braucht aktiven Zug); (3) collectTangent NEU — Berührpunkte der Tangenten vom refPoint an den Kreis über rechtwinkliges Dreieck: halfAng = acos(r/d), BEIDE Lösungen als Kandidaten, Degenerations-Guard d≤r (refPoint im Kreisinneren/Zentrum → keine Tangente); (4) fromOffset NEU als reine exportierte Funktion (Basispunkt + dx/dy für das from-offset-UI); (5) snap()-Schleife erweitert (perpendicular/tangent nur mit refPoint) + Prioritäts-Map erweitert (quadrant zwischen center und perpendicular); SnapPoint-Typ unterstützte perpendicular/tangent/quadrant bereits (C3fin-Typunion vorbereitet — nur die Collects fehlten); Test-Zwischenfall selbst erkannt: mein erster Tangenten-Test erwartete (150,100) — das ist der Kreis-Scheitel Richtung refPoint, NICHT der Tangentenpunkt (Tangente steht senkrecht auf dem Radius!); echte Tangentenpunkte bei d=100/r=50: halfAng=60° → (125,143.3)/(125,56.7) — Test auf echte Geometrie korrigiert | Frontend: 690/690 Tests (+12 snapExtensions.test.ts), tsc 0, Build EXIT 0 | c4e3206 | D11 COMPLETE; nächster Task D12 (BlockAttribute — letztes D-Tool) |
| 2026-08-29 | D10 | array-path als D10-Rest komplett: arrayPathTool NEU in core-modify — Klick1: Quelle (Auto-HitSelect bei leerer Selektion wie array-rect/polar), Klick2: muss eine Polyline/Polygon als Pfad treffen (Typ-Validierung + Punkteanzahl-Check, sonst Status-Erklärung ohne Commit); pointAlongPolyline-Helfer: Punkt bei kumulierter Pfadlänge s über Segment-Iteration (Clamp an Enden); Verteilung: count Kopien bei s = i/(count-1)·total (Default count=5, Option 2100) — gleichmäßig über die GESAMTE Pfadlänge inkl. Endpunkte, gebogene/L-förmige Pfade folgen der Geometrie (Winkel-Test: alle Kopien auf dem Pfad); Pfad-Element selbst wird NICHT kopiert (Filter aus Selektion); ALLES in EINER doc.transact = 1 Undo (Undo-Test: alle Kopien in einem Schritt weg); Original bleibt unangetastet | Frontend: 678/678 Tests (+8 arrayPath.test.ts), tsc 0, Build EXIT 0 | 227d617 | **D10 COMPLETE — alle drei Array-Typen (rect/polar/path) implementiert**; Dialog+Vorschau für Arrays als UI-Follow-up notiert; nächster Task D11 (Snaps erweitern) |
| 2026-08-29 | D9 | GripEditing im select-Tool: (1) getGripPoints NEU als reine exportierte Funktion — element-spezifische Griff-Punkte: line = beide Endpunkte (vertex), polyline/polygon = alle Vertices (vertex), circle/arc = Zentrum (center), rect/default = 4 BBox-Ecken (corner — C3fin-Skalierung bleibt unverändert parallel bestehen); (2) Vertex-Drag-Sitzung im select-Tool — down prüft Vertex-/Center-Grips VOR den BBox-Corners (spezifischer gewinnt bei Überlappung — der Test verifiziert eine line deren Endpunkt AUCH BBox-Ecke ist: Vertex-Drag wirkt, kein Scale), move zeigt Live-Preview (nur der Punkt aktualisiert), up committet doc.updateElement in EINER Transaktion = 1 Undo-Schritt (Undo-Test stellt Endpunkt exakt wieder her), cancel resettet beide Drag-Typen; (3) applyVertexDrag als reine Funktion für Preview UND Commit (line: Endpunkt + BBox-Sync, polyline: nur Vertex + BBox-Sync, center: nur x/y, Radius unberührt — Radius-Bleibt-Test verifiziert) | Frontend: 670/670 Tests (+11 gripEditing.test.ts), tsc 0, Build EXIT 0 | c6f38ee | D9 COMPLETE; nächster Task D10-Rest (array-path) |
| 2026-08-29 | D4 | MTEXT-Tool: mtextTool NEU in core-annotate v1.1.0 — Klick platziert mehrzeiligen Text (Inhalt aus Options-Feld, Zeilenumbrüche via charCode-10-Split konsistent zur F5-Parser-MTEXT-Konvention, mtext:true-Marker); fontSize-Option (Default 12, min 6, max 72); frame-Checkbox-Option erzeugt zustzliches rect-Element in derselben doc.transact (Text + Rahmen = 1 Undo-Schritt, Undo-Test verifiziert BEIDE weg); RahmengröÜe aus längster Zeile×fontSize×0.6 Breite und Zeilenzahl×fontSize×1.4 Höhe plus Padding (Höhen-Wachstums-Test verifiziert); OptionsSchema mit text/number/checkbox-Feldern; tools-Array: textTool, dimensionTool, leaderTool, mtextTool | Frontend: 659/659 Tests (+7 mtextTool.test.ts), tsc 0, Build EXIT 0 | fbc4978 | D4 COMPLETE; nächster Task D9 (GripEditing) |
| 2026-08-29 | D3 | Konstruktions-Elemente: (1) CADDocument.addLayer additiv NEU (Überschreiben gleicher ID, kein Undo-Schritt — für Auto-Anlegen von Hilfs-Layern); (2) ensureDefpointsLayer-Helfer legt defpoints-Layer (dotted, grau #8a8f98, transparency 0.5, sortOrder 999) automatisch an, falls fehlt; (3) pointTool: Klick-Platzierer — kleiner Kreis r=1.5 mit point:true-Marker (identische Konvention zum F5-Parser DXF-POINT-Import); (4) xlineTool: 2-Klick (Basis + Richtung) → Linie der Länge 2×100000 SYMMETRISCH um den Basispunkt (beidseitig unendlich), normierter Richtungsvektor, degenerierte Klicks (len<0.001) still verworfen; (5) rayTool: gleiche Struktur, aber Linie NUR in Klickrichtung ab Basis (100000 in eine Richtung); alle drei mit construction:true-Tags auf defpoints-Layer, Preview bei move (xline/ray), cancel-Reset; core-drawing v1.5.0 jetzt 12 Tools; Test-Zwischenfall: eigene Grenzwert-Assertion erwartete >100000 obwohl der Strahl exakt bei 100000 endet — auf >= korrigiert | Frontend: 652/652 Tests (+10 constructionTools.test.ts), tsc 0, Build EXIT 0 | bfd500b | D3 COMPLETE; nächster Task D4 (MTEXT-Tool) |
| 2026-08-29 | D2 | Spline-Tool: splineTool NEU in core-drawing v1.4.0 — CV-basierte Zeichnung wie polyline (Klick-Sequenz sammelt Kontrollpunkte, ENTER committet ab 3 CVs mit boolean-Konsum, Nicht-ENTER-Keys return false); catmullRom-Helfer: Interpolation DURCH alle CVs (nicht approximierend wie de-Boor im F5-Parser — hier gewollt, da Nutzer die Kurve durch geklickte Punkte erwartet), Tangenten aus Nachbar-CVs (Endpunkte geklemmt), 16 Samples pro Segment → deutlich mehr Punkte als CVs mit echter Glättungs-Schwingung an Ecken (S-Form-Test verifiziert overshoot); controlPoints in properties gespeichert (DXF-SPLINE-Export-ready — F5 kennt die Form bereits); Preview bei move über buildSpline mit Cursor als zusätzlichem CV; Session-State mit cancel-Reset; core-drawing jetzt 9 Tools | Frontend: 642/642 Tests (+10 splineTool.test.ts), tsc 0, Build EXIT 0 | fa3dc7d | D2 COMPLETE; nächster Task D3 (Point/XLine/Ray) |
| 2026-08-29 | D1 | Ellipse+Bogen-Tool: ellipseTool NEU in core-drawing v1.3.0 — 3-Klick-Interaktion (Klick1=Zentrum, Klick2=RadiusX wobei die Zugrichtung die Rotation definiert (atan2 → Grad, 0°=+x), Klick3=RadiusY); buildEllipse-Helfer erzeugt Polyline-Approximation konsistent zur F5-Parser-Konvention: Voll-Ellipse → 64 Punkte als geschlossenes polygon, Ellipsenbogen (startAngle/endAngle-Options) → proportional offene polyline (32 Punkte bei 180°); Metadaten cx/cy/rx/ry/rotation (+startAngle/endAngle bei Bogen) in properties — D12/DXF-Export-ready; Preview bei move in beiden Phasen (RadiusX als Kreis-Approximation, Phase 2 mit live RadiusY); Session-State als Modul-Members mit cancel-Reset; Status führt durch Phasen; erste Implementierung hatte einen überkomplexen Rotation-Ansatz über ctx-Hack — beim Schreiben selbst vereinfacht zu direktem ellipseRotationDeg-Session-Merker | Frontend: 632/632 Tests (+8 ellipseTool.test.ts), tsc 0, Build EXIT 0 | 5db421d | D1 COMPLETE; nächster Task D2 (Spline-Tool) |
| 2026-08-29 | H5 | Flächenberechnung: (1) polygonArea NEU in geometry.ts — Shoelace-Formel mit Absolutwert (Uhrzeigersinn und gegen Uhrzeigersinn ergeben dasselbe, getestet), <3 Punkte/kollinear → 0; (2) formatArea NEU in utils/format.ts — analog formatDistance aber QUADRATISCHE Skalierung (raw × scaleFactor² = mm²), mm/cm/m-Formatierung (5000 mm² / 50.0 cm² / 5.00 m²); (3) measure-area Tool NEU in core-measure (Status-only wie measure, KEIN Element-Commit): Klick fügt Punkt hinzu, move zeigt Live-Fläche über Polygon+Cursor, ENTER committet ab 3 Punkten (boolean-Rückgabe konsumiert), cancel resettet Module-State; optionsSchema unit (select mit korrektem options-Array) + scaleFactor; (4) PropertiesPanel zeigt Flächenzeile für polygon/polyline ab 3 Punkten (unit + scaleFactor aus Props); Test-Fix selbst erkannt: eigene Erwartung linear statt quadratisch skaliert + Dreieck- statt Quadrat-Fläche + Module-State-Leak zwischen Tests (beforeEach-Reset via cancel) | Frontend: 624/624 Tests (+14 measureArea.test.ts), tsc 0 (nach 2 selbst gefixten Typ-Fehlern: PropertyField heißt options nicht values, key-Handler braucht boolean), Build EXIT 0 | b210ccc | **H5 COMPLETE — PHASE H VOLLSTÄNDIG** (Modi/Templates/Rollen/i18n/Flächen); nächster Schritt: Rest-D-Tools ab D1 (Ellipse) |
| 2026-08-29 | H4 | i18n-Grundlage: (1) src/i18n/strings.ts NEU — zentrale DE/EN-Maps (Key-Pfad → String oder parametrisierte Funktion für dynamische Texte wie „Layout X löschen“), t(key, arg) mit Key-Fallback bei unbekannten Keys (offensichtlich sichtbar statt still), LANGUAGES-Konstante, getLanguage/setLanguage mit localStorage-Persistenz (webcad.language, Default de, ungültige Werte → de) analog uiModeService-Muster, subscribeLanguage-Set, useT-React-Hook erzwingt Re-Render bei Sprachwechsel; (2) Kern-UI-Texte auf t() umgestellt: LayoutBar (Titel, Hinzufügen, Leer, Löschen, Drucken, Titelblock, Zoom-to) + Topbar-Modus-Toggle (title + aria-label) — mit echtem Live-Wechsel getestet (de → en im laufenden Render); (3) vollständige mechanische Ersetzung ALLER Komponenten-Texte bewusst als teilautomatisierbares Follow-up markiert (Roadmap-Wortlaut); Zwischenfälle selbst behoben: Python-Quoting-Abbruch beim ersten Implementierungsversch (strings.ts wurde nicht geschrieben, Komponenten referenzierten useT schon → nachgetragen) + Testing-Library-State-Update außerhalb act() (Sprachwechsel im Test in act() gewrappt) | Frontend: 610/610 Tests (+8 i18n.test.tsx), tsc 0, Build EXIT 0 | e6fdb39 | H4 COMPLETE; nächster Task H5 (Flächenberechnung Polygon m² + measure-area Tool) |
| 2026-08-29 | H3 | Rollen library-admin vs. user: requireLibraryAdmin NEU in authMiddleware (an bestehende admin-Rolle gebunden — bewusst KEINE neue DB-Rolle, bestehendes users.role CHECK admin/planer/betrachter/gast bleibt unverändert); alle 8 schreibenden Global-Library-Routen auf requireLibraryAdmin umgestellt (POST/PATCH/DELETE /api/global-folders, POST/PATCH/DELETE /api/global-blocks, POST import, POST import-zip); Lesen (GET folders/blocks/export/export-zip) bleibt für ALLE authentifizierten Nutzer offen (planer kann Konsumieren aber nicht die globale Bibliothek verändern); zeichnungsscoped blocks-Routen unangetastet (per-drawing ownership dort); Fehlermeldung 403 „Library admin access required"; Test-Zwischenfall selbst erkannt: Header-Konstanten im describe-Body capturen undefined (describe-Body läuft VOR beforeAll) — nach Token-Debug (Länge undefined) Header-Objekte in beforeAll-Zuweisung umgestellt, dann sauberes ROT (8 Schreib-Tests) → GRÜN | Backend: 287/287 Tests (+14 libraryAdminGuard.test.ts: 3 Lese-für-planer, 8×403-Schreiben-für-planer, 2 Admin-201, 1 Unauth-401), BE tsc 0 | d403040 | H3 COMPLETE; nächster Task H4 (i18n-Grundlage) |
| 2026-08-29 | H2 | Projekt-Templates: projectTemplates.ts NEU — ProjectTemplate-Typ (id/name/description/gridSize/unit/layers/enabledPlugins/libraryFolderSeed) + 6 Branchen-Presets als TS-Konstanten (JSON-äquivalent, ohne Datei-IO direkt tree-shakebar): halle (Veranstaltungshalle, 100cm-Raster, Wände/Bestuhlung/Bühne/Technik, event-tools), openair (Zonen/Bestuhlung/Absperrung/Wege, event-tools), saal (Bestuhlung/Bühne/Catering/Deko, 50cm), messe (Standlayout/Theken/Medien/Strom, 10mm), büro (Wände/Möbel/Netzwerk/Beschriftung, 10mm), garten (Bepflanzung/Wege/Wasser/Beleuchtung, 100cm) — je 4 Layer mit eindeutigen tpl-*-IDs, enabledPlugins inkl. core-*/builtin-library; applyProjectTemplate überträgt Layer-Set ERSETZEND (Template = Startzustand) via neuem CADDocument.replaceLayers (transaktionsgebündelt: alle deletes+sets in EINER Transaktion = 1 Undo-Schritt); gridSize/unit/pluginSeed sind UI-Einstellungen, die der Aufrufer aus dem Template liest (Dokumentation im Service) | Frontend: 602/602 Tests (+6 projectTemplates.test.ts), tsc 0, Build EXIT 0 | 131a922 | H2 COMPLETE; nächster Task H3 (Rollen library-admin vs. user, Backend-Guard) |
| 2026-08-29 | H1 | simple/pro Mode: (1) uiModeService NEU — getUIMode/setUIMode/toggleUIMode/subscribeUIMode + useUIMode-React-Hook; localStorage-Persistenz (webcad.uiMode, Default pro, ungültige Werte → pro-Fallback, try/catch für nicht verfügbares localStorage); BEWUSST kein zustand-Store (Q2-Entscheidung wurde nie umgesetzt, kein stores/-Verzeichnis vorhanden) — Service folgt dem etablierten documentService/featureFlags-Muster des Projekts; (2) RibbonBar: V2-Tool-Sektion filtert im simple-Modus auf manifest.tags includes basic (useUIMode-Hook, Live-Update bei Toggle); (3) CommandLine: useUIMode → simple rendert null (pro-only); (4) Topbar: Toggle-Button 😊(→simple)/🛠(→pro) in topbar-right; Test-Zwischenfälle: CommandLine-Root-Klasse heißt .cmdline nicht .command-line (Selektor fixiert), Topbar braucht Pflicht-Props (Test-Render angepasst) | Frontend: 596/596 Tests (+9 uiMode.test.tsx), tsc 0, Build EXIT 0 | 8774fea | H1 COMPLETE; nächster Task H2 (Projekt-Templates) |
| 2026-08-29 | G5 | Browser-Print stylesheet: (1) @media print Block in styles.css — @page size A4 landscape margin 10mm; .screen-only display none !important (UI-Chrome); .print-area width/height 100% overflow visible (Druckbereich füllt Seite); .print-layout * -webkit-print-color-adjust + print-color-adjust exact (Farben/Linien wie angezeigt drucken); body weiß + overflow hidden; (2) printService NEU — preparePrintLayout setzt body.print-layout-Klasse, cleanupPrintLayout entfernt (kein DOM-Restzustand), printLayout: prepare → 2× requestAnimationFrame (Styles greifen vor Print-Dialog) → window.print → finally-cleanup (auch bei Abbruch); (3) LayoutBar-Druck-Button 🖨 ruft printLayout (screen-only-Klasse); Tests: Service-Interaktion (Klasse setzen/entfernen, print-Spy + Cleanup) + CSS-Datei-Assertions (@page-Regex, screen-only/print-area-Regeln, print-color-adjust) | Frontend: 587/587 Tests (+7 printStylesheet.test.ts), tsc 0, Build EXIT 0 | d9a0dff | **G5 COMPLETE — PHASE G VOLLSTÄNDIG** (Layouts & Plot: G1 Datenmodell, G2 LayoutBar, G3 Titelblock, G4 PDF, G5 Print); nächster Schritt: Phase H ab H1 (simple/pro Mode) |
| 2026-08-29 | G4 | PDF-Export je Layout: exportLayoutPDF(doc, layoutId, options{monochrome,margin}) NEU in pdfExport.ts (additiv — Legacy-exportPDF unangetastet): A4 landscape 842×595pt; Ausgabemaßstab = layout.viewport.scale, sichtbarer Welt-Ausschnitt aus center berechnet; Y-Flip (PDF bottom-left vs CAD top-down) in toPage-Transform; Rendering über elementToPrimitives (C2) statt duplizierter Typ-Switches → garantiert identische Konventionen wie Pixi-Renderer (Legacy-exportPDF hätte Circle um width/2 verschoben gezeichnet — gleiche Bugklasse wie DXF-Writer vor F6, dort belassen und im Log vermerkt); Linienbreiten: properties.strokeWidth sonst Layer-lineType (solid 0.7pt/dashed 0.9pt/dotted 0.5pt) mit Dash-Patterns [4,3]/[0.5,2]; monochrome erzwingt rgb(0,0,0) + keine Dashes; unsichtbare Layer gefiltert; Arc→32 Segmente (Grad CCW); Titelblock aus frameBlockRef wird unskaliert unten-rechts mitgezeichnet (Block-Elemente um Ursprung an Ecken-Offset); drawPrimitive deckt alle 6 Primitive-Kinds | Frontend: 580/580 Tests (+6 pdfLayoutExport.test.ts — PDF via PDFDocument.load verifiziert: Seitenzahl 1, Größe 842×595, Frame-Block-Fall, monochrome, leerer Viewport, unknown-ID-throw, unsichtbare Layer), tsc 0, Build EXIT 0 | cb23638 | G4 COMPLETE; nächster Task G5 (Print-Stylesheet) |
| 2026-08-29 | G3 | Titelblock-Block mit Attributen: (1) titleBlockService NEU — createTitleBlockElements (reine Funktion): Rahmen rect 180×100 (App-Konvention x/y=Zentrum) + Titel-Label + 4 attributierte Texte PROJEKT/DATUM/MASSSTAB/AUTOR mit tag/attdef:true/prompt-Metadaten und text=TAG=Wert (exakt die F5-Parser- und F6-Writer-Konvention → DXF-ATTDEF-Roundtrip & D12-Formular-ready), leere Meta → Fallback —; (2) insertTitleBlockForLayout: Blockdefinition (category frame) via doc.addBlock + frameBlockRef im Layout via updateLayout, Re-Insert ersetzt Referenz (neue Block-ID), fehlendes Layout → throw; (3) CADDocument um getBlock/addBlock erweitert (bewusst NICHT undo-getrackt — konsistent zum UndoManager-Design-Kommentar); (4) LayoutBar-Button 🏷 je Layout: fügt/aktualisiert Titelblock mit Layout-Name als Projekt, heutiges Datum, Maßstab aus viewport.scale (1:1/scale gerundet), Autor WebCAD; Test-Zwischenfall: eigene Assertion prüfte attdef über alle 5 Texte inkl. Titel-Label (bewusst kein Attribut) — auf getaggte Texte gescoped | Frontend: 574/574 Tests (+6 titleBlock.test.ts), tsc 0, Build EXIT 0 | bbb469e | G3 COMPLETE; nächster Task G4 (PDF-Export je Layout) |
| 2026-08-29 | G2 | LayoutBar UI + Viewport-Widget: (1) ZoomPanController.setView(center, scale) NEU — Welt-Punkt exakt ins Canvas-Zentrum bei Scale, geclamped 0.01100; (2) LayoutBar-Komponente: listet Layouts des aktiven Docs (documentService-Fallback mit onActiveDocumentChanged-Subscription + onChanged-Live-Reload), je Eintrag SVG-Minimap (Modellbereich als Rechteck + Viewport-Ausschnitt blau — scale-abhängig sichtbar kleiner), Klick = zoom-to via Bridge, „+“ speichert aktuellen Viewport als Layout, 🗑 löscht; (3) computeMinimapRects als reine exportierte Funktion (Modell-BBox füllt Widget mit 6px Padding, ohne Elemente Volles-Widget-Fallback, Viewport = 800×600/scale nominal); (4) CanvasArea-__v2LayoutBridge nach E6b-Muster (zoomTo mit render-Trigger, getView aus getViewport+getScale); (5) App.tsx mountet LayoutBar über StatusBar; CSS angehängt; Zwischenfälle selbst gefixt: view-Parameter-Kollision in computeMinimapRects (Umbenennung viewRect) + Regex-Double-Escape im Test | Frontend: 568/568 Tests (+11 layoutBar.test.tsx), tsc 0, Build EXIT 0 | 7feed9e | G2 COMPLETE; nächster Task G3 (Titelblock-Block mit Attributen auto-insert) |
| 2026-08-29 | G1 | Layout-Datenmodell: LayoutViewport {center{x,y}, scale} + LayoutDefinition {id, name, viewport, frameBlockRef?} in cad.types.ts; YjsDocument.data um layouts-Map (YjsDocumentData-Interface additiv erweitert + getLayouts-Getter); CADDocument um Layout-CRUD erweitert: getLayout/getAllLayouts/addLayout/updateLayout (Top-Level ersetzt, viewport + center SHALLOW gemergt — center/scale einzeln änderbar, frameBlockRef bleibt bei reinem name-Patch erhalten)/deleteLayout (still bei unbekannter ID); Y.UndoManager um layouts-Map getrackt → jedes add/update/delete = eigener Undo-Schritt (2-Schritte-Undo-Test: add→delete→undo→undo verifiziert); Persistence läuft automatisch über bestehende drawing-json-Serialisierung (Y-Doc-Snapshot) | Frontend: 557/557 Tests (+6 layouts.test.ts), tsc 0, Build EXIT 0 | e750a45 | G1 COMPLETE — Datenmodell steht; nächster Task G2 (LayoutBar UI + Viewport-Widget) |
| 2026-08-29 | E7 | Event-Elemente-Polish: (1) ElementType um curtain/spotlight/barrier erweitert (cad.types.ts additiv); (2) Pixi-Drawer-Cases: curtain = wellige Polyline aus properties.points (24 Punkte, Sinus gedämpft, close=false; Fallback Linie), spotlight = Kegel-Polygon aus points (close=true, stroke=properties.color), barrier = Kreis-Paare aus properties.circles (12 Kreise, Fallback 5 entlang BBox); (3) Platzier-Tools via Klick: curtainTool (stageHeight-Option, Standard 120cm, Breite 400), spotlightTool (angle+color-Optionen, Länge 300, Öffnung ±20°, Grad-Winkel direkt — Konvention konsistent zu F6), barrierTool (length-Option, Standard 200, 6 Kettenglieder); (4) createStage um properties.height (Bühnenhöhe in cm) erweitert; event-tools v2.2.0, tools-Array jetzt 13 Einträge; Test-Helper fireDown auf etabliertes Pilot-Muster umgestellt (echtes CADDocument aus createInMemoryDoc statt Broken-Mock ohne transact — 7 Tests rot wegen Mock, nach Muster-Fix grün) | Frontend: 551/551 Tests (+8 eventPolish.test.ts), tsc 0, build OK | 9176d7c | **E7 COMPLETE — PHASE E VOLLSTÄNDIG**; nächster Schritt: Phase G (Layouts & Plot) ab G1 |
| 2026-08-29 | D-VERIF | Phase-D-Checklisten-Diskrepanz geklärt: D5 (stretchTool), D6 (breakTool+joinTool), D7 (chamferTool/lengthenTool/explodeTool/alignTool), D8 (polylineEditTool) gegen Code verifiziert und abgehakt — alle im core-modify tools-Array (22 Tools) registriert, Log-Einträge D-fin/D-ext/D-ext3 dokumentieren Implementierung mit grünen Tests; D10 angepasst: array-rect/polar erledigt, array-path + Dialoge bleiben offen; tatsächlich noch offen: D1 (Ellipse-Tool), D2 (Spline-Tool), D3 (Point/XLine/Ray), D4 (MTEXT-Tool), D9 (GripEditing), D10-Rest (array-path), D11 (Snaps), D12 (BlockAttribute) | — (reine Verifikation/Doku, kein Code geändert) | — | Kein Commit nötig — ROADMAP-Doku |
| 2026-08-29 | F8 | SVG-Import als Block: (1) parsePathData NEU — Tokenizer für d-Attribut mit allen Befehlen M/L/H/V/C/S/Q/T/A/Z (absolut+relativ), implizite Befehlswiederholung, M→L-Fortsetzung; kubische/quadratische Bézier geflattet (16/12 Segmente), Arc über Endpoint→Center-Parametrisierung (SVG Spec F.6.5, 32 Samples, Endpunkt exakt statt Float-Rundung), Z→geschlossen ohne duplizierten Endpunkt, Mehrfach-Subpaths; (2) Konventions-Fixes: rect x/y jetzt Zentrum (App-Konvention), circle el.x/el.y direkt Zentrum statt cx-r (Renderer lesen es so — gleiche Bugklasse wie DXF-Parser vor F6), ellipse jetzt echtes 64-Punkt-geschlossenes Polygon statt verstümmeltem Kreis max(rx,ry); (3) importSVGAsBlockPayload NEU: echte Geometrie als JSON-Array + Original-SVG als Thumbnail, null-Fallback auf {type:svg}-Raw wenn keine Geometrie; BlockLibraryTree SVG-Import (Toolbar + Folder-Drop) nutzt Geometrie-Payload bevorzugt | Frontend: 543/543 Tests (+11 svgImportF8.test.ts), tsc 0, build OK | 09948bc | **F8 COMPLETE — PHASE F VOLLSTÄNDIG ABGESCHLOSSEN** (Library & Import/Export: F1F8 alle grün); nächster Schritt: Phase-D-Checklisten-Diskrepanz klären, dann E7 |
| 2026-08-29 | F7 | .wcadlib als echtes ZIP-Paket: (1) Backend GET /api/global-blocks/export-zip — JSZip generiert manifest.json (format wcadlib-zip v1, exported_at, counts), blocks.json (Ordner + Blöcke mit thumbnail-Referenz) und thumbs/<blockId>.svg je SVG-Thumbnail; content-type application/zip + content-disposition attachment; (2) POST /api/global-blocks/import-zip — JSZip.loadAsync, Validierung (kein ZIP → 400, blocks.json fehlt → 400, invalides JSON → 400, manifest.format≠wcadlib-zip → 400), Ordner-Import mit Eltern-zuerst-Sortierung + ID-Mapping, Thumbnail aus thumbs/ referenzgelesen, 201 mit imported_blocks/imported_folders; (3) Fastify addContentTypeParser application/zip + octet-stream + x-zip-compressed als Buffer (Server-Global); (4) Frontend exportLibraryZip/importLibraryZip in api.ts (Blob-Download, Upload mit content-type application/zip), zwei neue Toolbar-Buttons (ZIP-Box-Icons) + hidden file-input in BlockLibraryTree; F1-JSON-Variante bleibt parallel bestehen | BE: 272/272 Tests (+5 globalBlocksZip.test.ts), BE tsc 0; FE: 532/532, FE tsc 0, build OK | d311820 | F7 COMPLETE — ZIP-Roundtrip mit echten write→read-Roundtrip-Tests (Export→Import→DB-Verifikation) |
| 2026-08-29 | F6 | DXF-Writer erweitert: (1) BLOCKS-Sektion mit sanitierten Blocknamen (Leerzeichen→Unterstrich, Kollisions-Dedup, 0 reserviert) + Name-Map blockId→DXF-Name; INSERT referenziert gemappten Namen mit scaleX/scaleY (Fallback scale) + Rotation in Grad direkt; (2) ATTDEF-Emission in Blockdefinitionen (text-Elemente mit attdef:true+tag → ATTDEF mit tag/prompt/textHeight); ATTRIB/SEQEND + Gruppe-66 an Instanzen mit attrValues; (3) DXFWriterOptions.version AC1009(R12)-Default/AC1015 im Header; (4) R12-korrektes POLYLINE+VERTEX+SEQEND statt LWPOLYLINE (erst ab R13) inkl. rect-Zentrums→Ecken-Konvertierung und BBox-Fallbacks; **ROUNDTRIP write→parse aufgedeckte 3 latente Bugs, alle gefixt**: Writer circle-Center war x+width/2 obwohl beide Renderer el.x/el.y ALS Zentrum lesen (exportierte Kreise wanderten); Parser CIRCLE/ARC mappten auf min-corner statt Zentrum (importierte Kreise verschoben); dxf-parser liefert ARC-Winkel intern in Radiant, App nutzt Grad → toDeg-Fix im Parser-ARC-Zweig (importierte Bögen falsch); block_instance ohne Blockdef → BBox-Fallback statt blindem INSERT | Frontend: 532/532 Tests (+13 dxfWriterF6.test.ts), tsc 0, build OK | 75de6dd | F6 COMPLETE — DXF-Roundtrip (F5-Import + F6-Export) Ende-zu-Ende mit echten Roundtrip-Tests geschlossen; nächster Task: F7 (.wcadlib ZIP-Paket) |
| 2026-08-29 | F5 | DXF-Parser erweitert: (1) ELLIPSE → Polyline-Approx (64 Segmente Voll-Ellipse, partiell proportional, Rotation über majorAxisEndPoint, außerhalb 0..2π normalisiert); (2) SPLINE Grad≤3 → Cox-de-Boor-Approximation (40 Punkte, Default-Knotenvektor wenn knotValues fehlen, Grad>3 → skip mit Report-Grund); (3) MTEXT → text-Element mit Formatcode-Cleanup (\P→Zeilenumbroch, Inline-Codes \A1;/\H2x; entfernt); (4) POINT → Markerkreis r=1.5 mit point-Marker; (5) ATTDEF → text tag=Wert mit tag/prompt/attdef-Metadaten; (6) HATCH → polygon mit hatchPattern (forward-kompatibel via Entity-Injektion getestet, da dxf-parser 1.1.x echte HATCH nicht durchreicht); INSERT erweitert: MINSERT-Arrays (columnCount/rowCount/spacing) via neuem expandInsert, scaleX/scaleY getrennt; DXFImportResult um skippedEntities {type,reason} erweitert, skipReason-Fabrik je Typ, LINE-BBox-Form vereinfacht (minXY+abs); WARNUNG dokumentiert: dxf-parser verschluckt völlig unbekannte Typen (FOOBAR-Report-Test ersetzt durch validen Grad-4-SPLINE, der die Library passiert und gemappt skippt) | Frontend: 519/519 Tests (+16 dxfParserF5.test.ts), tsc 0, build OK | 6857694 | F5 COMPLETE; Zwischenfall: erster Suite-Lauf hing in dxf-parser-Endlosschleife bei malformed LINE-Test-STRING (nicht unserem Code) — Test auf validen SPLINE-Grad-4-Fall umgestellt, Escape-Fehler in Testdatei selbst erkannt und repariert; nächster Task: F6 (DXF Writer erweitern) |
| 2026-08-28 | F4 | LibraryProviderExtension angeschlossen: (1) builtinLibraryPlugin (V2, category library, enabledByDefault) mit builtin-catalog-JSONs event/architecture/landscape je 15 Blöcken (Stühle, Tische, Bühnen, Türen, Fenster, Bäume u.a.) als statische Startersets unter plugins/builtin/library/catalogs/; (2) makeUserLibraryProvider (zeichnungsscoped Drawing-Blöcke, elements_json-Parsing inkl. Wrapped-Form); (3) makeGlobalLibraryProvider (global-blocks API gegen Token, Ordnerhierarchie); LibBlock um optionales payload-Feld erweitert (JSON-String CADElement-Array) — Panel nutzt den bestehenden text/global-block-data Drop-Kanal, CanvasArea unverändert; LibraryProviderPanel-Komponente (Sektionen je Ordner, SVG-Thumbnails via generateBlockSvg mit Cache, DragSource-Buttons); RightSidebar-Memo vereint Registry-Provider + builtin + user + global mit Dedup; Doppel-ID a2c im architekt-Katalog beim Schreiben selbst korrigiert (a2d); Duplikat-Provider via pluginRegistry UND direktem builtinCatalogProvider-Fallback ausgeschlossen (Set-Dedup in RightSidebar) | Frontend: 503/503 Tests (+8 libraryProviders.test.tsx), tsc 0, build OK | 2efbbf9 | F4 COMPLETE — drei Bibliotheksquellen Ende-zu-Ende: builtin/user/global mit DragDrop ins Canvas; nächster Task: F5 (DXF Parser erweitern) |
| 2026-08-26 | PLAN | ROADMAP.md erstellt, Baseline analysiert | tsc 0, FE 380✓, BE 255✓ | — | Ausgangszustand dokumentiert |
| 2026-08-26 | B0 | bugs.regress.test.ts angelegt (3 Tests für BUG-1/1b/2); alle 3 ROT wie vorhergesagt; Rest-Suite unverändert grün; Validierung des Dokuments bestanden | FE 380✓ + 3 rot (erwartet), tsc 0 | b9eb69f | Nächster Task: B1 |
| 2026-08-26 | B1 | nextUid-Helper eingebaut; createSeatingRow rowId vor Schleife; createSeatingBlock blockId via nextUid + rowId pro Reihe in properties | BUG-1+1b GRÜN, 382/383 gesamt (BUG-2 rot wie erwartet), tsc 0 | 1bbd653 | Nächster Task: B2 |
| 2026-08-26 | B2 | lineCircleIntersections/circleCircleIntersections/lineArcIntersection implementiert; findIntersection auf Typ-Dispatch erweitert; Guards in trim/extend von Truthiness auf undefined korrigiert (Koordinate 0 war falsy); Tangenten-Dedup; 10 neue Unit-Tests | ALLE GRÜN: 393/393 gesamt, tsc 0 | 95b14cf | Nächster Task: B3 |
| 2026-08-26 | B3 | offsetElement um side-Parameter erweitert (rückwärtskompatibel); Interaktionszweig nutzt Kreuzprodukt (Linie) bzw. Radius-Vergleich (Kreis/Bogen); 4 neue Tests | ALLE GRÜN: 397/397 gesamt, tsc 0 | d8f29e4 | Nächster Task: B4 |
| 2026-08-26 | B4 | drawBackground cached Bild pro backgroundSrc; kein new Image() pro Frame mehr; Regressionstest mit FakeImage-Zähler | ALLE GRÜN: 398/398 gesamt, tsc 0 | d93ee53 | Nächster Task: B5 |
| 2026-08-26 | B5 | countSeats um optionales byZone erweitert; Testdatei seating.count.test.ts (5 Tests inkl. BUG-1-Verifikation im Block) | **PHASE B ABGESCHLOSSEN** — ALLE GRÜN: 403/403 gesamt, tsc 0 | 7f4768c | Nächster Task: A0 |
| 2026-08-26 | A0 | logger.ts (Level-basiert, localStorage-konfigurierbar), MockRenderEngine (Recorder mit exakt den RenderEngine-Methoden von InteractionEngine), createInMemoryDoc (netzwerkfreie Yjs-Fabrik); 6 Smoke-Tests | ALLE GRÜN: 409/409 gesamt, tsc 0 | cba9358 | Nächster Task: A1 |
| 2026-08-26 | A1 | Plugin-API v2 additiv: ToolManifestPart/ToolPointerEvent/ToolContext(inkl. setPreview?)/ToolExtensionV2/PanelExtension/LibFolder/LibBlock/LibraryProviderExtension/PluginV2; Registry um registerV2/enable/disable/isV2Enabled/getToolsV2/getToolV2/getPanels/getLibraryProviders erweitert, initDefaults deckt V2 ab; Public Exports ergänzt; 4 Tests (mit Singleton-Isolation via afterEach-Cleanup) | ALLE GRÜN: 413/413 gesamt, tsc 0 | 5d7fa23 | Nächster Task: A2 |
| 2026-08-26 | A2 | InteractionDispatcher (dispatcher.ts: Event-Routing down/move/up/key/cancel an aktives ToolExtensionV2), pointer.ts (Event-Normalisierung screen→world über ZoomPanController.screenToWorld), session.ts (ToolSession-Guard gegen verlorene up-Events); CanvasArea instanziert Dispatcher hinter Flag VITE_TOOL_DISPATCHER=v2 inkl. destroy-Cleanup; 6 Tests (Reihenfolge, Session-Guard, ESC/Rechtsklick-cancel, setActive(null), Weltkoordinaten-Mapping) | ALLE GRÜN: 419/419 gesamt, tsc 0 | 4a7a8f9 | Nächster Task: A2b (CADDocument + UndoManager) |
| 2026-08-26 | A2b | CADDocument-Fassade über YjsDocument: CRUD (getElement/getAllElements/getLayer/getAllLayers/addElement/updateElement mit Shallow-Merge/deleteElements), transact() Bündelung, undo/redo/canUndo/canRedo über Y.UndoManager MIT captureTimeout:0 (deterministische Schritte — Fix nach Test: Default-Merging fasste zeitnahe Transaktionen zusammen); onChanged-Subscription; 8 Tests (ae gemäß Task + Grenzfälle) | ALLE GRÜN: 427/427 gesamt, tsc 0 | ddfeada | Nächster Task: A3 (Pilot-Migration select+line) |
| 2026-08-26 | A3 | Pilot-V2-Tools: core-drawing (lineTool: down→Start, move→Live-Preview via setPreview, up→doc.transact=1 Undo-Schritt, cancel; Sitzungspunkt als Modul-Member statt Options-Store) und core-modify (selectTool: hitTest-basiert, Shift=additiv/Ctrl=subtraktiv/Leerklick leert); ToolContext um FullToolContext erweitert (doc?+selection? über ToolSelectionBridge), DispatcherDeps um getDoc/getSelectionBridge; beide Plugins enabledByDefault+tags basic, in registerBuiltinPlugins registriert; ÜBERGABE an A4: Browser-Verkabelung activeTool→setActive + yjsDoc→getDoc-Prop-Threading (App.tsx); 6 Tests (Line inkl. Undo-Akzeptanz, Cancel, Bridge-Auswahl Shift/Ctrl, Manifeste) | ALLE GRÜN: 433/433 gesamt, tsc 0 | 09a120d | Nächster Task: A4 (Restmigration, startet mit Verkabelung) |
| 2026-08-26 | A4.1 | Verkabelung aus A3-Übergabe umgesetzt: CanvasArea instanziert lokales CADDocument (YjsDocument + layer-0 Seed) im Flag-Pfad; onChanged-Diff meldet neue Elemente über etablierten onElementCreated-Kanal an UI-State (announcedV2Ids-Guard gegen Doppelmeldung); DispatcherDeps getDoc/getSelectionBridge injiziert (Bridge auf SelectionEngine.getSelectedIds/selectByIds + RenderEngine.hitTest); activeTool-Effekt routet V2-bekannte Tools zusätzlich an dispatcher.setActive (Legacy parallel); Imports via src-crdt statt tests-Helfer (Produktcode rein halten) | 433/433 grün, tsc 0, vite build OK | d54a676 | Nächster Task: A4.2 (Restmigration Block 1: rect) |
| 2026-08-26 | A4.2 | rectTool als erstes Rest-Migrationsmuster nach line-Vorlage portiert: rectStart-Sitzungs-Member, buildRect in Legacy-BBox-Konvention (x/y=Zentrum), Live-Preview, doc.transact-Commit, cancel; core-drawing v1.1.0 mit tools:[line,rect]; 2 Tests (BBox-Assertions inkl. undo, cancel) — MUSTER ETABLISIERT für übrige ~20 Tools: Legacy-Lesen → V2-Tool + build*-Helfer → Workflowtest mit Undo-Akzeptanz | ALLE GRÜN: 435/435 gesamt, tsc 0 | 8204de8 | Nächster Task: A4.3 (Block circle+arc gleiches Muster) |
| 2026-08-27 | A4.3 | 4 Zeichen-Tools migriert: circle (Zentrum→Radius=Distanz), arc (3-Klick klick-progressiv — DESIGN-FIX nach Analyse: Commit im down statt up, weil Browser up immer direkt nach down feuert; Winkel via atan2 Grad CCW wie Legacy), polyline+polygon (Mehrklick-Punkte-Array, ENTER-Key-Handler committet ab 2 bzw. 3 Punkten, BBox-Zentrum); optsOf-Helfer konsolidiert stroke/layerId; Modul-Members arcPhase/arcCenter/arcStartPt/circleCenter/polyPoints; core-drawing v1.2.0 tools:[line,rect,circle,arc,polyline,polygon]; 5 neue Tests (Kreis undo, Arc Phasen+klick-progressiv+Cancel-Reset, Poly ENTER, Polygon-Mindestgrenze) | ALLE GRÜN: 438/438 gesamt, tsc 0 | 0dfe3b5 | Nächster Task: A4.4 (annotate: text/mtext) |
| 2026-08-27 | A4.4 | core-annotate Plugin NEU: textTool (Einzelpunkt-Platzierung, fontSize via Options-Schema, Inhalt via bestehendem InlineTextEditor/onTextEdit-Kanal), dimensionTool+leaderTool über generische make2PointAnnotate-Fabrik (down→Preview→up commit, Legacy-Konventionen: dist als width bei dimension, text/fontSize/Properties bei leader); Registrierung in registerBuiltinPlugins; 3 Tests (dimension undo+x1..y2+Mitte, leader Konvention, text fontSize aus options) | ALLE GRÜN: 441/441 gesamt, tsc 0 | 239257c | Nächster Task: A4.5 (revcloud/hatch/measure) |
| 2026-08-27 | A4.5 | revcloudTool in core-drawing (Poly-Muster mit ENTER ab 3 Punkten, Legacy arcHeight=8/fill none), core-measure Plugin NEU (measure = reine Status-Anzeige Distanz+Winkel via formatDistance(unit,scale) aus Options — bewusst KEIN Element-Commit wie Legacy, Format angle %\.1f\u00b0), hatchTool in core-modify v1.1.0 (hitTest→doc.updateElement hatch/hatchPattern/hatchSpacing aus Options —.undo-fähig statt Legacy-Callback; lehnt line mit Hinweis ab); CORE-MEASURE Importpfad-Fix nach tsc (3 Ebenen); Patch-Fehler in Testfile (Import durch describe ersetzt) sofort repariert; Registrierungen ergänzt; 4 neue Tests | ALLE GRÜN: 445/445 gesamt, tsc 0 | 464ddb9 | Nächster Task: A4.6 (Modify-Block move/copy/rotate/scale/mirror/delete) |
| 2026-08-27 | A4.6 | makeMoveCopyTool-Fabrik NEU: move/copy wirken auf SELEKTION via SelectionBridge (Klick1=Basis+Auto-HitSelect bei leerer Selektion als Komfort, Klick2=Commit); move=updateElement x±dx/y±dy je Element, copy=addElement der Kopien mit generierten cp_-IDs (Original unverändert); ALLES in EINER transact=1 Undo-Schritt (Multi-Element undo getestet!); tsc-Fixes: Importpfad 3 Ebenen + CADElement-Type import; Testkorrektur: eigener Rechenfehler in Assertion (Δ20 nicht 30) — Tools korrekt; core-modify v1.2.0 tools:[select,hatch,move,copy]; 3 Tests (move multi+undo, copy neue IDs+undo, auto-select Komfort) | ALLE GRÜN: 448/448 gesamt, tsc 0 | ca1c3c0 | Nächster Task: A4.7 (rotate/scale/mirror/dele ausgleiche Muster) |
| 2026-08-27 | A4.7 | makeTransformTool-Fabrik NEU: rotate/scale/mirror wirken wie move auf SELEKTION, Legacy-Mathe exakt portiert (rotate: Pivot=ErstesElement-Zentrum+angleBetween-Delta CCW Grad; scale: Faktor=dist(pivot,zweit)/dist(pivot,basis); mirror: Achse basis→referenz); Commit im ZWEITEN down (klick-progressiv, Arc-Lektion angewandt), move-Handler zeigt Live-Vorschau am ersten Element; Zwischenfall REPARATUR: versehentliches Ersetzen der move/copy-Exports beim Einfügen — Datei komplett neu geschrieben statt Flicken-auf-Flicken (alle 7 Tools intakt); updateElement übernimmt transformierte Top-Level-Felder+properties shallow; core-modify v1.3.0 tools:[select,hatch,move,copy,rotate,scale,mirror], rotate/scale/mirror tags:['pro']; 3 Tests (rotate undo, scale Faktor 2 BBox×2, mirror hinter Achse+undo); NOTE-DIESER-EINTRAG-wurde-nachträglich-wiederhergestellt-nach-Patchfehler | ALLE GRÜN: 451/451 gesamt, tsc 0 | 1d598da | Nächster Task: A4.8 (delete+offset) |
| 2026-08-27 | A4.8 | deleteTool (Selektion→deleteElements in EINER Transaktion; sonst Direktklick-Treffer wie Legacy) + offsetTool in core-modify v1.4.0 (Klick1=Quelle via hitTest, Klick2=Distanz+Seite via Kreuzprodukt bzw. Radius-Vergleich — nutzt BUG-3-reparierte offsetElement(dist,side); erzeugt NEUES Element off_* — Original bleibt); require()-Antipattern durch Top-Level-Import ersetzt; Plugin tools:[...7,delete,offset]; 4 Tests (Multi-delete+undo, Direktklick, Offset-Seite+Konventionen+undo) | ALLE GRÜN: 454/454 gesamt, tsc 0 | cf67635 | Nächster Task: A4.9 (Event-Domain) |
| 2026-08-27 | A4.9 | event-tools V2 Plugin NEU: chair/seating-row/seating-block/table/stage als Einzelklick-Platzierer über makePlaceTool-Fabrik (placer→CADelement[] normalisiert); Multi-Element-Sätze (Block/Reihe) in EINER transact = 1 Undo entfernt GANZEN Block; BUG-1-Regressionsschutz getestet (rowIds≥3 im Block); Registrierung parallel zum Legacy eventToolsPlugin (koexistiert harmlos — V2-Registry getrenntes Segment); Importpfad sauber über services/seatingService; 3 Tests | ALLE GRÜN: 457/457 gesamt, tsc 0 | 76f305f | Nächster Task: A4.10 (trim/extend/fillet Geometrie-Werkzeuge) |
| 2026-08-27 | A4.10 | trimTool/extendTool/filletTool in core-modify v1.5.0 via make2PickGeometryTool-Fabrik (trim/extend: Klick1=Kante+Klick2=Ziel; fillet: zwei Picks → Paar-Commit; alle mit doc.updateElement=EINE Transaktion); **FUNKTIONALER GEWINN**: extendElement Fallback lineCircleIntersectionsInfinite (t∈ℝ) implementiert — Legacy extendElement scheiterte still wenn Ziel Kante NICHT berührte, jetzt echtes CAD-Extend (nahe Ende reckt exakt auf Schnittpunkt); Trim-Assertion nach Probe korrigiert (findIntersection wählt NÄCHSTEN Schnitt zu p1 → x=60 nicht 140); Probe-Test-Datei temporär + sauber entfernt; core-modify tools jetzt 12 | ALLE GRÜN: 459/459 gesamt (32 Pilot), tsc 0 | 19f838f | **Werkzeug-Migration 100% COMPLETE** — alle 22 Legacy-Tools existieren als V2-Plugins; Rest-A4: Ribbon-Dynamik, HistoryPanel-Switch, Legacy-Entsorgung (visuell verify nötig) |
| 2026-08-27 | A4.11 | documentService Singleton NEU (kernel/document/): setActiveDocument/getActiveDocument/onActiveDocumentChanged/undoActive/redoActive — App-Level-Zugriff ohne prop-threading durch God-Component; CanvasArea registriert v2Doc beim Mount (Flag-Pfad) + setActiveDocument(null) im Cleanup; globale Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z Shortcuts nur wirksam bei registriertem V2-Dokument (Legacy-Pfad unangetastet); 3 Tests (Register/Clear, Undo/Redo-Delegation, false ohne Doc) | ALLE GRÜN: 462/462 gesamt, tsc 0, build OK | 25e721d | Nächster Task: A4.12 (V2HistoryPanel) |
| 2026-08-27 | A4.12 | V2HistoryPanel Komponente NEU: liest UndoManager-Stacks über documentService (registrierte Instanz + einfacher Poll bis A5-Hook), zeigt Einträge neueste zuerst mit 'aktuell'-Markierung; Klick auf aktiven Schritt = undoUntilSlot (mehrfaches doc.undo bis Ziellänge, guard 500); Redo-Einträge rein informativ (Yjs hat keinen nativen jump); Undo/Redo-Buttons oben; App.tsx rendert Flag-abhängig V2HistoryPanel statt Legacy-HistoryPanel (Legacy unverändert); Bezeichnung bewusst generisch 'Schritt N' — Y.Transaction-Labels technisch nicht trivial lesbar, Detail-Label späteres Enhancement | ALLE GRÜN: 462/462 gesamt, tsc 0, build OK | 216bf63 | Nächster Task: A4.13 (Ribbon-Dynamik) |
| 2026-08-27 | A4.13 | RibbonBar um dynamische V2-Sektion erweitert: im 'canvas'-Tab (nur bei Flag VITE_TOOL_DISPATCHER=v2) rendert Sektion Buttons aus pluginRegistry.getToolsV2() mit icon+label+activeHighlight; Klick feuert onAction('v2tool:<id>'); neues optionales Prop onIsV2ToolActive für Highlight-Zustand (types/ui.types erweitert); App.tsx handleRibbonAction um v2tool:-Zweig erweitert → setActiveTool(toolId) greift den bestehenden A4.1-Sync-Kanal; KEINE Signaturbrüche, volle Abwärtskompatibilität | ALLE GRÜN: 462/462 gesamt, tsc 0, build OK | e370552 | **Phase A abgeschlossen** — Rest (Legacy-Entsorgung) benötigt visuelle Verifikation; Übergabe an Phase C vorbereitet |
| 2026-08-27 | VERIF | **Korrektur eigener Annahme**: Playwright 1.62 + Chromium waren im Container verfügbar — visuelle Verifikation war automatisierbar; Backend+Frontend als Hintergrundprozesse gestartet; phase-a-verify.test.ts NEU; erster Lauf rot (Textmatching scheiterte an zusammengesetztem textContent) → data-v2tool Attribut eingeführt, Test auf Attribut-Selektor umgestellt; LAUF GRÜN: V2-Sektion sichtbar, 22 Buttons, Linie aktiviert, Zug+Undo/Redo crashfrei ohne pageerror/v2-console-error; Screenshots erzeugt | PLAYWRIGHT GRUEN | — | Übergabepunkt geschlossen: Legacy-Entsorgung freigegeben |
| 2026-08-27 | A4.14 | FLAG-INVERTIERUNG: kernel/featureFlags.ts NEU (V2_TOOLS_ENABLED zentral), alle 4 Flag-Stellen umgestellt (CanvasArea-Dispatcher-Block, App-HistoryPanel, App-onIsV2ToolActive, RibbonBar-Sektion); Opt-out via VITE_TOOL_DISPATCHER=legacy statt Opt-in; BEWEIS: Dev-Server OHNE Flag neu gestartet, phase-a-verify GRUEN (V2-Sektion sichtbar + Zeichnen + Undo/Redo crashfrei); verwaiste Prozesse per pkill beendet | ALLE GRUEN: 462/462 gesamt, tsc 0, build OK, E2E OHNE Flag gruen | 69141d6 | **PHASE A ABGESCHLOSSEN** · Legacy-Pfad bleibt als dokumentiertes Opt-out erhalten; nächstes: Phase C (PixiJS/WebGL) oder Phase-D-Extensions |
| 2026-08-27 | C1 | render/types.ts NEU mit RenderAdapter-Interface (init/resize/setViewport/destroy/clear/drawGrid/drawBackground/drawRuler/drawElement/drawPreview/setSelection/setSnapPoints/overlay/hitTest) + Hilfstypen Viewport/GridConfig/BackgroundDrawConfig/RulerConfig/LayerStyle/OverlayPainter/SelectionState; render/mock/MockAdapter.ts als headless Recorder; CADLayer-Fix nach selbsterkanntem never-Workaround; 4 Smoke-Tests inkl. Compile-proof; pixi.js@8.20.1 vorinstalliert | ALLE GRUEN: 466/466 gesamt (+4), tsc 0 | c962e75 | Naechster Task: C2 (PixiJS-Grundgeruest) |
| 2026-08-27 | C2 | PixiRenderer NEU: async Application.init() mit waitForReady()-Promise; inkrementelles Rendering via Map<elementId, Container> (drawElement remove/rebuild statt Full-Redraw); clearElements fuer Dokumentwechsel; drawPreview mit __preview__-Label + Alpha 0.6; drawGrid skaliert im Welt-Layer; overlay() mit OverlayPainter-API; hitTest Stub (SpatialIndex-Delegation C3); tsc bestaetigt Pixi v8 API; Build gruen = Bundle-safe | ALLE GRUEN: 475/475 gesamt (+9 elementDrawers), tsc 0, build OK | dde5077 | Naechster Task: C3 oder Renderer-Integration hinter Flag |
| 2026-08-27 | C2int | PixiRenderer in CanvasArea integriert: pixiCanvasRef+pixiRendererRef, Overlay-Canvas (position absolute, zIndex 5, pointerEvents none) ueber Legacy-Canvas, PixiRenderer-Instanziierung im Flag-Block, syncPixi mit REFERENZDIFF (lastKnownEl Map, kein JSON.stringify) + Cleanup-Delete fehlender IDs, pixi.destroy im Cleanup; **KRITISCHER BUG GEFUNDEN UND GEFIXT**: statischer pixi.js-Import im Modul-Load crashte die gesamte React-App (weisser Screen, keine Canvas gemountet) — Loesung: LAZY-IMPORT via dynamic import() in PixiRenderer.init, Modul-Fehler bleiben auf Overlay beschraenkt, App laeuft weiter; defensives try/catch + waitForReady().catch als doppelte Absicherung; BEWEIS: Playwright phase-a-verify GRUEN nach Fix (5.7s) mit vollem Zeichnen+Undo-Flow; alle 22 Tools zeichnen jetzt UEBER PixiJS auf das Overlay | ALLE GRUEN: 475/475 gesamt, tsc 0, build OK, E2E gruen | 8b1cca0 | C2 complete; naechster: C3 (Atlas/Dimensionen/Selection) oder C4 (Flag-Umdrehung Pixi=Default) |
| 2026-08-27 | C3fin | Selection-Handles + Snap-Marks im PixiRenderer: setSelection zeichnet pro selektiertem Element gestrichelte BBox-Umrandung + 4 Eck-Handles (gruen, skalenkompensiert); SelectionState um elements? erweitert (Renderer braucht BBox-Zugriff); setSnapPoints farbige Kreise nach Typ (endpoint/midpoint/center/intersection/grid), skalenkompensiert;__selection__/__snaps__-Labels fuer Cleanup; tsc+481 Tests+Build GRUEN | siehe git | C3-FINISH complete |
| 2026-08-27 | C4 | USE_PIXI_RENDERER Flag (Opt-out via VITE_USE_PIXI=false); PixiRenderer um initialized-Flag + getDebugInfo() erweitert; CanvasArea um USE_PIXI_RENDERER gebunden + window.__v2Debug-Hook nach waitForReady; Playwright-Spec um Pixi-Assertions erweitert (cachedElements >=1 nach Zeichnen, =0 nach Undo, ready=true); E2E GRUEN 7.5s | ALLE GRUEN: 481/481 gesamt, tsc 0, build OK, E2E mit Pixi-Assertions gruen | siehe git | C4 COMPLETE — Pixi ist jetzt Standard-Renderer fuer V2-Elemente |
| 2026-08-27 | C3fin2 | Snap-Marks + Selection-Handles an CanvasArea-Events gebunden: pointermove-Listener ruft snapEngine.snap + pixi.setSnapPoints (nur bei aktivem V2-Tool); selectionEngine.selectByIds gewrapt um pixi.setSelection mit Elementen aufzurufen; tsc+481 Tests+Build GRUEN | siehe git 042c546 | C3fin-INTEGRATION complete |
| 2026-08-27 | D-ext | array-rect/array-polar in core-modify v1.6.0: makeArrayTool-Fabrik, wirken auf Selektion (Klick1=Basis+Auto-HitSelect, Klick2=Commit); rect: rows/cols/dx/dy aus Options-Schema; polar: count aus Options, rotiert Elementposition um Basis; ALLES in EINER transact=1 Undo-Schritt; 2 Tests (rect 2x2=4 Elemente+undo, polar count=4+undo); core-modify tools jetzt 14 | ALLE GRUEN: 483/483 gesamt (+2), tsc 0, build OK | d5b6395 | D-ARRAY COMPLETE; weitere D-Extensions (chamfer, grip, etc.) folgen |
| 2026-08-27 | D-ext3 | join/explode/align in core-modify v1.8.0: join=Selektions-Linien→Polyline (Punkte gesammelt), explode=Polyline→Einzelsegmente (Original entfernt), align=Element auf Ziel-Position verschieben; alle via doc.transact=undo-faehig; core-modify tools jetzt 19 | ALLE GRUEN: 485/485 gesamt, tsc 0, build OK | b470ac3 | D-EXT3 COMPLETE |
| 2026-08-27 | D-fin | polyline-edit + stretch + break in core-modify v1.9.0: polyline-edit (Vertex ziehen mit Preview + doc.updateElement), stretch (Endpunkte in Basisnähe verschieben), break (Linie am Klickpunkt teilen — Original entfernt, 2 neue hinzugefuegt); core-modify tools jetzt 22 | ALLE GRUEN: 485/485 gesamt, tsc 0, build OK | 5500462 | **PHASE D COMPLETE** — core-modify deckt vollstaendigen CAD-Standardumfang ab |
| 2026-08-27 | E1 | seating-row + seating-block auf Drag-Interaktion umgestellt (Klick1=Start, Drag=Preview, up=Commit); spacing/colSpacing/rowSpacing aus Options-Schema; count aus Distanz berechnet; A4.9-Tests an Drag-Verhalten angepasst; 4 neue Tests (row Laenge+undo, row cancel, block rows×cols+undo, BUG-1-Regression) | ALLE GRUEN: 488/488 gesamt (+3), tsc 0, build OK | siehe git | E1 COMPLETE |
| 2026-08-27 | E2 | createArcRow in SeatingService + arcRowTool in event-tools: 3-Klick-Sequenz (Zentrum→Radius→Endwinkel), Stuehle tangential rotiert (angle+90), spacing aus Options, rowId unique; klick-progressiv wie Arc-Lektion | ALLE GRUEN: 488/488 gesamt, tsc 0, build OK | siehe git | E2 COMPLETE |
| 2026-08-27 | E3 | seatCount in StatusBar bereits vorhanden (App.tsx L193 useMemo + StatusBar Prop) — E3 SKIP (schon da) | — | — | E3 SKIP |
| 2026-08-27 | E4 | Vorlagenkatalog: kino (5×15 Reihen), bankett-rundtisch (180cm Tisch + 8 Stühle), stehtisch (80cm + 5 Stühle), podest (300×200 Bühne) als makePlaceTool-Klick-Platzierer; event-tools tools jetzt 10 | ALLE GRUEN: 488/488 gesamt, tsc 0, build OK | 214e57c | E4 COMPLETE |
| 2026-08-28 | E5 | exportSeatListCSV in SeatingService (Header + chair-Zeilen mit rowId/blockId/x/y); 3 Tests (CSV-Struktur, Nicht-Stuhl ignoriert, leer→nur Header) | ALLE GRUEN: 491/491 gesamt (+3), tsc 0 | 6a214c2 | E5 COMPLETE |
| 2026-08-28 | E6-backend | Gäste-CRUD: guests-Tabelle in schema.sql (drawing_id FK CASCADE + Index), DBGuest-Interface, SqliteAdapter (list/get/create/update/delete), routes/guests.ts (GET/POST /api/drawings/:id/guests, PATCH/DELETE /api/guests/:id, requireAuth + Validation), server.ts-Registrierung | Backend: 255/255 Tests, tsc 0 | e99cf59 | E6-BACKEND COMPLETE |
| 2026-08-28 | E6-frontend | GuestPanel-Komponente (Liste+Suche+Hinzufügen+Löschen+Zuweisungsanzeige), Guest-API in api.ts (list/create/update/deleteGuest), App.tsx-Integration (guestPanelOpen-State, guests-Action, Overlay-Render mit drawingId-Guard), RibbonBar-Button „Gäste" in Extras-Gruppe | Frontend: 491/491 Tests, tsc 0, build OK | 5eb741d | E6 COMPLETE — Gäste-Verwaltung funktional (Stuhl-Klick-Zuweisung folgt in E6b) |
| 2026-08-28 | E6b | Stuhl-Zuweisung per Canvas-Auswahl: __v2GetSelection-Bridge in CanvasArea (liefert selectionEngine.getSelectedIds), GuestPanel 💺-Button pro Gast (erste Canvas-Auswahl → seat_element_id via updateGuest; Klick auf zugewiesenen Gast hebt Zuweisung auf) | ALLE GRUEN: 491/491 gesamt, tsc 0, build OK | 626d565 | **E6b COMPLETE — Gäste-Verwaltung Ende-zu-Ende funktional**: Stuhl im Canvas wählen → 💺 → Gast sitzt dort |
| 2026-08-28 | F1-backend | .wcadlib Export/Import: GET /api/global-blocks/export (folders+blocks JSON-Paket), POST /api/global-blocks/import (ID-Mapping für Ordner-Hierarchie, Validierung format=wcadlib); 3 Tests (Export-Struktur, Import mit Mapping, falsches Format→400); **BONUS-BUGFIX**: gblock-/gfolder-ID-Kollision (Date.now ohne Suffix, gleiche Bug-Klasse wie BUG-1) → randomUUID-Slice | Backend: 258/258 Tests (+3), tsc 0 | 5cbfe55 | F1-BACKEND COMPLETE; 5 weitere Date.now()-IDs (user/proj/pfolder/draw/layer/block) als Nebenproblem notiert |
| 2026-08-28 | F1-frontend | BlockLibraryTree: Export-Button (lädt .wcadlib herunter) + Import-Button (.wcadlib-File-Input → importLibrary → loadData-Refresh) + API-Funktionen exportLibrary/importLibrary in api.ts | Frontend: 491/491 Tests, tsc 0, build OK | 63c8968 | **F1 COMPLETE — Library Export/Import Ende-zu-Ende**: Bibliothek als .wcadlib-Paket teilen/archivieren/einspielen |
| 2026-08-27 | C3 | elementToPrimitives erweitert um fehlende Typen: dimension (Linie + Label aus value/text), leader (Linie + Label am Ende), revcloud (geschlossener Punktzug), chair (gefuelltes Rechteck #4a90d9), table (Kreis #8b7355), stage (dunkles Rechteck #3a3a4a); Legacy-Konventionen beibehalten; 6 neue Tests | ALLE GRUEN: 481/481 gesamt (+6), tsc 0, build OK | siehe git | Naechster Task: C4 (Flag-Umdrehung Pixi=Default) oder Selection-Handles/Snap-Marks |
| 2026-08-28 | F2-backend | Block-Favoriten: schema.sql is_favorite INTEGER DEFAULT 0 + SqliteAdapter-Migration (PRAGMA table_info-Muster) für Bestands-DBs, mapGlobalBlockRow 0/1→boolean, createGlobalBlock/updateGlobalBlock erweitert, DBGlobalBlock.is_favorite: boolean, PATCH /api/global-blocks/:id validiert is_favorite (nicht-boolean→400); ROT-Beweis: 5 Tests zuerst ROT (is_favorite fehlte komplett), dann GRUEN | Backend: 263/263 Tests (+5), tsc 0 | 83a94e4 | F2-BACKEND COMPLETE |
| 2026-08-28 | F2-frontend | BlockLibraryTree: Suchfeld (global-lib-toolbar, filtert root+folder+favorites), ★/☆-Favorite-Toggle je Block (setGlobalBlockFavorite-API, optimistisches UI-Update), Favoriten-Sektion mit goldenem Titel, SVG-Thumbnail-Vorschau (tree-thumb, dangerouslySetInnerHTML, Fallback 📦), Grid-Ansicht (lib-grid, Thumbnail-Zellen mit Drag-Drop + Kontextmenü + Favorit); GlobalBlock.is_favorite: boolean in api.ts | Frontend: 491/491 Tests, tsc 0, build OK | 8b50c0a | **F2 COMPLETE — LibraryPanel-Kernfeatures**: Baum+Suche+Favoriten+Thumbnails+Grid+DragDrop→Canvas (Drop-Handler existierte bereits) |
| 2026-08-28 | F3 | blockThumbnail.ts NEU (generateBlockSvg/primitivesToSvg: Elemente→elementToPrimitives→eigenständiges SVG mit PAD-BBox, alle 6 Primitive-Kinds, XML-Escape, Arc→path mit Konvention 0°=3Uhr/CCW); Save-Button 💾 im Bibliotheks-Header (liest __v2GetSelection + documentService.getActiveDocument, Name via prompt, block_data=Element-Array → Drop-Handler akzeptiert Arrays, svg_data=Thumbnail, createGlobalBlock+loadData-Refresh); Dokumentierte Abweichung: SVG statt canvas.toDataURL (konsistent, testbar); ROT-Beweis: 4 Tests zuerst fehlgeschlagen (Modul fehlte), 1 Erwartung nach Primitive-BBox-Analyse korrigiert (-50 -50 560 360); Fix: Duplikat-Import + Handler vor loadData-Deklaration (tsc 2300/2448) | Frontend: 495/495 Tests (+4), tsc 0, build OK | 5d8253a | **F3 COMPLETE — Auswahl als Block speichern**: Canvas-Selektion → wiederverwendbarer Block mit Thumbnail, sofort per DragDrop einsetzbar |
| 2026-08-28 | ids | Nebenproblem „Date.now()-IDs“ erledigt: Alle 9 verbleibenden Auto-ID-Generierungen im SqliteAdapter (user/proj/pfolder/draw/layer/elem/block/notif/share) auf etabliertes randomUUID().slice(0,8)-Muster umgestellt (gleiche BUG-1-Kollisionsklasse wie gblock-Fix in F1); Deterministischer ROT-Beweis: 4 neue Tests mit vi.useFakeTimers+EINGEFRORENER Systemzeit (zwei Creates im selben Tick → UNIQUE-PK-Exception mit Date.now, eindeutig mit UUID); perl-Escape-Fehler (\${p} literal) selbst erkannt und zeilenweise korrigiert | Backend: 267/267 Tests (+4), tsc 0 | c78d8d8 | **BUG-1-KLASSE GESCHLOSSEN** — keine Date.now()-Auto-IDs mehr im Backend |
## Gefundene Nebenprobleme (noch nicht zugewiesen)
- `webhook-receiver.py` im Root: Zweck unklar, ggf. entfernen/dokumentieren (Frage an Nutzer)
- `phase2..4-task-spec.md` veraltet gegenüber BAUPLAN — nach v2.0 archivieren
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
---
*Nächster offener Schritt: **I3** (Release 2.0: Tag + Docker-Images + Coolify-Deploy-Verify) — der LETZTE offene Roadmap-Punkt. Alle Phasen AH + I1 + I2 sind komplett.*
*Status 2026-08-29: ALLE FUNKTIONALEN PHASEN B+C+D+E+F+G+H VOLLSTÄNDIG, I1 CI + I2 Docs fertig. FE 697/697 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main. Offen: NUR NOCH I3 (Release 2.0).*
+77
View File
@@ -14,6 +14,7 @@
"bcrypt": "^6.0.0",
"better-sqlite3": "^11.0.0",
"fastify": "^5.9.0",
"jszip": "^3.10.1",
"y-leveldb": "^0.2.0",
"y-protocols": "^1.0.7",
"yjs": "^13.6.0"
@@ -1503,6 +1504,11 @@
"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": {
"version": "6.0.0",
"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": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -1926,6 +1937,11 @@
"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": {
"version": "0.2.5",
"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",
"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": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/level/-/level-8.0.1.tgz",
@@ -2135,6 +2189,14 @@
"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": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@@ -2587,6 +2649,11 @@
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
@@ -2722,6 +2789,11 @@
"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": {
"version": "5.0.0",
"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",
"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": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+1
View File
@@ -18,6 +18,7 @@
"bcrypt": "^6.0.0",
"better-sqlite3": "^11.0.0",
"fastify": "^5.9.0",
"jszip": "^3.10.1",
"y-leveldb": "^0.2.0",
"y-protocols": "^1.0.7",
"yjs": "^13.6.0"
+17
View File
@@ -44,3 +44,20 @@ export function requireAdmin(request: FastifyRequest, reply: FastifyReply, authS
}
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;
}
+20
View File
@@ -121,6 +121,7 @@ export interface DBGlobalBlock {
name: string;
block_data: string;
svg_data: string | null;
is_favorite: boolean;
created_at: string;
updated_at: string;
}
@@ -213,4 +214,23 @@ export interface DatabaseInterface {
createGlobalBlock(data: Partial<DBGlobalBlock>): DBGlobalBlock;
updateGlobalBlock(id: string, data: Partial<DBGlobalBlock>): DBGlobalBlock | null;
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;
}
+63 -17
View File
@@ -11,6 +11,7 @@ import type {
DBElement, DBBlock, DBSetting, DBUser, DBSession,
DBNotification, DBProjectShare,
DBGlobalBlockFolder, DBGlobalBlock,
DBGuest,
DBProjectFolder,
} from './DatabaseInterface.js';
@@ -34,6 +35,11 @@ export class SqliteAdapter implements DatabaseInterface {
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');
}
// 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 {
@@ -50,7 +56,7 @@ export class SqliteAdapter implements DatabaseInterface {
}
createUser(data: Partial<DBUser>): DBUser {
const id = data.id ?? `user-${Date.now()}`;
const id = data.id ?? `user-${randomUUID().slice(0, 8)}`;
this.db.prepare(
'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)',
).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 {
const id = data.id ?? `proj-${Date.now()}`;
const id = data.id ?? `proj-${randomUUID().slice(0, 8)}`;
this.db.prepare(
'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);
@@ -143,7 +149,7 @@ export class SqliteAdapter implements DatabaseInterface {
}
createProjectFolder(data: Partial<DBProjectFolder>): DBProjectFolder {
const id = data.id ?? `pfolder-${Date.now()}`;
const id = data.id ?? `pfolder-${randomUUID().slice(0, 8)}`;
this.db.prepare(
'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');
@@ -177,7 +183,7 @@ export class SqliteAdapter implements DatabaseInterface {
}
createDrawing(data: Partial<DBDrawing>): DBDrawing {
const id = data.id ?? `draw-${Date.now()}`;
const id = data.id ?? `draw-${randomUUID().slice(0, 8)}`;
this.db.prepare(
'INSERT INTO drawings (id, project_id, name, data_json) VALUES (?, ?, ?, ?)',
).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 {
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.
// This prevents race conditions under React StrictMode double-render.
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 {
const id = data.id ?? `elem-${Date.now()}`;
const id = data.id ?? `elem-${randomUUID().slice(0, 8)}`;
this.db.prepare(
'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,
@@ -275,7 +281,7 @@ export class SqliteAdapter implements DatabaseInterface {
}
createBlock(data: Partial<DBBlock>): DBBlock {
const id = data.id ?? `block-${Date.now()}`;
const id = data.id ?? `block-${randomUUID().slice(0, 8)}`;
this.db.prepare(
'INSERT INTO blocks (id, drawing_id, name, description, category, elements_json, thumbnail) VALUES (?, ?, ?, ?, ?, ?, ?)',
).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 {
const id = data.id ?? `notif-${Date.now()}`;
const id = data.id ?? `notif-${randomUUID().slice(0, 8)}`;
this.db.prepare(
'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);
@@ -371,7 +377,7 @@ export class SqliteAdapter implements DatabaseInterface {
}
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();
this.db.prepare(
'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 {
const id = data.id ?? `gfolder-${Date.now()}`;
const id = data.id ?? `gfolder-${randomUUID().slice(0, 8)}`;
this.db.prepare(
'INSERT INTO global_block_folders (id, name, parent_id) VALUES (?, ?, ?)',
).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null);
@@ -422,25 +428,31 @@ export class SqliteAdapter implements DatabaseInterface {
}
// ─── 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[] {
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) {
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 {
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 {
const id = data.id ?? `gblock-${Date.now()}`;
const id = data.id ?? `gblock-${randomUUID().slice(0, 8)}`;
this.db.prepare(
'INSERT INTO global_blocks (id, folder_id, name, block_data, svg_data) VALUES (?, ?, ?, ?, ?)',
).run(id, data.folder_id ?? null, data.name ?? 'Global Block', data.block_data ?? '{}', data.svg_data ?? null);
'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, data.is_favorite ? 1 : 0);
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.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.is_favorite !== undefined) { sets.push('is_favorite = ?'); vals.push(data.is_favorite ? 1 : 0); }
sets.push("updated_at = datetime('now')");
this.db.prepare(`UPDATE global_blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
return this.getGlobalBlock(id);
@@ -459,4 +472,37 @@ export class SqliteAdapter implements DatabaseInterface {
deleteGlobalBlock(id: string): boolean {
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;
}
}
+15
View File
@@ -171,6 +171,7 @@ CREATE TABLE IF NOT EXISTS global_blocks (
name TEXT NOT NULL,
block_data TEXT NOT NULL DEFAULT '{}',
svg_data TEXT,
is_favorite INTEGER NOT NULL DEFAULT 0,
created_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
@@ -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_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);
+202 -8
View File
@@ -3,9 +3,10 @@
*/
import type { FastifyInstance } from 'fastify';
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 { validateName, validateIdParam } from '../utils/validation.js';
import JSZip from 'jszip';
export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// ─── Global Block Folders ──────────────────────────────
@@ -35,7 +36,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
// Create a new folder
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 nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr });
@@ -48,7 +49,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
// Rename / move a folder
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 idErr = validateIdParam(id, 'id');
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)
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 idErr = validateIdParam(id, 'id');
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
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 nameErr = validateName(body.name, 'name');
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)
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 idErr = validateIdParam(id, 'id');
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) {
const nameErr = validateName(body.name, 'name');
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) {
const fidErr = validateIdParam(body.folder_id, 'folder_id');
if (fidErr) return reply.code(400).send({ error: fidErr });
@@ -156,7 +160,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
// Delete a global block
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 idErr = validateIdParam(id, 'id');
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' });
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 });
});
}
+63
View File
@@ -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();
});
}
+7
View File
@@ -22,6 +22,11 @@ export interface ServerOptions {
export async function createServer(opts: ServerOptions) {
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
await fastify.register(cors, {
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 { registerNotificationRoutes } = await import('./routes/notifications.js');
const { registerShareRoutes } = await import('./routes/shares.js');
const { registerGuestRoutes } = await import('./routes/guests.js');
const { registerGlobalBlockRoutes } = await import('./routes/globalBlocks.js');
const { registerProjectFolderRoutes } = await import('./routes/projectFolders.js');
@@ -103,6 +109,7 @@ export async function createServer(opts: ServerOptions) {
registerNotificationRoutes(fastify, opts.db, authService);
registerShareRoutes(fastify, opts.db, authService);
registerGlobalBlockRoutes(fastify, opts.db, authService);
registerGuestRoutes(fastify, opts.db, authService);
registerProjectFolderRoutes(fastify, opts.db, authService);
// Yjs collaboration WebSocket
+69
View File
@@ -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);
});
});
+188
View File
@@ -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);
});
});
+164
View File
@@ -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);
});
});
+160
View File
@@ -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);
});
});
+80
View File
@@ -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.
+139
View File
@@ -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);
```
+86
View File
@@ -10,6 +10,7 @@
"dependencies": {
"dxf-parser": "^1.1.2",
"pdf-lib": "^1.17.1",
"pixi.js": "^8.20.1",
"rbush": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -661,6 +662,11 @@
"pako": "^1.0.10"
}
},
"node_modules/@pixi/colord": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA=="
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
@@ -1094,6 +1100,11 @@
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true
},
"node_modules/@types/earcut": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ=="
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1257,6 +1268,19 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.72",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
"integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw=="
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
"integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/abstract-leveldown": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
@@ -1559,6 +1583,11 @@
"loglevel": "^1.7.1"
}
},
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="
},
"node_modules/electron-to-chromium": {
"version": "1.5.378",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz",
@@ -1629,6 +1658,11 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -1678,6 +1712,14 @@
"node": ">=6.9.0"
}
},
"node_modules/gifuct-js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
"dependencies": {
"js-binary-schema-parser": "^2.0.3"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
@@ -1737,6 +1779,11 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true
},
"node_modules/ismobilejs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw=="
},
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
@@ -1746,6 +1793,11 @@
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/js-binary-schema-parser": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg=="
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2377,6 +2429,11 @@
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"node_modules/parse-svg-path": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz",
"integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg=="
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -2424,6 +2481,27 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pixi.js": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.20.1.tgz",
"integrity": "sha512-akLVBMLvQbaEViqsmVraK0Njer1q4Q4lm4iNKuzvBiFrV8q+6/+HluJN3sWIwO663IBeQtUUS/CaXFJZs6ffXQ==",
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/earcut": "^3.0.0",
"@webgpu/types": "^0.1.69",
"@xmldom/xmldom": "^0.8.15",
"earcut": "^3.0.2",
"eventemitter3": "^5.0.1",
"gifuct-js": "^2.1.2",
"ismobilejs": "^1.1.1",
"parse-svg-path": "^0.2.0",
"tiny-lru": "^11.4.7"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/pixijs"
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
@@ -2754,6 +2832,14 @@
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true
},
"node_modules/tiny-lru": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
"engines": {
"node": ">=12"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+2 -1
View File
@@ -9,11 +9,12 @@
"preview": "vite preview",
"test": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ci": "playwright test playwright-tests/diagnose-editor.test.ts playwright-tests/tool-exhaustive.test.ts"
"test:e2e:ci": "playwright test playwright-tests/diagnose-editor.test.ts playwright-tests/tool-exhaustive.test.ts playwright-tests/perf-gate.test.ts"
},
"dependencies": {
"dxf-parser": "^1.1.2",
"pdf-lib": "^1.17.1",
"pixi.js": "^8.20.1",
"rbush": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -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();
});
@@ -0,0 +1,124 @@
/**
* Phase-A-Verifikation (ROADMAP Übergabepunkt):
* Beweist mit echtem Chromium bei gesetzttem VITE_TOOL_DISPATCHER=v2:
* 1. Ribbon-V2-Sektion rendert die registrierten V2-Werkzeuge
* 2. Werkzeugwechsel über den v2tool:-Kanal funktioniert
* 3. Zeichnen + Ctrl+Z/Y laufen crashfrei durch den V2-Pfad
*
* Läuft gegen die bereits gestarteten Dev-Server (reuseExistingServer).
*/
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';
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('Phase-A: V2-Pfad end-to-end (Ribbon-Sektion, Toolwechsel, Zeichnen+Undo crashfrei)', async () => {
test.setTimeout(180000); // Summe aller Schritte (Auth+Nav+Pixi lazy+Draw+Undo+Shots)
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
// ── Auth & Navigation (Muster aus diagnose-editor.test.ts) ──
const email = `pa-verify-${Date.now()}@example.com`;
const reg = await api('POST', '/api/auth/register', { email, password: 'Test1234!', name: 'PA' });
expect(reg.status).toBe(201);
const token = reg.body.session.token;
const proj = await api('POST', '/api/projects', { name: `PA-${Date.now()}`, description: 'x' }, token);
expect(proj.status).toBe(201);
await page.goto(`${BASE_URL}/`);
await page.evaluate((t) => localStorage.setItem('auth_token', t), token);
await page.reload();
// Dashboard-Karte warten (Klasse .dashboard-project-card existiert in Dashboard.tsx Z.688):
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(1500); // Engines mounten lassen
// ── Fehlerkanäle sammeln — V2-Pfad darf KEINE erzeugen ──
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (err) => pageErrors.push(err.message));
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
// ── 1) Ribbon: Zeichenflächen-Tab öffnen und V2-Sektion prüfen ──
await page.locator('.ribbon-tab', { hasText: 'Zeichenfläche' }).first().click();
await page.waitForTimeout(300);
const v2Section = page.locator('text=V2:').first();
await expect(v2Section).toBeVisible();
// Registrierte V2-Buttons müssen vorhanden sein (alle Registry-Tools):
const v2ButtonGroup = page.locator('[data-v2tool]');
const buttonCount = await v2ButtonGroup.count();
expect(buttonCount).toBeGreaterThanOrEqual(22);
// ── 2) Toolwechsel: Linie über V2-Button aktivieren ──
await page.locator('[data-v2tool="line"]').first().click();
await page.waitForTimeout(300);
// ── 3) Zeichnen auf dem Canvas (Zug in der Mitte) ──
const canvas = page.locator('canvas').first();
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
const cx = box!.x + box!.width / 2;
const cy = box!.y + box!.height / 2;
await page.mouse.move(cx - 100, cy - 50);
await page.mouse.down();
await page.mouse.move(cx + 100, cy + 50, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(400);
// ── 4) Undo/Redo Shortcuts crashfrei ──
await page.keyboard.press('Control+z');
await page.waitForTimeout(300);
await page.keyboard.press('Control+y');
await page.waitForTimeout(300);
// ── 4b) Task C4: PixiRenderer hat die Elemente im Cache ──
const pixiInfo = await page.evaluate(() => {
const debug = (window as unknown as { __v2Debug?: { info(): { cachedElements: number; ready: boolean } | null } }).__v2Debug;
return debug?.info() ?? null;
});
expect(pixiInfo).not.toBeNull();
expect(pixiInfo!.ready).toBe(true);
expect(pixiInfo!.cachedElements).toBeGreaterThanOrEqual(1);
// Nach Undo muss der Cache leer sein (Referenzdiff synchronisiert)
await page.keyboard.press('Control+z');
await page.waitForTimeout(300);
const pixiInfoAfterUndo = await page.evaluate(() => {
const debug = (window as unknown as { __v2Debug?: { info(): { cachedElements: number; ready: boolean } | null } }).__v2Debug;
return debug?.info() ?? null;
});
expect(pixiInfoAfterUndo!.cachedElements).toBe(0);
// Kein Seitenfehler durch den gesamten V2-Ablauf:
expect(pageErrors).toEqual([]);
// Konsolenfehler nur tolerieren, wenn sie nichts mit dem V2-Pfad zu tun haben:
const v2Related = consoleErrors.filter((e) => /dispatcher|v2tool|undo/i.test(e));
expect(v2Related).toEqual([]);
// ── 5) Sichtbelege ──
await page.screenshot({ path: 'playwright-tests/screenshots/phase-a-canvas.png', fullPage: false });
await page.keyboard.press('Control+z');
await page.waitForTimeout(250);
await page.screenshot({ path: 'playwright-tests/screenshots/phase-a-after-undo.png', fullPage: false });
await browser.close();
});
+35 -1
View File
@@ -15,9 +15,13 @@ import CanvasArea from './components/CanvasArea';
import RightSidebar from './components/RightSidebar';
import CommandLine from './components/CommandLine';
import StatusBar from './components/StatusBar';
import LayoutBar from './components/LayoutBar';
import MobileDrawers from './components/MobileDrawers';
import BackgroundImport from './components/BackgroundImport';
import HistoryPanel from './components/HistoryPanel';
import V2HistoryPanel from './components/V2HistoryPanel';
import GuestPanel from './components/GuestPanel';
import { V2_TOOLS_ENABLED } from './kernel/featureFlags';
import SettingsModal from './components/SettingsModal';
import InlineTextEditor from './components/InlineTextEditor';
import { BackgroundService, type BackgroundConfig } from './services/backgroundService';
@@ -174,6 +178,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
// History panel
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
const [guestPanelOpen, setGuestPanelOpen] = useState(false);
// Export format selector (cross-browser replacement for nwsave)
const [exportFormatOpen, setExportFormatOpen] = useState(false);
@@ -1146,6 +1151,16 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
// ─── Tools actions ───
if (action === 'measure') { setActiveTool('dimension'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Messwerkzeug aktiv', type: 'info' }]); 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
// bestehende CanvasArea-Sync aus A4.1 routet es an den Dispatcher (Flag aktiv).
if (action.startsWith('v2tool:')) {
const toolId = action.slice('v2tool:'.length);
setActiveTool(toolId);
setCommandHistory((prev) => [...prev, { prefix: '·', text: `V2-Werkzeug aktiv: ${toolId}`, type: 'info' }]);
return;
}
// ─── Background ───
if (action === 'background-import') { setBgImportOpen(true); return; }
@@ -1563,6 +1578,11 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
activeTab={activeRibbonTab}
onTabChange={setActiveRibbonTab}
onAction={handleRibbonAction}
onIsV2ToolActive={(toolId: string) =>
V2_TOOLS_ENABLED &&
activeTool === toolId &&
!!pluginRegistry.getToolV2(toolId)
}
canvasBgColor={canvasBgColor}
gridColor={gridColor}
rulerEnabled={rulerEnabled}
@@ -1685,6 +1705,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
history={commandHistory}
onCommand={handleCommand}
/>
<LayoutBar />
<StatusBar
snapEnabled={snapEnabled}
orthoEnabled={orthoEnabled}
@@ -1728,7 +1749,11 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
onApply={handleBgApply}
backgroundService={bgServiceRef.current}
/>
{historyPanelOpen && (
{historyPanelOpen && (V2_TOOLS_ENABLED ? (
<V2HistoryPanel
onClose={() => setHistoryPanelOpen(false)}
/>
) : (
<HistoryPanel
entries={historyEntries}
onJumpTo={(entryId: string) => {
@@ -1741,6 +1766,15 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
}}
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 && (
<div className="export-format-overlay" onClick={() => setExportFormatOpen(false)}>
+26 -5
View File
@@ -50,6 +50,10 @@ export class RenderEngine {
private snapPoints: SnapPoint[] = [];
private activeSnapPoint: SnapPoint | null = null;
private previewElement: CADElement | null = null;
/** Gecachtes Hintergrundbild (BUG-4-Fix): nur 1× pro backgroundSrc laden. */
private bgImage: HTMLImageElement | null = null;
/** src des zuletzt erfolgreich geladenen Hintergrundbilds. */
private bgSrcLoaded = '';
private dpr = 1;
constructor(
@@ -379,13 +383,30 @@ export class RenderEngine {
}
private drawBackground(): void {
// Background image rendering with transform
const img = new Image();
img.src = this.options.backgroundSrc!;
if (!img.complete) {
img.onload = () => this.render();
// Background image rendering with transform.
// BUG-4-Fix: Das Bild wird nur EINMAL pro backgroundSrc geladen und gecacht;
// vorher wurde pro Frame ein neues Image erzeugt (Flackern/Rerender-Schleife).
const src = this.options.backgroundSrc!;
// Quellwechsel: Cache invalidieren und neu laden
if (!this.bgImage || this.bgSrcLoaded !== src) {
const img = new Image();
img.onload = () => {
this.bgImage = img;
this.bgSrcLoaded = src;
this.render(); // einmalig nach erfolgreichem Laden neu zeichnen
};
img.src = src;
if (img.complete && img.naturalWidth > 0) {
// Bereits aus Browser-Cache verfügbar (z.B. dataURL)
this.bgImage = img;
this.bgSrcLoaded = src;
}
return;
}
// Gecachtes Bild zeichnen
const img = this.bgImage;
this.ctx.save();
this.ctx.globalAlpha = this.options.backgroundOpacity;
const s = this.zoomPan.getScale();
+95 -1
View File
@@ -104,6 +104,16 @@ export class SnapEngine {
if (this.config.modes.has('nearest')) {
this.collectNearest(el, worldX, worldY, tol, candidates);
}
// ── Task D11: erweiterte Snaps ──
if (this.config.modes.has('quadrant')) {
this.collectQuadrant(el, worldX, worldY, tol, candidates);
}
if (this.config.modes.has('perpendicular') && refPoint) {
this.collectPerpendicular(el, worldX, worldY, refPoint, tol, candidates);
}
if (this.config.modes.has('tangent') && refPoint) {
this.collectTangent(el, worldX, worldY, refPoint, tol, candidates);
}
}
// Intersection snap (between pairs)
@@ -124,7 +134,8 @@ export class SnapEngine {
// Priority: endpoint > intersection > center > midpoint > nearest > grid
const priority: Record<string, number> = {
endpoint: 0, intersection: 1, center: 2, midpoint: 3, nearest: 4, grid: 5,
endpoint: 0, intersection: 1, center: 2, quadrant: 3, perpendicular: 4,
tangent: 5, midpoint: 6, nearest: 7, grid: 8,
};
// Find best within tolerance — prefer higher priority if distances are close
@@ -234,6 +245,79 @@ export class SnapEngine {
}
}
/** Task D11: Quadranten-Punkte bei Kreis/Bogen (0/90/180/270). */
private collectQuadrant(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const check = (x: number, y: number) => {
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
if (d < tol) out.push({ x, y, type: 'quadrant' });
};
if (el.type === 'circle' || el.type === 'arc') {
const r = (el.properties as Record<string, unknown>).radius as number | undefined ?? el.width / 2;
check(el.x + r, el.y); // 0
check(el.x, el.y + r); // 90
check(el.x - r, el.y); // 180
check(el.x, el.y - r); // 270
}
}
/**
* Task D11: Perpendicular-Fußpunkt der Punkt auf einem Segment,
* an dem die Verbindung zum refPoint SENKRECHT auf dem Segment steht.
*/
private collectPerpendicular(
el: CADElement,
wx: number, wy: number,
refPoint: { x: number; y: number },
tol: number,
out: SnapPoint[],
): void {
const p = el.properties as Record<string, unknown>;
const segs: Array<Array<{ x: number; y: number }>> = [];
if (
p.x1 !== undefined && p.y1 !== undefined &&
p.x2 !== undefined && p.y2 !== undefined
) {
segs.push([{ x: p.x1 as number, y: p.y1 as number }, { x: p.x2 as number, y: p.y2 as number }]);
} else if (Array.isArray(p.points)) {
const pts = p.points as Array<{ x: number; y: number }>;
for (let i = 0; i < pts.length - 1; i++) segs.push([pts[i], pts[i + 1]]);
}
for (const [a, b] of segs) {
const foot = this.nearestOnSegment(refPoint.x, refPoint.y, a.x, a.y, b.x, b.y);
const d = Math.sqrt((wx - foot.x) ** 2 + (wy - foot.y) ** 2);
if (d < tol) out.push({ x: foot.x, y: foot.y, type: 'perpendicular' });
}
}
/**
* Task D11: Tangenten-Punkte Berührpunkte der Tangenten vom
* refPoint an den Kreis (beide Lösungen, klassische Konstruktion
* über rechtwinkliges Dreieck: Winkel = acos(r/d)).
*/
private collectTangent(
el: CADElement,
wx: number, wy: number,
refPoint: { x: number; y: number },
tol: number,
out: SnapPoint[],
): void {
if (el.type !== 'circle' && el.type !== 'arc') return;
const r = ((el.properties as Record<string, unknown>).radius as number | undefined) ?? el.width / 2;
const dx = refPoint.x - el.x;
const dy = refPoint.y - el.y;
const d = Math.hypot(dx, dy);
// refPoint im Zentrum oder im Kreisinneren: keine reale Tangente
if (d <= r) return;
const baseAng = Math.atan2(dy, dx);
const halfAng = Math.acos(r / d);
for (const t of [baseAng + halfAng, baseAng - halfAng]) {
const px = el.x + r * Math.cos(t);
const py = el.y + r * Math.sin(t);
const dist = Math.sqrt((wx - px) ** 2 + (wy - py) ** 2);
if (dist < tol) out.push({ x: px, y: py, type: 'tangent' });
}
}
private collectNearest(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const p = el.properties;
const check = (x: number, y: number) => {
@@ -400,3 +484,13 @@ export class SnapEngine {
return { x: snapX, y: snapY, type: 'nearest' };
}
}
// ─────────────────────────────────────────────────────────────
// Task D11: fromOffset — Basispunkt + Eingabefeld dx/dy.
// Reine Funktion (für from-offset-Snap im UI nutzbar).
// ─────────────────────────────────────────────────────────────
/** Zielpunkt = Basispunkt + Offsets dx/dy (Task D11 from-offset). */
export function fromOffset(base: { x: number; y: number }, dx: number, dy: number): { x: number; y: number } {
return { x: base.x + dx, y: base.y + dy };
}
+11
View File
@@ -60,6 +60,17 @@ export class ZoomPanController {
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 {
const padding = 20;
const w = rect.maxX - rect.minX;
+278 -78
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import type { GlobalBlockFolder, GlobalBlock } from '../services/api';
import { importSVGAsBlockPayload } from '../services/importService';
import {
getGlobalFolders,
createGlobalFolder,
@@ -9,7 +10,15 @@ import {
createGlobalBlock,
renameGlobalBlock,
deleteGlobalBlock,
setGlobalBlockFavorite,
exportLibrary,
importLibrary,
exportLibraryZip,
importLibraryZip,
} from '../services/api';
import { getActiveDocument } from '../kernel/document/documentService';
import { generateBlockSvg } from '../utils/blockThumbnail';
import type { CADElement } from '../types/cad.types';
interface BlockLibraryTreeProps {
token: string;
@@ -38,8 +47,23 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
} | null>(null);
const [loading, setLoading] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const wcadInputRef = useRef<HTMLInputElement>(null);
const zipInputRef = useRef<HTMLInputElement>(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 () => {
try {
const [allFolders, allBlocks] = await Promise.all([
@@ -59,6 +83,99 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
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
const buildTree = useCallback((): FolderNode[] => {
const folderMap = new Map<string, FolderNode>();
@@ -92,7 +209,10 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
return roots;
}, [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 toggleExpand = (folderId: string) => {
@@ -186,10 +306,11 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
reader.onload = async (ev) => {
const svgContent = ev.target?.result as string;
try {
const svgPayload = importSVGAsBlockPayload(svgContent);
const created = await createGlobalBlock(token, {
name: file.name.replace(/\.svg$/i, ''),
folder_id: folderId,
block_data: JSON.stringify({ type: 'svg', svg: svgContent }),
block_data: svgPayload.blockData ?? JSON.stringify({ type: 'svg', svg: svgContent }),
svg_data: svgContent,
});
setBlocks(prev => [...prev, created]);
@@ -224,9 +345,10 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
reader.onload = async (ev) => {
const svgContent = ev.target?.result as string;
try {
const svgPayload = importSVGAsBlockPayload(svgContent);
const created = await createGlobalBlock(token, {
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,
});
setBlocks(prev => [...prev, created]);
@@ -271,6 +393,83 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
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 isRenaming = renamingId === node.folder.id;
const isDragOver = dragOverFolderId === node.folder.id;
@@ -314,44 +513,7 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
{node.expanded && (
<div className="tree-children">
{node.children.map(child => renderFolderNode(child, depth + 1))}
{node.blocks.map(block => {
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>
);
})}
{node.blocks.filter(matchesSearch).map(block => renderBlockLeaf(block, depth + 1))}
</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-header">
<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
className="global-lib-add-btn"
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>
</button>
<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 className="global-lib-toolbar">
<input
type="text"
className="global-lib-search"
placeholder="Blöcke suchen…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<button
className="global-lib-add-btn"
title={viewMode === 'grid' ? 'Baumansicht' : 'Rasteransicht'}
onClick={() => setViewMode(m => (m === 'grid' ? 'tree' : 'grid'))}
>
{viewMode === 'grid' ? '☰' : '▦'}
</button>
</div>
<div className="tree tree-global" role="tree" aria-label="Globale Block-Bibliothek">
{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
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(''); }
}}
/>
) : (
<span
className="tree-label"
onDoubleClick={(e) => { e.stopPropagation(); startRename(block.id, block.name); }}
>
{block.name}
</span>
)}
</div>
);
})}
{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 && (
<div className="global-lib-empty">Globale Bibliothek ist leer Rechtsklick für Ordner</div>
)}
+204
View File
@@ -12,6 +12,14 @@ import { SpatialIndex } from '../canvas/SpatialIndex';
import { LayerManager } from '../canvas/LayerManager';
import { GroupManager } from '../tools/modification/GroupTool';
import { formatCoordinate } from '../utils/format';
import { InteractionDispatcher } from '../interaction/dispatcher';
import { logger } from '../utils/logger';
import { CADDocument } from '../kernel/document/CADDocument';
import { YjsDocument } from '../crdt/YjsDocument';
import { setActiveDocument, getActiveDocument } from '../kernel/document/documentService';
import { V2_TOOLS_ENABLED, USE_PIXI_RENDERER } from '../kernel/featureFlags';
import { PixiRenderer } from '../render/pixi/PixiRenderer';
import { pluginRegistry } from '../plugins';
const CanvasArea: React.FC<CanvasAreaProps> = ({
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
@@ -26,6 +34,13 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
const zoomPanRef = useRef<ZoomPanController | null>(null);
const renderEngineRef = useRef<RenderEngine | null>(null);
const interactionRef = useRef<InteractionEngine | null>(null);
const dispatcherRef = useRef<InteractionDispatcher | null>(null);
/** Task C2: PixiJS-Canvas-Ref für V2-Elemente-Overlay. */
const pixiCanvasRef = useRef<HTMLCanvasElement>(null);
/** Task C2: Aktiver PixiJS-Renderer (für Cleanup). */
const pixiRendererRef = useRef<PixiRenderer | null>(null);
/** Bereits an onElementCreated gemeldete V2-Element-IDs (verhindert Doppelmeldung bei jedem onChanged-Frame). */
const announcedV2Ids = useRef<Set<string>>(new Set());
const spatialIndexRef = useRef<SpatialIndex | null>(null);
const layerManagerRef = useRef<LayerManager | null>(null);
const selectionEngineRef = useRef<SelectionEngine | null>(null);
@@ -44,6 +59,149 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
canvas, zoomPan, renderEngine, snapEngine, selectionEngine, spatialIndex, layerManager,
);
// ── Task A2/A4.14: V2-Pfad ist seit Verifikation STANDARD — Opt-out via
// VITE_TOOL_DISPATCHER=legacy. Siehe kernel/featureFlags.ts.
if (V2_TOOLS_ENABLED) {
const ydoc = new YjsDocument();
ydoc.data.layers.set('layer-0', {
id: 'layer-0', name: 'Standard', visible: true, locked: false,
color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
} as never);
const v2Doc = new CADDocument(ydoc);
// App-Level-Zugriffspunkt für HistoryPanel/Tastenkürzel (Task A4.11):
setActiveDocument(v2Doc);
// ── Task C2/C4: PixiJS-Renderer parallel schalten ──
// DEFENSIV: Ein Ausfall von Pixi (z.B. WebGL im Headless-Browser)
// darf NIEMALS den kompletten Editor-Mount brechen. Im Fehlerfall
// läuft weiter ohne Overlay.
let pixi: PixiRenderer | null = null;
const pixiCanvas = pixiCanvasRef.current;
if (pixiCanvas && USE_PIXI_RENDERER) {
try {
pixi = new PixiRenderer();
pixiRendererRef.current = pixi;
pixi.init(pixiCanvas);
pixi.waitForReady().then(() => {
// Task C4: Debug-Hook für Playwright-E2E-Assertions
(window as unknown as { __v2Debug: unknown }).__v2Debug = {
renderer: pixi,
info: () => pixiRendererRef.current?.getDebugInfo() ?? null,
};
}).catch((err) => {
logger.warn('Pixi init failed — V2-Overlay deaktiviert:', err);
pixi = null;
pixiRendererRef.current = null;
});
} catch (err) {
logger.warn('Pixi init exception — V2-Overlay deaktiviert:', err);
pixi = null;
pixiRendererRef.current = null;
}
}
/** Letztes bekanntes Elementobjekt je ID (Referenzdiff statt JSON). */
const lastKnownEl = new Map<string, CADElement>();
const syncPixi = (): void => {
if (!pixi) return;
void pixi.waitForReady().then(() => {
const all = v2Doc.getAllElements();
const visibleIds = new Set<string>();
for (const el of all) {
visibleIds.add(el.id);
// Nur neu zeichnen wenn sich die OBJEKTREFERENZ geändert hat:
if (lastKnownEl.get(el.id) !== el) {
lastKnownEl.set(el.id, el);
pixi!.drawElement(el, {
id: 'layer-0', name: 'Standard', visible: true, locked: false,
color: '#ffffff', lineType: 'solid', transparency: 0,
sortOrder: 0, parentId: null,
} as never, {
color: '#ffffff', lineType: 'solid', transparency: 0, visible: true,
});
}
}
// Gelöschte IDs aus dem Renderer entfernen:
for (const knownId of lastKnownEl.keys()) {
if (!visibleIds.has(knownId)) {
lastKnownEl.delete(knownId);
pixi!.removeElement(knownId);
}
}
});
};
// Committete V2-Elemente fließen über den etablierten Kanal in UI/State:
v2Doc.onChanged(() => {
for (const el of v2Doc.getAllElements()) {
if (!announcedV2Ids.current.has(el.id)) {
announcedV2Ids.current.add(el.id);
onElementCreated(el);
}
}
syncPixi(); // Task C2: V2-Elemente in den PixiJS-Overlay-Renderer spiegeln
});
dispatcherRef.current = new InteractionDispatcher(canvas, {
zoomPan,
setStatus: (msg) => {
/* StatusBar-Bridge folgt in A4 */
},
setPreview: (el) => renderEngine.setPreviewElement(el as CADElement | null),
getDoc: () => v2Doc,
getSelectionBridge: () => ({
getIds: () => Array.from(selectionEngine.getSelectedIds()),
setIds: (ids) => selectionEngine.selectByIds(ids, false),
hitTest: (wx, wy, tol) => renderEngine.hitTest(wx, wy, tol),
}),
});
// ── 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 ──
if (pixi && USE_PIXI_RENDERER) {
// Snap-Marks bei Mausbewegung (wenn ein V2-Tool aktiv ist):
const snapMove = (e: PointerEvent) => {
if (!dispatcherRef.current?.getActive()) return;
const rect = canvas.getBoundingClientRect();
const world = zoomPan.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
const snapResult = snapEngine.snap(world.x, world.y);
const marks = snapResult.point
? [{ x: snapResult.point.x, y: snapResult.point.y, type: (snapResult.point as { type?: string }).type ?? 'snap' }]
: [];
pixi!.setSnapPoints(marks);
};
canvas.addEventListener('pointermove', snapMove);
// Selection-Handles bei Änderung:
const origSetIds = selectionEngine.selectByIds.bind(selectionEngine);
selectionEngine.selectByIds = (ids: string[], additive: boolean) => {
origSetIds(ids, additive);
const selected = v2Doc.getAllElements().filter((el) => ids.includes(el.id));
pixi!.setSelection({ selectedIds: ids, elements: selected });
};
}
logger.info('InteractionDispatcher v2 aktiviert (VITE_TOOL_DISPATCHER=v2)');
}
zoomPanRef.current = zoomPan;
renderEngineRef.current = renderEngine;
interactionRef.current = interaction;
@@ -66,6 +224,11 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
return () => {
interaction.detach();
dispatcherRef.current?.destroy();
dispatcherRef.current = null;
pixiRendererRef.current?.destroy(); // Task C2: WebGL-Kontext freigeben
pixiRendererRef.current = null;
setActiveDocument(null);
zoomPanRef.current = null;
renderEngineRef.current = null;
interactionRef.current = null;
@@ -229,9 +392,34 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
const interaction = interactionRef.current;
if (!interaction) return;
interaction.setTool(activeTool as ToolType);
// Task A4.1: V2-Werkzeuge zusätzlich an den Dispatcher routen (Flag aktiv).
// Legacy bleibt parallel aktiv für noch nicht migrierte Tools.
dispatcherRef.current?.setActive(
pluginRegistry.getToolV2(activeTool) ? activeTool : null,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTool]);
// Task A4.11: Globale Undo/Redo-Shortcuts wirken nur auf das V2-Dokument,
// wenn es registriert ist (= Flag aktiv). Legacy-History bleibt unberührt.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey)) return;
const doc = getActiveDocument();
if (!doc) return; // Flag aus → Legacy-Verhalten unangetastet
const key = e.key.toLowerCase();
if (key === 'z' && !e.shiftKey) {
e.preventDefault();
if (doc.undo()) logger.info('V2 undo');
} else if (key === 'y' || (key === 'z' && e.shiftKey)) {
e.preventDefault();
if (doc.redo()) logger.info('V2 redo');
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
// Sync selected template to interaction engine
useEffect(() => {
const interaction = interactionRef.current;
@@ -338,6 +526,22 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
<span style={{ color: 'var(--color-text-faint)' }}>{unit}</span>
</div>
{/* Task C2-Integration: PixiJS-Overlay für V2-Elemente liegt ÜBER dem
Legacy-Canvas (z-index höher), rendert nur Elemente aus dem V2-Dokument.
Pointer-Events gehen durch (pointerEvents:none), damit Drag/Drop weiter
auf dem Legacy-Canvas funktioniert. */}
{V2_TOOLS_ENABLED && (
<canvas
ref={pixiCanvasRef}
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: 5,
}}
/>
)}
<canvas
ref={canvasRef}
className="canvas-svg"
+4
View File
@@ -1,6 +1,7 @@
import React, { useState, useRef, useMemo, useEffect } from 'react';
import type { CommandLineProps } from '../types/ui.types';
import { getCommandRegistry, type CommandDefinition } from '../services/commandRegistry';
import { useUIMode } from '../services/uiModeService';
const defaultHistory = [
{ 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 uiMode = useUIMode();
if (uiMode === 'simple') return null;
const [input, setInput] = useState('');
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
const [commandHistory, setCommandHistory] = useState<string[]>([]);
+170
View File
@@ -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;
+220
View File
@@ -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;
+40 -1
View File
@@ -1,6 +1,9 @@
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 { formatInputValue, parseDistance, type UnitType } from '../utils/format';
import { getActiveDocument } from '../kernel/document/documentService';
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-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' }}>
{onDelete && el && (
<button
+44
View File
@@ -1,5 +1,8 @@
import React from 'react';
import { getUIMode, useUIMode } from '../services/uiModeService';
import type { RibbonBarProps, RibbonTab } from '../types/ui.types';
import { pluginRegistry } from '../plugins';
import { V2_TOOLS_ENABLED } from '../kernel/featureFlags';
const tabs: Array<{ id: RibbonTab; label: string; svg: React.ReactNode }> = [
{ id: 'start', label: 'Start', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> },
@@ -15,7 +18,10 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
activeTab, onTabChange, onAction,
canvasBgColor = '#1e1e2e', gridColor = '#3a3a4a', rulerEnabled = true,
onCanvasBgColorChange, onGridColorChange, onToggleRuler,
onIsV2ToolActive,
}) => {
const uiMode = useUIMode();
return (
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
<div className="ribbon-tabs" role="tablist">
@@ -36,6 +42,40 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
</div>
<div className="ribbon-content" id="ribbon-content">
{/* ===== V2 TOOLS (Task A4.13/A4.14: Standard aktiv — Opt-out via VITE_TOOL_DISPATCHER=legacy) ===== */}
{activeTab === 'canvas' && V2_TOOLS_ENABLED && (
<div style={{
display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center',
padding: '8px 4px', width: '100%',
borderBottom: '1px solid var(--color-border, #333)', marginBottom: '8px',
}}>
<span style={{ fontSize: '11px', color: 'var(--color-ki, #8a8aff)', marginRight: '4px' }}>V2:</span>
{pluginRegistry.getToolsV2()
.filter((tool) => uiMode === 'pro' || (tool.manifest.tags ?? []).includes('basic'))
.map((tool) => {
const isActive = onIsV2ToolActive?.(tool.manifest.id);
return (
<button
key={tool.manifest.id}
data-v2tool={tool.manifest.id}
title={tool.manifest.description ?? tool.manifest.label}
onClick={() => onAction(`v2tool:${tool.manifest.id}`)}
style={{
display: 'flex', alignItems: 'center', gap: '4px',
padding: '5px 9px', borderRadius: '4px', fontSize: '12px',
cursor: 'pointer', border: '1px solid var(--color-border, #333)',
background: isActive ? 'rgba(80,250,123,0.15)' : 'var(--color-bg-secondary, #2a2a3a)',
color: isActive ? '#50fa7b' : 'inherit',
fontWeight: isActive ? 600 : 400,
}}
>
<span style={{ fontFamily: 'monospace' }}>{tool.manifest.icon}</span>
<span>{tool.manifest.label}</span>
</button>
);
})}
</div>
)}
{/* ===== START TAB ===== */}
{activeTab === 'start' && (
<>
@@ -289,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>
<span>Historie</span>
</button>
<button className="ribbon-btn" title="Gäste-Verwaltung" onClick={() => onAction('guests')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>Gäste</span>
</button>
</div>
<div className="ribbon-group-label">Extras</div>
</div>
+23
View File
@@ -1,9 +1,14 @@
import React, { useCallback } from 'react';
import type { RightSidebarProps, RightPanel } from '../types/ui.types';
import type { LibraryProviderExtension } from '../plugins/types';
import PropertiesPanel from './PropertiesPanel';
import LayerPanel from './LayerPanel';
import BlockLibrary from './BlockLibrary';
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';
const MIN_RIGHTBAR_WIDTH = 200;
@@ -39,6 +44,23 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
rightbarWidth = 300,
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) => {
e.preventDefault();
e.stopPropagation();
@@ -138,6 +160,7 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
}}
/>
)}
<LibraryProviderPanel providers={libraryProviders} />
</>
)}
</div>
+14
View File
@@ -1,12 +1,18 @@
import React from 'react';
import type { TopbarProps } from '../types/ui.types';
import type { UnitType } from '../utils/format';
import { useUIMode, toggleUIMode, getUIMode } from '../services/uiModeService';
import { useT } from '../i18n/strings';
const Topbar: React.FC<TopbarProps> = ({
projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme,
onOpenSettings, onOpenLeftDrawer, onOpenRightDrawer,
unit = 'mm', onUnitChange, onNavigateBack,
}) => {
const t = useT();
const uiMode = useUIMode();
return (
<header className="topbar" role="banner">
<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>
</div>
<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}>
<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>
+164
View File
@@ -0,0 +1,164 @@
/**
* V2HistoryPanel History-Anzeige für das V2-Dokument (Task A4.12).
*
* Liest die Undo/Redo-Stacks des aktiven Dokuments (documentService).
* Klick auf einen aktiven Eintrag: macht alles DANACH rückgängig.
* Redo-Einträge sind rein informativ (gezielter Sprung nicht nativ).
*
* Wird statt des Legacy HistoryPanel gerendert, wenn
* VITE_TOOL_DISPATCHER=v2 gesetzt ist (App.tsx entscheidet).
*/
import React, { useEffect, useState } from 'react';
import { getActiveDocument, undoActive, redoActive } from '../kernel/document/documentService';
import type { CADDocument } from '../kernel/document/CADDocument';
type StackKind = 'undo' | 'redo';
interface StackItemInfo {
kind: StackKind;
index: number; // Position im jeweiligen Stack (0 = ältester)
}
const V2HistoryPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const [doc, setDoc] = useState<CADDocument | null>(getActiveDocument());
const [, bump] = useState(0);
// Auf Dokumentwechsel reagieren + bei Doc-Änderung neu rendern
useEffect(() => {
const unsubChange = doc?.onChanged(() => bump((b) => b + 1));
const interval = setInterval(() => {
const active = getActiveDocument();
if (active !== doc) setDoc(active);
}, 500); // einfacher Registry-Poll bis A5 einen Hook liefert
return () => {
unsubChange?.();
clearInterval(interval);
};
}, [doc]);
const um = doc?.getUndoManager();
const undoCount = um ? um.undoStack.length : 0;
const redoCount = um ? um.redoStack.length : 0;
const items: Array<StackItemInfo & { isCurrent: boolean }> = [];
// Neueste zuerst auflisten:
for (let i = undoCount - 1; i >= 0; i--) {
items.push({ kind: 'undo', index: i, isCurrent: i === undoCount - 1 });
}
for (let i = redoCount - 1; i >= 0; i--) {
items.push({ kind: 'redo', index: i, isCurrent: false });
}
/** Macht alle Schritte nach slotIndex rückgängig (slot 0 = ältester). */
const undoUntilSlot = (slotIndex: number): void => {
if (!doc) return;
const targetLen = slotIndex + 1;
let guard = 0;
while (
doc.canUndo() &&
doc.getUndoManager().undoStack.length > targetLen &&
guard < 500
) {
doc.undo();
guard++;
}
};
const shellStyle: React.CSSProperties = {
position: 'fixed', top: '50%', right: '320px', transform: 'translateY(-50%)',
background: 'var(--color-bg-primary, #1e1e2e)', borderRadius: '8px',
padding: '16px', width: '280px', maxHeight: '60vh', overflow: 'auto',
border: '1px solid var(--color-border, #333)', zIndex: 900,
color: 'var(--color-text, #e0e0e0)',
};
if (!doc || items.length === 0) {
return (
<div style={shellStyle}>
<Header onClose={onClose} count={items.length} />
<p style={{ fontSize: '13px', color: '#888' }}>
{doc ? 'Keine Historie vorhanden.' : 'Kein V2-Dokument aktiv (Flag aus?).'}
</p>
<UndoRedoButtons doc={doc} />
</div>
);
}
return (
<div style={shellStyle}>
<Header onClose={onClose} count={items.length} />
<UndoRedoButtons doc={doc} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{items.map((item) => (
<button
key={`${item.kind}-${item.index}`}
onClick={() => item.kind === 'undo' && undoUntilSlot(item.index)}
title={item.kind === 'undo'
? 'Klicken: alles danach rückgängig machen'
: 'Redo-Eintrag (nur anzeigbar)'}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '6px 8px', borderRadius: '4px',
cursor: item.kind === 'undo' ? 'pointer' : 'default',
border: item.isCurrent && item.kind === 'undo'
? '1px solid rgba(80,250,123,0.4)' : 'none',
textAlign: 'left', width: '100%',
background: item.isCurrent && item.kind === 'undo'
? 'rgba(80, 250, 123, 0.12)' : item.kind === 'redo'
? 'rgba(255,255,255,0.03)' : 'transparent',
color: item.kind === 'redo' ? '#888' : 'var(--color-text, #e0e0e0)',
fontWeight: item.isCurrent && item.kind === 'undo' ? 600 : 400,
fontSize: '13px', opacity: item.kind === 'redo' ? 0.6 : 1,
}}
>
<span style={{ fontSize: '10px', minWidth: '44px', color: '#666' }}>
{item.kind === 'undo' ? `Schritt ${item.index + 1}` : 'Redo'}
</span>
{item.isCurrent && item.kind === 'undo' && (
<span style={{ fontSize: '10px', color: '#50fa7b' }}> aktuell</span>
)}
</button>
))
}
</div>
</div>
);
};
function Header({ onClose, count }: { onClose: () => void; count: number }): JSX.Element {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '15px' }}>Historie V2{count > 0 ? ` (${count})` : ''}</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: '18px' }}>×</button>
</div>
);
}
function UndoRedoButtons({ doc }: { doc: CADDocument | null }): JSX.Element {
return (
<div style={{ display: 'flex', gap: '8px', marginBottom: doc && (doc.canUndo() || doc.canRedo()) ? '10px' : '4px' }}>
<button
onClick={() => undoActive()}
disabled={!doc?.canUndo()}
title="Strg+Z"
style={btnStyle(doc?.canUndo() ?? false)}
> Undo</button>
<button
onClick={() => redoActive()}
disabled={!doc?.canRedo()}
title="Strg+Y"
style={btnStyle(doc?.canRedo() ?? false)}
> Redo</button>
</div>
);
}
function btnStyle(enabled: boolean): React.CSSProperties {
return {
padding: '5px 12px', borderRadius: '4px', fontSize: '12px',
cursor: enabled ? 'pointer' : 'not-allowed',
border: '1px solid var(--color-border, #333)',
background: 'var(--color-bg-secondary, #2a2a3a)',
color: enabled ? 'inherit' : '#555',
};
}
export default V2HistoryPanel;
+8
View File
@@ -6,6 +6,7 @@ import * as Y from 'yjs';
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
import type { ElementGroup } from '../tools/modification/GroupTool';
import type { BackgroundConfig } from '../services/backgroundService';
import type { LayoutDefinition } from '../types/cad.types';
export interface YjsDocumentData {
elements: Y.Map<CADElement>;
@@ -14,6 +15,7 @@ export interface YjsDocumentData {
meta: Y.Map<unknown>;
groups: Y.Map<ElementGroup>;
bgConfig: Y.Map<BackgroundConfig>;
layouts: Y.Map<LayoutDefinition>; // Task G1
}
export class YjsDocument {
@@ -29,6 +31,7 @@ export class YjsDocument {
meta: this.doc.getMap<unknown>('meta'),
groups: this.doc.getMap<ElementGroup>('groups'),
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());
}
/** Get all layouts as a plain array (Task G1) */
getLayouts(): LayoutDefinition[] {
return Array.from(this.data.layouts.values());
}
/** Add or update a single element */
setElement(el: CADElement): void {
this.doc.transact(() => {
+105
View File
@@ -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;
}
+157
View File
@@ -0,0 +1,157 @@
/**
* dispatcher.ts InteractionDispatcher (Task A2).
*
* Ersetzt langfristig den 1157-Zeilen-Switch der InteractionEngine:
* - Routet DOM-Pointer-/Key-Events an das AKTIVE V2-Werkzeug (ToolExtensionV2)
* - Normalisiert Events (pointer.ts) zu Weltkoordinaten
* - Führt eine ToolSession als Down/Up-Guard
* - Kein Werkzeugwissen! Alle Logik liegt in den Plugins.
*
* Aktivierung per Feature-Flag VITE_TOOL_DISPATCHER=v2 (CanvasArea, A2 Schritt 4).
*/
import type { ToolExtensionV2, ToolContext } from '../plugins/types';
import { pluginRegistry } from '../plugins/PluginRegistry';
import { toToolPointerEvent } from './pointer';
import { ToolSession } from './session';
import type { ZoomPanController } from '../canvas/ZoomPanController';
export interface DispatcherDeps {
zoomPan: ZoomPanController;
/** Liefert Optionsstände je ToolId (typisch: toolStore.optionValues). */
getOptions?(toolId: string): Record<string, unknown>;
/** Statuszeile (typisch: StatusBar-Bridge). */
setStatus?(msg: string): void;
/** Live-Vorschau-Ziel (typisch RenderEngine.setPreviewElement). */
setPreview?(el: unknown | null): void;
/** Tool-Nachschlag überschreibbar (Default: aktive Registry-Tools). */
toolLookup?(toolId: string): ToolExtensionV2 | undefined;
/** Dokument-Fassade für Tools (ab A3; lazy pro Event abgefragt). */
getDoc?(): import('../kernel/document/CADDocument').CADDocument | undefined;
/** Selektions-Bridge für Tools (ab A3; lazy pro Event abgefragt). */
getSelectionBridge?(): import('../plugins/types').ToolSelectionBridge;
}
export class InteractionDispatcher {
private readonly canvas: HTMLCanvasElement;
private readonly deps: DispatcherDeps;
private readonly session = new ToolSession();
private active: ToolExtensionV2 | null = null;
private bound = false;
/** Zuletzt gesetzter Status-Text (für Tests/Debug). */
lastStatus = '';
constructor(canvas: HTMLCanvasElement, deps: DispatcherDeps) {
this.canvas = canvas;
this.deps = deps;
this.attach();
}
/** Setzt das aktive Werkzeug (null = deaktiviert Routing). */
setActive(toolId: string | null): void {
if (this.active && this.session.isActive) {
// Sitzung sauber abbrechen, bevor gewechselt wird
this.active.handlers.cancel?.(this.buildContext());
this.session.end();
}
if (!toolId) {
this.active = null;
return;
}
const lookup = this.deps.toolLookup ?? ((id: string) => pluginRegistry.getToolV2(id));
const tool = lookup(toolId);
if (!tool) {
console.warn(`[InteractionDispatcher] kein aktives V2-Tool '${toolId}'`);
this.active = null;
return;
}
this.active = tool;
this.setStatus(`${tool.manifest.label} aktiv`);
}
/** Aktives Werkzeug (oder null). */
getActive(): ToolExtensionV2 | null {
return this.active;
}
/** Entfernt alle Event-Listener (aufräumen beim Unmount). */
destroy(): void {
if (!this.bound) return;
this.canvas.removeEventListener('pointerdown', this.onDown);
window.removeEventListener('pointermove', this.onMove);
window.removeEventListener('pointerup', this.onUp);
window.removeEventListener('keydown', this.onKey);
this.bound = false;
}
// ── intern ───────────────────────────────────────────────
private attach(): void {
if (this.bound) return;
this.canvas.addEventListener('pointerdown', this.onDown);
window.addEventListener('pointermove', this.onMove);
window.addEventListener('pointerup', this.onUp);
window.addEventListener('keydown', this.onKey);
this.bound = true;
}
private buildContext(): import('../plugins/types').FullToolContext {
const toolId = this.active?.manifest.id ?? '';
return {
options: this.deps.getOptions?.(toolId) ?? {},
setStatus: (msg) => this.setStatus(msg),
setPreview: (el) => this.deps.setPreview?.(el),
doc: this.deps.getDoc?.(),
selection: this.deps.getSelectionBridge?.(),
};
}
private setStatus(msg: string): void {
this.lastStatus = msg;
this.deps.setStatus?.(msg);
}
private onDown = (e: PointerEvent): void => {
if (!this.active) return;
// Rechtsklick = Abbruch wie ESC
if (e.button === 2) {
this.cancelActive();
return;
}
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
this.session.begin();
this.active.handlers.down?.(tpe, this.buildContext());
};
private onMove = (e: PointerEvent): void => {
if (!this.active || !this.session.isActive) return;
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
this.active.handlers.move?.(tpe, this.buildContext());
};
private onUp = (e: PointerEvent): void => {
if (!this.active || !this.session.isActive) return;
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
try {
this.active.handlers.up?.(tpe, this.buildContext());
} finally {
this.session.end();
}
};
private onKey = (e: KeyboardEvent): void => {
if (!this.active) return;
if (e.key === 'Escape') {
this.cancelActive();
return;
}
const consumed = this.active.handlers.key?.(e, this.buildContext()) === true;
if (consumed) e.preventDefault();
};
private cancelActive(): void {
if (!this.active) return;
this.active.handlers.cancel?.(this.buildContext());
this.session.end();
}
}
+17 -1
View File
@@ -761,7 +761,23 @@ export class InteractionEngine {
if (this.modifySelected.length === 1) {
const first = this.state.points[0];
const dist = distance(first, pt);
const offsetted = offsetElement(this.modifySelected[0], dist);
const base = this.modifySelected[0];
// Seitigkeit bestimmen: Kreuzprodukt der Linienrichtung mit Klickvektor.
// cross > 0 → Klick links der Laufrichtung (p1→p2) → side=+1, sonst -1.
let side: 1 | -1 = 1;
if (base.type === 'line' && base.properties.x1 !== undefined && base.properties.y1 !== undefined) {
const p1x = base.properties.x1 as number;
const p1y = base.properties.y1 as number;
const p2x = base.properties.x2 as number;
const p2y = base.properties.y2 as number;
const cross = (p2x - p1x) * (pt.y - p1y) - (p2y - p1y) * (pt.x - p1x);
side = cross >= 0 ? 1 : -1;
} else if ((base.type === 'circle' || base.type === 'arc') && base.properties.radius !== undefined) {
// Kreis/Bogen: Klick außerhalb → vergrößern (+1), innerhalb → verkleinern (-1)
const clickDist = Math.hypot(pt.x - base.x, pt.y - base.y);
side = clickDist >= base.properties.radius ? 1 : -1;
}
const offsetted = offsetElement(base, dist, side);
// Create as new element (copy)
this.onElementCreated?.({ ...offsetted, id: this.generateId() });
this.resetModify();
+53
View File
@@ -0,0 +1,53 @@
/**
* pointer.ts DOM-PointerEvent ToolPointerEvent Normalisierung (Task A2).
* Konvertiert Bildschirmkoordinaten in Weltkoordinaten über ZoomPanController.
*/
import type { ToolPointerEvent } from '../plugins/types';
import type { ZoomPanController } from '../canvas/ZoomPanController';
/**
* Normalisiert ein Pointer/Mouse-Event zu einem ToolPointerEvent.
* @param e DOM-Event (MouseEvent ist Obermenge von PointerEvent-Feldern)
* @param canvas Das Canvas-Element (für offset-Koordinaten)
* @param zoomPan aktiver Zoom/Pan-Controller
*/
export function toToolPointerEvent(
e: MouseEvent,
canvas: HTMLCanvasElement,
zoomPan: ZoomPanController,
): ToolPointerEvent {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const world = zoomPan.screenToWorld(sx, sy);
return {
world: { x: world.x, y: world.y },
screen: { x: sx, y: sy },
button: e.button,
shift: e.shiftKey,
alt: e.altKey,
ctrl: e.ctrlKey || e.metaKey,
};
}
/** Fangt weitere Pointer-Events auf dem Canvas bis zum release (Pen/Tablet- Komfort). */
export function capturePointer(canvas: HTMLCanvasElement, e: PointerEvent): void {
if (typeof canvas.setPointerCapture === 'function') {
try {
canvas.setPointerCapture(e.pointerId);
} catch {
/* pointerId evtl. schon ungültig — unkritisch */
}
}
}
/** Gibt einen vorherigen Pointer-Capture frei. */
export function releasePointer(canvas: HTMLCanvasElement, e: PointerEvent): void {
if (typeof canvas.releasePointerCapture === 'function') {
try {
canvas.releasePointerCapture(e.pointerId);
} catch {
/* ignorieren */
}
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* session.ts Minimaler Werkzeug-Sitzungszustand (Task A2).
*
* phase 'idle' : kein Ziehen aktiv, Tool wartet auf down
* phase 'active' : down ist erfolgt; Tool zieht/zeichnet bis up/cancel
*
* Der Dispatcher nutzt das, um move-Events nur während aktiver Sitzung an
* Handler durchzureichen (und als Guard gegen verlorene up-Events).
*/
export type ToolPhase = 'idle' | 'active';
export class ToolSession {
private current: ToolPhase = 'idle';
/** Ist eine Zeichensitzung aktiv (zwischen down und up)? */
get isActive(): boolean {
return this.current === 'active';
}
/** Aktuelle Phase. */
get phase(): ToolPhase {
return this.current;
}
/** Startet eine Sitzung (nach einem gültigen down). */
begin(): void {
this.current = 'active';
}
/** Beendet die Sitzung regulär (up) oder abgebrochen (cancel/ESC). */
end(): void {
this.current = 'idle';
}
}
+217
View File
@@ -0,0 +1,217 @@
/**
* CADDocument Fassade über YjsDocument (Task A2b).
*
* - Elementar-CRUD auf CADElement/CADLayer
* - `transact(fn)`: Bündelt Änderungen zu EINEM Undo-Schritt
* (kollaborativ korrekt via Y.UndoManager)
* - `undo()/redo()` wrappen den Manager; HistoryPanel liest später daraus (A4)
* - Keine Tool-Logik! Nur Dokument-Operationen.
*
* Nutzung künftiger V2-Werkzeuge (ab A3):
* doc.transact(() => doc.addElement(line)); // 1 Klick = 1 Undo-Schritt
*/
import * as Y from 'yjs';
import { YjsDocument } from '../../crdt/YjsDocument';
import type { CADElement, CADLayer, LayoutDefinition, BlockDefinition } from '../../types/cad.types';
export class CADDocument {
private readonly ydoc: YjsDocument;
private readonly undoManager: Y.UndoManager;
/** @param ydoc Netzwerkfrei erstellbar (createInMemoryDoc) oder CRDT-verbunden. */
constructor(ydoc: YjsDocument) {
this.ydoc = ydoc;
// Trackt alle Operationen auf Elements + Layers → gemeinsame Undo-Historie.
// Blocks/Groups/Configs bewusst NICHT tracken; Layouts (G1) dazu seit Task G1.
// captureTimeout 0: KEIN automatisches Mergen zeitnaher Transaktionen —
// 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.ydoc.data.layouts], {
captureTimeout: 0,
});
}
// ── Lesen ────────────────────────────────────────────────
/** Ein Element per ID (oder undefined). */
getElement(id: string): CADElement | undefined {
return this.ydoc.data.elements.get(id);
}
/** Alle Elemente als Array. */
getAllElements(): CADElement[] {
return Array.from(this.ydoc.data.elements.values());
}
/** Ein Layer per ID (oder undefined). */
getLayer(id: string): CADLayer | undefined {
return this.ydoc.data.layers.get(id);
}
/** Alle Layer als Array. */
getAllLayers(): CADLayer[] {
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 ────────────────────────────────────────────
/** Fügt ein Element hinzu (einzelner Aufruf = eigener Undo-Schritt). */
addElement(el: CADElement): void {
this.transact(() => {
this.ydoc.data.elements.set(el.id, el);
});
}
/**
* Aktualisiert ein Element: Top-Level-Felder werden ersetzt,
* `patch.properties` wird SHALLOW in bestehende Properties gemergt
* (Details wie fill/rotation einzeln änderbar, ohne den Rest zu verlieren).
* @throws wenn Element nicht existiert
*/
updateElement(id: string, patch: Partial<CADElement>): void {
const existing = this.ydoc.data.elements.get(id);
if (!existing) throw new Error(`updateElement: Element '${id}' nicht gefunden`);
const { properties, ...rest } = patch;
const merged: CADElement = {
...existing,
...rest,
properties: properties ? { ...existing.properties, ...properties } : existing.properties,
};
this.transact(() => {
this.ydoc.data.elements.set(id, merged);
});
}
/** Entfernt mehrere Elemente in EINER Transaktion. Ignoriert unbekannte IDs still. */
deleteElements(ids: string[]): void {
this.transact(() => {
for (const id of ids) {
if (this.ydoc.data.elements.has(id)) {
this.ydoc.data.elements.delete(id);
}
}
});
}
// ── Transaktion & Undo ───────────────────────────────────
/** Führt fn aus; ALLE darin getätigten Y-Änderungen werden EIN Undo-Schritt. */
transact(fn: () => void): void {
this.ydoc.doc.transact(fn);
}
/** Macht den letzten Schritt rückgängig. false wenn nichts da. */
undo(): boolean {
if (!this.canUndo()) return false;
this.undoManager.undo();
return true;
}
/** Stellt einen Rückgängig-Schritt wieder her. false wenn nichts da. */
redo(): boolean {
if (!this.canRedo()) return false;
this.undoManager.redo();
return true;
}
/** Gibt es etwas zum Rückgängigmachen? */
canUndo(): boolean {
return this.undoManager.undoStack.length > 0;
}
/** Gibt es etwas zum Wiederherstellen? */
canRedo(): boolean {
return this.undoManager.redoStack.length > 0;
}
/** Exponiert den Manager (HistoryPanel liest Stacklängen für die UI, A4). */
getUndoManager(): Y.UndoManager {
return this.undoManager;
}
// ── Subscriptions ────────────────────────────────────────
/** Benachrichtigung bei JEDEM Doc-Update (inkl. Remote über CRDT). */
onChanged(cb: () => void): () => void {
return this.ydoc.onChange(cb);
}
}
@@ -0,0 +1,43 @@
/**
* documentService Prozessweiter Zugriffspunkt auf das aktive CADDocument.
*
* Warum: CanvasArea besitzt die V2-Dokumentinstanz (Task A4.1), aber App-Level
* UI (HistoryPanel, Tastenkürzel Strg+Z/Y) benötigt Zugriff OHNE prop-
* threading durch die God-Component. Dieser Service ist die bewusste,
* dokumentierte Ausnahme von der Reinheitsregel nur EINE Instanz aktiv.
*
* Setzen/Entfernen übernimmt CanvasArea im Mount-/Cleanup-Zyklus.
*/
import type { CADDocument } from './CADDocument';
type Listener = () => void;
let activeDoc: CADDocument | null = null;
const listeners = new Set<Listener>();
/** Registriert das aktive Dokument (CanvasArea-Mount). */
export function setActiveDocument(doc: CADDocument | null): void {
activeDoc = doc;
listeners.forEach((l) => l());
}
/** Aktives Dokument oder null (z.B. wenn Flag aus / Canvas unmounted). */
export function getActiveDocument(): CADDocument | null {
return activeDoc;
}
/** Änderungsabonnement (für React-Hooks). Liefert Unsubscribe. */
export function onActiveDocumentChanged(cb: Listener): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
/** Bequemlichkeit: globaler Undo am aktiven Dokument. false wenn kein Doc/Stack leer. */
export function undoActive(): boolean {
return activeDoc?.undo() ?? false;
}
/** Bequemlichkeit: globales Redo am aktiven Dokument. */
export function redoActive(): boolean {
return activeDoc?.redo() ?? false;
}
+23
View File
@@ -0,0 +1,23 @@
/**
* featureFlags Zentrale Feature-Schalter (Task A4.14).
*
* V2_TOOLS_ENABLED: Ab v2.0 ist der Plugin-first-Pfad (InteractionDispatcher,
* CADDocument, dynamisches Ribbon, V2HistoryPanel) STANDARDMÄSSIG AKTIV.
* Opt-out für den Legacy-Pfad: VITE_TOOL_DISPATCHER=legacy setzen.
*
* Begründung Historie: Vor A4.14 war V2 opt-in ('v2' explizit nötig), weil
* noch keine Browser-Verifikation existierte. Die Playwright-Verifikation
* (phase-a-verify.test.ts, grün) hat das Risiko entwertet.
*/
/** Ist der V2-Werkzeugpfad (Dispatcher/CADDocument) aktiv? Default: ja. */
export const V2_TOOLS_ENABLED: boolean =
import.meta.env.VITE_TOOL_DISPATCHER !== 'legacy';
/**
* Task C4: Pixi als Primär-Renderer für V2-Elemente (Overlay über Legacy).
* Default: ja (durch phase-a-verify E2E abgesichert).
* Opt-out: VITE_USE_PIXI=false.
*/
export const USE_PIXI_RENDERER: boolean =
import.meta.env.VITE_USE_PIXI !== 'false';
+105
View File
@@ -9,14 +9,29 @@ import type {
ToolExtension,
CommandExtension,
ImportExportExtension,
// API v2 (Task A1):
PluginV2,
ToolExtensionV2,
PanelExtension,
LibraryProviderExtension,
} from './types';
/** Interner Zustand eines registrierten V2-Plugins. */
interface PluginStateV2 {
plugin: PluginV2;
enabled: boolean;
loaded: boolean;
}
class PluginRegistryClass {
private plugins = new Map<string, Plugin>();
private states = new Map<string, PluginState>();
private context: PluginContext | null = null;
private listeners = new Set<() => void>();
// ── API v2 (Task A1) ─────────────────────────────────────
private v2Plugins = new Map<string, PluginStateV2>();
/** Set the plugin context (called once on app init) */
setContext(ctx: PluginContext) {
this.context = ctx;
@@ -164,6 +179,96 @@ class PluginRegistryClass {
this.enable(id);
}
}
for (const [id, state] of this.v2Plugins) {
if (state.plugin.manifest.enabledByDefault && !state.enabled) {
this.enableV2(id);
}
}
}
// ═══════════ API v2 (Task A1 — additiv) ═══════════
/** Registriert ein V2-Plugin. Doppelte IDs werden verworfen (Warnung). */
registerV2(plugin: PluginV2): void {
const { id } = plugin.manifest;
if (this.v2Plugins.has(id)) {
console.warn(`[PluginRegistry] V2-Plugin '${id}' bereits registriert verworfen`);
return;
}
this.v2Plugins.set(id, {
plugin,
enabled: false,
loaded: false,
});
this.notify();
}
/** Entfernt ein V2-Plugin (ruft onDestroy wenn geladen). */
unregisterV2(pluginId: string): void {
const state = this.v2Plugins.get(pluginId);
if (!state) return;
if (state.loaded) state.plugin.onDestroy?.();
this.v2Plugins.delete(pluginId);
this.notify();
}
/** Aktiviert ein V2-Plugin (onInit einmalig, dann onActivate). */
enableV2(pluginId: string): void {
const state = this.v2Plugins.get(pluginId);
if (!state || state.enabled) return;
state.enabled = true;
if (!state.loaded) {
state.plugin.onInit?.();
state.loaded = true;
}
state.plugin.onActivate?.();
this.notify();
}
/** Deaktiviert ein V2-Plugin. */
disableV2(pluginId: string): void {
const state = this.v2Plugins.get(pluginId);
if (!state || !state.enabled) return;
state.enabled = false;
state.plugin.onDeactivate?.();
this.notify();
}
/** Ist dieses V2-Plugin aktiv? */
isV2Enabled(pluginId: string): boolean {
return this.v2Plugins.get(pluginId)?.enabled ?? false;
}
/** Alle Tools aus AKTIVEN V2-Plugins (flach über alle Plugins). */
getToolsV2(): ToolExtensionV2[] {
const tools: ToolExtensionV2[] = [];
for (const state of this.v2Plugins.values()) {
if (state.enabled && state.plugin.tools) tools.push(...state.plugin.tools);
}
return tools;
}
/** Ein bestimmtes V2-Tool per ID (nur aus aktiven Plugins). */
getToolV2(toolId: string): ToolExtensionV2 | undefined {
return this.getToolsV2().find((t) => t.manifest.id === toolId);
}
/** Alle Panels aktiver V2-Plugins. */
getPanels(): PanelExtension[] {
const panels: PanelExtension[] = [];
for (const state of this.v2Plugins.values()) {
if (state.enabled && state.plugin.panels) panels.push(...state.plugin.panels);
}
return panels;
}
/** Alle Library-Provider aktiver V2-Plugins. */
getLibraryProviders(): LibraryProviderExtension[] {
const providers: LibraryProviderExtension[] = [];
for (const state of this.v2Plugins.values()) {
if (state.enabled && state.plugin.libraryProviders) providers.push(...state.plugin.libraryProviders);
}
return providers;
}
}
@@ -0,0 +1,207 @@
/**
* core-annotate Built-in V2-Plugin (Task A4.4).
*
* Werkzeuge: text (Platzierung Inhalt via InlineTextEditor/PropertiesPanel,
* Bestandskanal onTextEdit bleibt), dimension (2-Punkt linear), leader
* (2-Punkt Pfeil+Label).
*/
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
import type { CADElement } from '../../../types/cad.types';
import type { Pt } from '../../../tools/modification/geometry';
const uid = (prefix: string): string =>
`${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
/** Gemeinsamer 2-Punkt-Sitzungsstart für dimension/leader. */
let twoPointStart: Pt | null = null;
function optsOf(ctx: FullToolContext): { stroke: string; layerId: string } {
return {
stroke: (ctx.options.strokeColor as string | undefined) ?? '#e0e0e0',
layerId: (ctx.options.layerId as string | undefined) ?? 'layer-0',
};
}
function buildDimension(start: Pt, end: Pt, ctx: FullToolContext): CADElement {
const o = optsOf(ctx);
return {
id: uid('dim'),
type: 'dimension',
layerId: o.layerId,
x: (start.x + end.x) / 2,
y: (start.y + end.y) / 2,
width: Math.hypot(end.x - start.x, end.y - start.y), // Legacy: dist als Breite
height: 20,
properties: { x1: start.x, y1: start.y, x2: end.x, y2: end.y },
} as unknown as CADElement;
}
function buildLeader(start: Pt, end: Pt, ctx: FullToolContext): CADElement {
const o = optsOf(ctx);
return {
id: uid('leader'),
type: 'leader',
layerId: o.layerId,
x: end.x,
y: end.y,
width: Math.abs(end.x - start.x),
height: Math.abs(end.y - start.y),
properties: { x1: start.x, y1: start.y, x2: end.x, y2: end.y, text: '', fontSize: 12, stroke: o.stroke, strokeWidth: 1 },
} as unknown as CADElement;
}
/** Generischer 2-Punkt-Annotate-Fluss (down→Preview→up commit). */
function make2PointAnnotate(
id: 'dimension' | 'leader',
label: string,
icon: string,
build: (a: Pt, b: Pt, ctx: FullToolContext) => CADElement,
): ToolExtensionV2 {
return {
manifest: { id, label, icon, ribbonTab: 'format', tags: ['basic'] },
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
handlers: {
down(e, ctx) {
twoPointStart = e.world;
ctx.setStatus(`${label}: Startpunkt gesetzt Ende wählen`);
},
move(e, ctx) {
if (!twoPointStart) return;
ctx.setPreview?.(build(twoPointStart, e.world, ctx as FullToolContext));
},
up(e, ctx) {
const c = ctx as FullToolContext;
if (!twoPointStart || !c.doc) { twoPointStart = null; return; }
const el = build(twoPointStart, e.world, c);
c.doc.transact(() => c.doc!.addElement(el));
twoPointStart = null;
ctx.setPreview?.(null);
ctx.setStatus(`${label} erstellt (${el.id})`);
},
cancel(ctx) {
twoPointStart = null;
ctx.setPreview?.(null);
ctx.setStatus(`${label} abgebrochen`);
},
},
};
}
/**
* text-Werkzeug (A4.4): Klick platziert Textelement mit leerem Inhalt;
* Bearbeitung läuft über den bestehenden InlineTextEditor/onTextEdit-Kanal.
*/
export const textTool: ToolExtensionV2 = {
manifest: {
id: 'text',
label: 'Text',
icon: 'T',
ribbonTab: 'format',
tags: ['basic'],
description: 'Text platzieren und über Editor beschriften',
},
optionsSchema: [{ key: 'fontSize', label: 'Größe', type: 'number', min: 6, max: 72 }],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc) return;
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
const fontSize = (c.options.fontSize as number | undefined) ?? 12;
const el = {
id: uid('text'),
type: 'text',
layerId,
x: e.world.x,
y: e.world.y,
width: 100,
height: 20,
properties: { text: '', fontSize },
} as unknown as CADElement;
c.doc.transact(() => c.doc!.addElement(el));
ctx.setStatus(`Text platziert (${el.id}) now via Editor`);
},
},
};
export const dimensionTool: ToolExtensionV2 = make2PointAnnotate('dimension', 'Bemaßung', '\u2194', buildDimension);
export const leaderTool: ToolExtensionV2 = make2PointAnnotate('leader', 'Leader', '\u21b1', buildLeader);
/** 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 = {
manifest: {
id: 'core-annotate',
name: 'Beschriftung (Kern)',
version: '1.1.0',
author: 'web-cad team',
description: 'Text, Bemaßung, Leader (V2).',
category: 'tools',
enabledByDefault: true,
},
tools: [textTool, dimensionTool, leaderTool, mtextTool],
};
@@ -0,0 +1,905 @@
/**
* core-drawing Built-in V2-Plugin (Task A3 Pilot).
*
* Werkzeuge: line (Migrations-Pilot aus dem Legacy-Switch).
* Elemente werden über CADDocument committed kollaborativ korrektes Undo.
*
* Sitzungszustand (Startpunkt der aktuellen Linie) lebt als Modul-Member,
* NICHT in options: Options sind geteilter UI-Store-Zustand.
*/
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
import type { CADElement } from '../../../types/cad.types';
import type { Pt } from '../../../tools/modification/geometry';
const uid = (prefix: string): string =>
`${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
/** Startpunkt der laufenden Linie (nur innerhalb einer down→up-Sitzung gesetzt). */
let lineStart: Pt | null = null;
/** Startpunkt des laufenden Rechtecks (A4.2). */
let rectStart: Pt | null = null;
/** Zentrum des laufenden Kreises (A4.3). */
let circleCenter: Pt | null = null;
/** Laufender Bogen: Phase 0=nichts, 1=Zentrum gesetzt, 2=Radius+Startwinkel gesetzt. */
let arcPhase = 0;
let arcCenter: Pt | null = null;
let arcStartPt: Pt | null = null;
/** Laufende Polyline/Polygon-Punkte; Commit per ENTER. */
let polyPoints: Pt[] = [];
/** Laufende RevCloud-Punkte (A4.5); Commit per ENTER. */
let cloudPoints: Pt[] = [];
function optsOf(ctx: FullToolContext): { stroke: string; layerId: string } {
return {
stroke: (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0',
layerId: (ctx.options.layerId as string | undefined) ?? 'layer-0',
};
}
function buildLine(start: Pt, end: Pt, ctx: FullToolContext): CADElement {
const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0';
const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0';
return {
id: uid('line'),
type: 'line',
layerId,
x: (start.x + end.x) / 2,
y: (start.y + end.y) / 2,
width: Math.abs(end.x - start.x),
height: Math.abs(end.y - start.y),
properties: { x1: start.x, y1: start.y, x2: end.x, y2: end.y, stroke, strokeWidth: 1 },
} as unknown as CADElement;
}
function buildRect(cornerA: Pt, cornerB: Pt, ctx: FullToolContext): CADElement {
const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0';
const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0';
const minX = Math.min(cornerA.x, cornerB.x);
const minY = Math.min(cornerA.y, cornerB.y);
const w = Math.abs(cornerB.x - cornerA.x);
const h = Math.abs(cornerB.y - cornerA.y);
return {
id: uid('rect'),
type: 'rect',
layerId,
x: minX + w / 2, // BBox-Zentrum (Legacy-Konvention)
y: minY + h / 2,
width: w,
height: h,
properties: { stroke, strokeWidth: 1 },
} as unknown as CADElement;
}
/**
* line-Werkzeug: down setzt Startpunkt, move zeigt Live-Vorschau,
* up committet via doc.transact (1 Linie = 1 Undo-Schritt).
*/
export const lineTool: ToolExtensionV2 = {
manifest: {
id: 'line',
label: 'Linie',
icon: '\u2571',
ribbonTab: 'canvas',
tags: ['basic'],
description: 'Zeichnet eine Linie (Klick Start, Klick Ende)',
},
optionsSchema: [
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
],
handlers: {
down(e, ctx) {
lineStart = e.world;
ctx.setStatus(`Linie ab (${lineStart.x.toFixed(0)}, ${lineStart.y.toFixed(0)}) Ende wählen`);
},
move(e, ctx) {
if (!lineStart) return;
ctx.setPreview?.(buildLine(lineStart, e.world, ctx));
},
up(e, ctx) {
const c = ctx as FullToolContext;
if (!lineStart || !c.doc) {
lineStart = null;
return;
}
const el = buildLine(lineStart, e.world, c);
c.doc.transact(() => {
// addElement öffnet inneres transact — Yjs verschachtelt sie → EIN Undo-Schritt
c.doc!.addElement(el);
});
lineStart = null;
ctx.setPreview?.(null);
ctx.setStatus(`Linie erstellt (${el.id})`);
},
cancel(ctx) {
lineStart = null;
ctx.setPreview?.(null);
ctx.setStatus('Linie abgebrochen');
},
},
};
/**
* rect-Werkzeug (A4.2): down setzt Ecke, move zeigt BBox-Vorschau,
* up committet via doc.transact. Portiert aus Legacy case 'rect'
* (BBox-Konvention: x/y = Zentrum, width/height absolut).
*/
export const rectTool: ToolExtensionV2 = {
manifest: {
id: 'rect',
label: 'Rechteck',
icon: '\u25ad',
ribbonTab: 'canvas',
tags: ['basic'],
description: 'Zieht ein Rechteck auf (Klick Ecke, Klick Gegenecke)',
},
optionsSchema: [
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
],
handlers: {
down(e, ctx) {
rectStart = e.world;
ctx.setStatus(`Rechteck ab (${rectStart.x.toFixed(0)}, ${rectStart.y.toFixed(0)}) Gegenecke wählen`);
},
move(e, ctx) {
if (!rectStart) return;
ctx.setPreview?.(buildRect(rectStart, e.world, ctx));
},
up(e, ctx) {
const c = ctx as FullToolContext;
if (!rectStart || !c.doc) {
rectStart = null;
return;
}
const el = buildRect(rectStart, e.world, c);
c.doc.transact(() => {
c.doc!.addElement(el);
});
rectStart = null;
ctx.setPreview?.(null);
ctx.setStatus(`Rechteck erstellt (${el.id})`);
},
cancel(ctx) {
rectStart = null;
ctx.setPreview?.(null);
ctx.setStatus('Rechteck abgebrochen');
},
},
};
/**
* circle-Werkzeug (A4.3): ZentrumRadius wie Legacy (Radius = Distanz).
*/
export const circleTool: ToolExtensionV2 = {
manifest: {
id: 'circle', label: 'Kreis', icon: '\u25cb', ribbonTab: 'canvas', tags: ['basic'],
description: 'Kreis: Zentrum klicken, Radius ziehen',
},
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
handlers: {
down(e, ctx) { circleCenter = e.world; ctx.setStatus('Kreiszentrum gesetzt Radius ziehen'); },
move(e, ctx) {
if (!circleCenter) return;
const r = Math.hypot(e.world.x - circleCenter.x, e.world.y - circleCenter.y);
ctx.setPreview?.(buildCircle(circleCenter, r, ctx));
},
up(e, ctx) {
const c = ctx as FullToolContext;
if (!circleCenter || !c.doc) { circleCenter = null; return; }
const r = Math.hypot(e.world.x - circleCenter.x, e.world.y - circleCenter.y);
const el = buildCircle(circleCenter, r, c);
c.doc.transact(() => c.doc!.addElement(el));
circleCenter = null;
ctx.setPreview?.(null);
ctx.setStatus(`Kreis erstellt (${el.id})`);
},
cancel(ctx) { circleCenter = null; ctx.setPreview?.(null); ctx.setStatus('Kreis abgebrochen'); },
},
};
/**
* arc-Werkzeug (A4.3): 3-Klick wie Legacy Zentrum, Startpunkt (Radius+
* Startwinkel), Endpunkt (Endwinkel). Winkel in Grad CCW.
*/
export const arcTool: ToolExtensionV2 = {
manifest: {
id: 'arc', label: 'Bogen', icon: '\u2322', ribbonTab: 'canvas', tags: ['basic'],
description: 'Bogen: Zentrum, Startpunkt, Endpunkt',
},
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (arcPhase === 0) {
arcCenter = e.world;
arcPhase = 1;
ctx.setStatus('Bogenzentrum gesetzt \u2013 Startpunkt w\u00e4hlen');
} else if (arcPhase === 1) {
arcStartPt = e.world;
arcPhase = 2;
ctx.setStatus('Bogen: Endpunkt w\u00e4hlen');
} else if (arcPhase === 2 && arcCenter && arcStartPt) {
// Dritter Klick committet (klick-progressiv wie Legacy — up feuert im
// echten Browser immer direkt nach down, darf deshalb NICHT committen)
if (!c.doc) { arcPhase = 0; arcCenter = null; arcStartPt = null; return; }
const el = buildArc(arcCenter, arcStartPt, e.world, c);
c.doc.transact(() => c.doc!.addElement(el));
ctx.setPreview?.(null);
ctx.setStatus(`Bogen erstellt (${el.id})`);
arcPhase = 0;
arcCenter = null;
arcStartPt = null;
}
},
move(e, ctx) {
if (arcPhase === 1 && arcCenter) {
const r = Math.hypot(e.world.x - arcCenter.x, e.world.y - arcCenter.y);
// Vollkreis-Guide in Phasen-Preview (Legacy-Konvention)
ctx.setPreview?.(buildArcFullGuide(arcCenter, r, ctx));
} else if (arcPhase === 2 && arcCenter && arcStartPt) {
ctx.setPreview?.(buildArc(arcCenter, arcStartPt, e.world, ctx));
}
},
cancel(ctx) {
arcPhase = 0; arcCenter = null; arcStartPt = null;
ctx.setPreview?.(null); ctx.setStatus('Bogen abgebrochen');
},
},
};
/**
* polyline-Werkzeug (A4.3): Mehrklick fügt Punkte; ENTER committet ab 2 Punkten.
*/
export const polylineTool: ToolExtensionV2 = makePolyTool('polyline', 'Polylinie', 2, '\u2500');
/**
* polygon-Werkzeug (A4.3): wie Polylinie, aber geschlossen; ENTER ab 3 Punkten.
*/
export const polygonTool: ToolExtensionV2 = makePolyTool('polygon', 'Polygon', 3, '\u2b21');
function makePolyTool(type: 'polyline' | 'polygon', label: string, minPoints: number, icon: string): ToolExtensionV2 {
return {
manifest: { id: type, label, icon, ribbonTab: 'canvas', tags: ['basic'], description: `${label}: Punkte klicken, ENTER beendet` },
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
handlers: {
down(e, ctx) {
polyPoints.push(e.world);
if (!isPolyType(type)) return;
ctx.setStatus(`${label}: ${polyPoints.length} Punkte ENTER beendet`);
},
move(e, ctx) {
if (polyPoints.length === 0) return;
const pts = [...polyPoints, e.world];
ctx.setPreview?.(buildPoly(type, pts, ctx));
},
up() { /* commit nur via ENTER */ },
key(e, ctx) {
if (e.key !== 'Enter') return false;
if (polyPoints.length < minPoints) {
ctx.setStatus(`${label}: mindestens ${minPoints} Punkte nötig`);
return true; // konsumiert
}
const el = buildPoly(type, polyPoints, ctx as FullToolContext);
const doc = (ctx as FullToolContext).doc;
if (!doc) { resetPoly(); return true; }
doc.transact(() => doc.addElement(el));
resetPoly();
ctx.setPreview?.(null);
ctx.setStatus(`${label} erstellt (${el.id})`);
return true;
},
cancel(ctx) {
resetPoly();
ctx.setPreview?.(null);
ctx.setStatus(`${label} abgebrochen`);
},
},
};
}
function isPolyType(t: string): boolean {
return t === 'polyline' || t === 'polygon';
}
function resetPoly(): void {
polyPoints = [];
}
// ─── Build-Helfer A4.3 ───────────────────────────────────────
function buildCircle(center: Pt, radius: number, ctx: FullToolContext): CADElement {
const o = optsOf(ctx);
return {
id: uid('circle'), type: 'circle', layerId: o.layerId,
x: center.x, y: center.y, width: radius * 2, height: radius * 2,
properties: { radius, stroke: o.stroke, strokeWidth: 1 },
} as unknown as CADElement;
}
function anglesOf(center: Pt, start: Pt, end: Pt): { radius: number; startAngle: number; endAngle: number } {
const radius = Math.hypot(start.x - center.x, start.y - center.y);
const startAngle = (Math.atan2(start.y - center.y, start.x - center.x) * 180) / Math.PI;
const endAngle = (Math.atan2(end.y - center.y, end.x - center.x) * 180) / Math.PI;
return { radius, startAngle, endAngle };
}
function buildArc(center: Pt, startPt: Pt, endPt: Pt, ctx: FullToolContext): CADElement {
const o = optsOf(ctx);
const a = anglesOf(center, startPt, endPt);
return {
id: uid('arc'), type: 'arc', layerId: o.layerId,
x: center.x, y: center.y, width: a.radius * 2, height: a.radius * 2,
properties: { radius: a.radius, startAngle: a.startAngle, endAngle: a.endAngle, stroke: o.stroke, strokeWidth: 1 },
} as unknown as CADElement;
}
function buildArcFullGuide(center: Pt, radius: number, ctx: FullToolContext): CADElement {
// Vollkreis-Vorschau als Radien-Leitfaden (Legacy-Verhalten Phase 1)
const o = optsOf(ctx);
return {
id: uid('arc'), type: 'circle', layerId: o.layerId,
x: center.x, y: center.y, width: radius * 2, height: radius * 2,
properties: { radius, stroke: o.stroke, strokeWidth: 1, guideOnly: true },
} as unknown as CADElement;
}
function buildPoly(type: 'polyline' | 'polygon', pts: Pt[], ctx: FullToolContext): CADElement {
const o = optsOf(ctx);
const xs = pts.map((p) => p.x);
const ys = pts.map((p) => p.y);
return {
id: uid(type), type, layerId: o.layerId,
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: { points: pts.map((p) => ({ x: p.x, y: p.y })), stroke: o.stroke, strokeWidth: 1 },
} as unknown as CADElement;
}
/**
* revcloud-Werkzeug (A4.5): Mehrklick-Revisionswolke wie Poly-Muster;
* ENTER committet ab 3 Punkten. Legacy-Konvention: arcHeight 8.
*/
export const revcloudTool: ToolExtensionV2 = {
manifest: {
id: 'revcloud', label: 'RevCloud', icon: '\u2611', ribbonTab: 'format', tags: ['pro'],
description: 'Revisionswolke: Punkte klicken, ENTER beendet',
},
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
handlers: {
down(e, ctx) {
cloudPoints.push(e.world);
ctx.setStatus(`RevCloud: ${cloudPoints.length} Punkte \u2013 ENTER beendet`);
},
move(e, ctx) {
if (cloudPoints.length === 0) return;
ctx.setPreview?.(buildRevCloud([...cloudPoints, e.world], ctx));
},
up() { /* commit via ENTER */ },
key(e, ctx) {
if (e.key !== 'Enter') return false;
if (cloudPoints.length < 3) {
ctx.setStatus('RevCloud: mindestens 3 Punkte n\u00f6tig');
return true;
}
const c = ctx as FullToolContext;
if (!c.doc) { cloudPoints = []; return true; }
const el = buildRevCloud(cloudPoints, c);
c.doc.transact(() => c.doc!.addElement(el));
cloudPoints = [];
ctx.setPreview?.(null);
ctx.setStatus(`RevCloud erstellt (${el.id})`);
return true;
},
cancel(ctx) {
cloudPoints = [];
ctx.setPreview?.(null);
ctx.setStatus('RevCloud abgebrochen');
},
},
};
function buildRevCloud(pts: Pt[], ctx: FullToolContext): CADElement {
const o = optsOf(ctx);
const xs = pts.map((p) => p.x);
const ys = pts.map((p) => p.y);
return {
id: uid('revcloud'), type: 'revcloud', layerId: o.layerId,
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: { points: pts.map((p) => ({ x: p.x, y: p.y })), arcHeight: 8, fill: 'none', stroke: '#e0e0e0', strokeWidth: 1.5 },
} as unknown as CADElement;
}
/** 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 = {
manifest: {
id: 'core-drawing',
name: 'Zeichnen (Kern)',
version: '1.5.0',
author: 'web-cad team',
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.',
category: 'tools',
enabledByDefault: true,
},
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool],
};
@@ -0,0 +1,125 @@
/**
* core-measure Built-in V2-Plugin (Task A4.5).
*
* measure: 2 Klicks Distanz+Winkel-Anzeige im Status. KEIN Element-Commit
* (Legacy-Verhalten getreu Messung ist Information, keine Zeichnung).
* Distanzformatierung via bestehendem formatDistance(unit, scaleFactor).
*/
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
import { formatDistance, formatArea } from '../../../utils/format';
import { polygonArea } from '../../../tools/modification/geometry';
type MeasurePhase = 0 | 1; // 0=wartet auf Start, 1=wartet auf Ende
let measureStart: import('../../../tools/modification/geometry').Pt | null = null;
export const measureTool: ToolExtensionV2 = {
manifest: {
id: 'measure',
label: 'Messen',
icon: '\u2194',
ribbonTab: 'view',
tags: ['basic'],
description: 'Distanz und Winkel zwischen zwei Punkten messen',
},
optionsSchema: [],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!measureStart) {
measureStart = e.world;
ctx.setStatus('Messung: Endpunkt klicken');
return;
}
// Zweiter Klick → anzeigen (Legacy-Ausgabeformat)
const rawDist = Math.hypot(e.world.x - measureStart.x, e.world.y - measureStart.y);
const angle = (Math.atan2(e.world.y - measureStart.y, e.world.x - measureStart.x) * 180) / Math.PI;
const unit = (c.options.unit as string | undefined) ?? 'mm';
const scale = (c.options.scaleFactor as number | undefined) ?? 1;
const distStr = formatDistance(rawDist, unit as never, scale);
ctx.setStatus(`distance: ${distStr} angle: ${angle.toFixed(1)}\u00b0`);
measureStart = null;
},
cancel(ctx) {
measureStart = null;
ctx.setStatus('Messung abgebrochen');
},
},
};
/** 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 = {
manifest: {
id: 'core-measure',
name: 'Messen (Kern)',
version: '1.0.0',
author: 'web-cad team',
description: 'Abstandswinkel-Messung ohne Element-Erzeugung.',
category: 'tools',
enabledByDefault: true,
},
tools: [measureTool, measureAreaTool],
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,435 @@
/**
* event-tools Built-in V2-Plugin (Tasks A4.9 + E1).
*
* Fachwerkzeuge für Veranstaltungsplanung: chair (Einzelnklick),
* seating-row + seating-block (Drag-Interaktion mit Live-Preview
* und Options-Schema), table + stage (Einzelnklick).
*
* Jede Platzierung = doc.transact 1 Undo-Schritt (Multi-Element-Sets
* wie seating-block erscheinen/verschwinden gemeinsam).
*
* SeatingService ist durch BUG-1-Fix deterministisch (rowIds unique).
*/
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
import type { CADElement } from '../../../types/cad.types';
import type { Pt } from '../../../tools/modification/geometry';
import { SeatingService } from '../../../services/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. */
function makePlaceTool(
id: string,
label: string,
icon: string,
placer: (x: number, y: number, layerId: string) => CADElement | CADElement[],
statusNoun: string,
): ToolExtensionV2 {
return {
manifest: {
id,
label,
icon,
ribbonTab: 'insert',
tags: ['basic'],
description: `${statusNoun} per Klick platzieren`,
},
optionsSchema: [],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc) return;
const { layerId } = optsOf(ctx);
const placed = placer(e.world.x, e.world.y, layerId);
const els = Array.isArray(placed) ? placed : [placed];
c.doc.transact(() => {
for (const el of els) c.doc!.addElement(el);
});
ctx.setStatus(`${statusNoun} platziert (${els.length} Element${els.length === 1 ? '' : 'e'})`);
},
},
};
}
/**
* 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',
(x, y, layerId) => seating.createChair(x, y, layerId), 'Stuhl');
export const tableTool = makePlaceTool('table', 'Tisch', '\u25cb',
(x, y, layerId) => seating.createTable(x, y, layerId), 'Tisch');
export const stageTool = makePlaceTool('stage', 'Bühne', '\u25ad',
(x, y, layerId) => seating.createStage(x, y, layerId), 'Bühne');
// ─────────────────────────────────────────────────────────────
// 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 = {
manifest: {
id: 'event-tools',
name: 'Event-Bestuhlung',
version: '2.2.0',
author: 'web-cad team',
description: 'Stühle, Reihen (Drag), Blöcke (Drag), Tische, Bühne, Vorhang, Scheinwerfer, Absperrung (V2).',
category: 'elements',
enabledByDefault: true,
},
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}`);
},
};
}
+31
View File
@@ -12,15 +12,46 @@ export type {
CommandExtension,
ImportExportExtension,
PropertyField,
// API v2 (Task A1):
PluginV2,
ToolExtensionV2,
ToolManifestPart,
ToolPointerEvent,
ToolContext,
PanelExtension,
LibraryProviderExtension,
LibFolder,
LibBlock,
PluginCategoryV2,
} from './types';
// Built-in plugins
export { eventToolsPlugin } from './builtin/eventTools';
export { coreDrawingPlugin } from './builtin/core-drawing';
export { coreModifyPlugin } from './builtin/core-modify';
export { coreAnnotatePlugin } from './builtin/core-annotate';
export { coreMeasurePlugin } from './builtin/core-measure';
export { eventToolsV2Plugin } from './builtin/event-tools';
export { builtinLibraryPlugin } from './builtin/library';
export { builtinCatalogProvider, makeUserLibraryProvider, makeGlobalLibraryProvider } from './builtin/library/providers';
import { pluginRegistry } from './PluginRegistry';
import { eventToolsPlugin } from './builtin/eventTools';
import { coreDrawingPlugin } from './builtin/core-drawing';
import { coreModifyPlugin } from './builtin/core-modify';
import { coreAnnotatePlugin } from './builtin/core-annotate';
import { coreMeasurePlugin } from './builtin/core-measure';
import { eventToolsV2Plugin } from './builtin/event-tools';
import { builtinLibraryPlugin } from './builtin/library';
/** Register all built-in plugins */
export function registerBuiltinPlugins() {
pluginRegistry.register(eventToolsPlugin);
// API v2 (Task A3+):
pluginRegistry.registerV2(coreDrawingPlugin);
pluginRegistry.registerV2(coreModifyPlugin);
pluginRegistry.registerV2(coreAnnotatePlugin);
pluginRegistry.registerV2(coreMeasurePlugin);
pluginRegistry.registerV2(eventToolsV2Plugin);
pluginRegistry.registerV2(builtinLibraryPlugin);
}
+130
View File
@@ -116,3 +116,133 @@ export interface PluginState {
enabled: boolean;
loaded: boolean;
}
// ═══════════════════════════════════════════════════════
// ─── API v2 (Task A1, additiv — siehe ROADMAP TEIL 2.4) ──
// ═══════════════════════════════════════════════════════
import type { Pt } from '../tools/modification/geometry';
/** Manifest-Teil eines V2-Werkzeugs. */
export interface ToolManifestPart {
id: string;
label: string;
icon: string;
ribbonTab: string;
shortcut?: string;
/** 'basic' = auch im Einfach-Modus sichtbar (Task H1). */
tags?: Array<'basic' | 'pro'>;
}
/** Normalisiertes Pointer-Event mit Welt- und Bildschirmkoordinaten. */
export interface ToolPointerEvent {
world: Pt;
screen: Pt;
button: number;
shift: boolean;
alt: boolean;
ctrl: boolean;
}
/** Kontext, den der Dispatcher einem aktiven V2-Tool übergibt. */
export interface ToolContext {
/** Aktueller Optionsstand für dieses Tool (aus toolStore). */
options: Record<string, unknown>;
/** Statuszeilentext setzen. */
setStatus(msg: string): void;
/** Live-Vorschau setzen (null = Vorschau entfernen). Ab A3 an Renderer angebunden. */
setPreview?(el: CADElement | null): void;
}
/** Selektions-/Treffer-Bridge für Werkzeuge (ab A3, von CanvasArea injiziert). */
export interface ToolSelectionBridge {
/** Aktuell selektierte Element-IDs. */
getIds(): string[];
/** Setzt die Selektion vollständig. */
setIds(ids: string[]): void;
/** Element-Treffer an Weltposition (oder null). */
hitTest(worldX: number, worldY: number, tolerancePx: number): CADElement | null;
}
/** Erweiterter Kontext für Tools mit Dokument-/Selektionszugriff (A3). */
export interface FullToolContext extends ToolContext {
/** Dokumentzugriff (transact/add/update/delete). */
doc?: import('../kernel/document/CADDocument').CADDocument;
/** Selektion + HitTest-Bridge. */
selection?: ToolSelectionBridge;
}
/** V2-Werkzeug mit eigenen Pointer-Handlern (ersetzt langfristig ToolExtension). */
export interface ToolExtensionV2 {
manifest: ToolManifestPart & { description?: string };
/** Optionsleisten-Schema (wiederverwendet PropertyField). */
optionsSchema: PropertyField[];
handlers: {
down?(e: ToolPointerEvent, ctx: ToolContext): void;
move?(e: ToolPointerEvent, ctx: ToolContext): void;
up?(e: ToolPointerEvent, ctx: ToolContext): void;
/** true = Tastendruck konsumiert. */
key?(e: KeyboardEvent, ctx: ToolContext): boolean;
/** ESC / Rechtsklick. */
cancel?(ctx: ToolContext): void;
};
}
/** Eigenes Sidebar-Panel eines Plugins. */
export interface PanelExtension {
id: string;
title: string;
slot: 'left' | 'right';
render(): HTMLElement;
}
/** Ordner einer Bibliothek (LibraryProvider). */
export interface LibFolder {
id: string;
label: string;
parentId: string | null;
}
/** Block einer Bibliothek (Metadaten; Payload provider-spezifisch). */
export interface LibBlock {
id: string;
folderId: string;
name: string;
tags?: string[];
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). */
export interface LibraryProviderExtension {
id: string;
label: string;
listFolders(): Promise<LibFolder[]>;
listBlocks(folderId: string): Promise<LibBlock[]>;
getBlock(id: string): Promise<LibBlock>;
}
/** Erweiterte Manifest-Kategorie inkl. library. */
export type PluginCategoryV2 = PluginManifest['category'] | 'library';
/** Plugin-API v2 (registriert parallel zum alten Plugin-Interface). */
export interface PluginV2 {
manifest: {
id: string;
name: string;
version: string;
author: string;
description: string;
category: PluginCategoryV2;
enabledByDefault?: boolean;
minAppVersion?: string;
permissions?: Array<'canvas' | 'network' | 'storage'>;
};
tools?: ToolExtensionV2[];
panels?: PanelExtension[];
libraryProviders?: LibraryProviderExtension[];
onInit?(): void;
onActivate?(): void;
onDeactivate?(): void;
onDestroy?(): void;
}
+123
View File
@@ -0,0 +1,123 @@
/**
* MockAdapter Headless-Implementierung des RenderAdapter (Task C1).
* Zeichnet nichts; protokolliert alle Aufrufe für assertions in Unit-Tests.
*/
import type {
RenderAdapter,
Viewport,
GridConfig,
BackgroundDrawConfig,
RulerConfig,
LayerStyle,
OverlayPainter,
SelectionState,
} from '../types';
import type { CADElement, CADLayer } from '../../types/cad.types';
import type { Pt } from '../../tools/modification/geometry';
export interface RecordedCall {
method: string;
args: unknown[];
}
export class MockAdapter implements RenderAdapter {
readonly calls: RecordedCall[] = [];
private canvas: HTMLCanvasElement | null = null;
nextHitTestResult: CADElement | null = null;
// ── Recording helper ─────────────────────────────────────
private rec(method: string, args: unknown[]): void {
this.calls.push({ method, args });
}
/** Test-Helfer: alle Aufrufe einer Methode. */
callsOf(method: string): RecordedCall[] {
return this.calls.filter((c) => c.method === method);
}
/** Test-Helfer: Anzahl Aufrufe einer Methode. */
countCalls(method: string): number {
return this.callsOf(method).length;
}
reset(): void {
this.calls.length = 0;
this.nextHitTestResult = null;
}
// ── RenderAdapter Implementierung (reines Recording) ─────
init(canvas: HTMLCanvasElement): void {
this.canvas = canvas;
this.rec('init', [canvas]);
}
resize(width: number, height: number, dpr: number): void {
this.rec('resize', [width, height, dpr]);
}
setViewport(vp: Viewport): void {
this.rec('setViewport', [vp]);
}
destroy(): void {
this.rec('destroy', []);
}
clear(color: string): void {
this.rec('clear', [color]);
}
drawGrid(cfg: GridConfig): void {
this.rec('drawGrid', [cfg]);
}
drawBackground(img: HTMLImageElement | null, cfg: BackgroundDrawConfig): void {
this.rec('drawBackground', [img, cfg]);
}
drawRuler(cfg: RulerConfig): void {
this.rec('drawRuler', [cfg]);
}
drawElement(el: CADElement, layer: CADLayer, style: LayerStyle): void {
this.rec('drawElement', [el, layer, style]);
}
removeElement(id: string): void {
this.rec('removeElement', [id]);
}
clearElements(): void {
this.rec('clearElements', []);
}
drawPreview(el: CADElement | null): void {
this.rec('drawPreview', [el]);
}
setSelection(sel: SelectionState): void {
this.rec('setSelection', [sel]);
}
setSnapPoints(pts: Array<{ x: number; y: number; type: string }>): void {
this.rec('setSnapPoints', [pts]);
}
overlay(draw: (g: OverlayPainter) => void): void {
const painter: OverlayPainter = {
line(a: Pt, b: Pt, color: string, width?: number) {},
rect(x: number, y: number, w: number, h: number, strokeColor: string) {},
circle(center: Pt, radius: number, strokeColor: string) {},
text(text: string, at: Pt, color: string, fontSize?: number) {},
};
draw(painter);
this.rec('overlay', [null]);
}
hitTest(world: Pt, tolerancePx: number): CADElement | null {
this.rec('hitTest', [world, tolerancePx]);
return this.nextHitTestResult;
}
}
+436
View File
@@ -0,0 +1,436 @@
/**
* PixiRenderer WebGL-Implementierung des RenderAdapter (Task C2).
*
* - PixiJS v8 wird LAZY per dynamischem import() geladen ein Ausfall des
* Pixi-Moduls (z.B. fehlendes WebGL im Headless-Browser) beeinflusst damit
* NICHT den Modul-Import der App (kein weißer Screen).
* - async Application.init(); danach synchron nutzbar über waitForReady()
* - Inkrementell: gerenderte Container pro Element-ID gecacht;
* removeElement/clearElements halten Cache und Szene konsistent
* - drawElement konsumiert die testbaren Shape-Primitives (elementDrawers)
*/
import type { Application, Container, Graphics, Text } from 'pixi.js';
import type {
RenderAdapter,
Viewport,
GridConfig,
BackgroundDrawConfig,
RulerConfig,
LayerStyle,
OverlayPainter,
SelectionState,
} from '../types';
import type { CADElement, CADLayer } from '../../types/cad.types';
import type { Pt } from '../../tools/modification/geometry';
import { elementToPrimitives } from './elementDrawers';
export class PixiRenderer implements RenderAdapter {
private app: Application | null = null;
private canvas: HTMLCanvasElement | null = null;
private root: Container | null = null;
private overlayLayer: Container | null = null;
private graphicsCache = new Map<string, Container>();
private viewport: Viewport = { x: 0, y: 0, scale: 1, width: 800, height: 600 };
private readyResolve: (() => void) | null = null;
private readyReject: ((err: unknown) => void) | null = null;
readonly ready: Promise<void>;
private pixiModule: typeof import('pixi.js') | null = null;
constructor() {
this.ready = new Promise<void>((resolve, reject) => {
this.readyResolve = resolve;
this.readyReject = reject;
});
}
/** Wartet auf Abschluss der Initialisierung; rejected bei Pixi-Fehler. */
async waitForReady(): Promise<void> {
await this.ready;
}
/** Task C4: true sobald setupApp fertig ist (via readyResolve gesetzt). */
private initialized = false;
// ── Lifecycle ────────────────────────────────────────────
init(canvas: HTMLCanvasElement): void {
this.canvas = canvas;
// LAZY-IMPORT: pixi.js wird erst jetzt geholt. Bei Fehler → ready rejected,
// CanvasArea deaktiviert das Overlay und die App läuft normal weiter.
void import('pixi.js')
.then((mod) => {
this.pixiModule = mod;
return this.setupApp(mod.Application, mod.Container, canvas);
})
.then(() => this.readyResolve?.())
.catch((err) => {
this.readyReject?.(err);
});
}
private setupApp(
ApplicationCtor: typeof Application,
ContainerCtor: typeof Container,
canvas: HTMLCanvasElement,
): Promise<void> {
const app = new ApplicationCtor();
this.app = app;
return app.init({
canvas,
width: this.viewport.width,
height: this.viewport.height,
backgroundAlpha: 0,
antialias: true,
autoDensity: false,
preference: 'webgl',
}).then(() => {
if (!this.app) return;
const root = new ContainerCtor();
this.root = root;
this.app.stage.addChild(root);
const overlay = new ContainerCtor();
this.overlayLayer = overlay;
this.app.stage.addChild(overlay);
this.applyViewportTransform();
this.initialized = true; // Task C4: fuer getDebugInfo
});
}
resize(width: number, height: number, _dpr: number): void {
if (!this.app?.renderer) return;
this.viewport.width = width;
this.viewport.height = height;
this.app.renderer.resize(width, height);
}
setViewport(vp: Viewport): void {
this.viewport = vp;
this.applyViewportTransform();
}
destroy(): void {
try {
this.app?.destroy(true, { children: true });
} catch {
// Pixi darf im Teardown nichts mehr werfen
}
this.app = null;
this.root = null;
this.overlayLayer = null;
this.graphicsCache.clear();
this.canvas = null;
}
// ── Basis-Zeichenoperationen ────────────────────────────
clear(_color: string): void {
// Hintergrund kommt vom Canvas-CSS; Pixi leert Buffer beim render.
}
drawGrid(cfg: GridConfig): void {
if (!cfg.enabled || !this.overlayLayer || !this.root) return;
const GraphicsCtor = this.pixiModule?.Graphics;
if (!GraphicsCtor) return;
const g = new GraphicsCtor();
const vp = this.viewport;
const worldLeft = vp.x;
const worldTop = vp.y;
const worldRight = vp.x + vp.width / vp.scale;
const worldBottom = vp.y + vp.height / vp.scale;
for (
let gx = Math.floor(worldLeft / cfg.size) * cfg.size;
gx < worldRight;
gx += cfg.size
) {
g.moveTo(gx, worldTop).lineTo(gx, worldBottom)
.stroke({ color: cfg.color, width: 0.5 / vp.scale });
}
for (
let gy = Math.floor(worldTop / cfg.size) * cfg.size;
gy < worldBottom;
gy += cfg.size
) {
g.moveTo(worldLeft, gy).lineTo(worldRight, gy)
.stroke({ color: cfg.color, width: 0.5 / vp.scale });
}
this.root.addChild(g);
}
drawBackground(_img: HTMLImageElement | null, _cfg: BackgroundDrawConfig): void {
// Sprite-/Texturintegration folgt in Task C3.
}
drawRuler(_cfg: RulerConfig): void {
// Lineal-Rendering folgt in Task C3.
}
// ── Elemente ─────────────────────────────────────────────
drawElement(el: CADElement, layer: CADLayer, _style: LayerStyle): void {
if (!this.root || !layer.visible || layer.locked) return;
this.removeElement(el.id);
const container = this.buildGraphicsContainer(el);
if (!container) return;
this.graphicsCache.set(el.id, container);
this.root.addChild(container);
}
removeElement(id: string): void {
const existing = this.graphicsCache.get(id);
if (!existing) return;
try {
existing.destroy({ children: true });
} catch {
// teardown-safe
}
this.graphicsCache.delete(id);
}
clearElements(): void {
for (const c of this.graphicsCache.values()) {
try {
c.destroy({ children: true });
} catch {
// teardown-safe
}
}
this.graphicsCache.clear();
}
drawPreview(el: CADElement | null): void {
if (!this.overlayLayer) return;
for (const child of [...this.overlayLayer.children]) {
if ((child as Container).label === '__preview__') {
try {
child.destroy({ children: true });
} catch {
// teardown-safe
}
}
}
if (!el) return;
const g = this.buildGraphicsContainer(el);
if (g) {
g.label = '__preview__';
g.alpha = 0.6;
this.overlayLayer.addChild(g);
}
}
setSelection(sel: SelectionState): void {
if (!this.overlayLayer) return;
// Bestehende Selektions-Grafik entfernen:
for (const child of [...this.overlayLayer.children]) {
if ((child as Container).label === '__selection__') {
try {
child.destroy({ children: true });
} catch {
// teardown-safe
}
}
}
if (!sel.selectedIds.length || !sel.elements || !this.pixiModule) return;
const GraphicsCtor = this.pixiModule.Graphics;
const g = new GraphicsCtor();
const HANDLE = 4; // halbe Kantenlänge der Eck-Handles (in Weltkoordinaten)
for (const el of sel.elements) {
if (!sel.selectedIds.includes(el.id)) continue;
// Legacy-Konvention getElementBBox: x/y = Zentrum für rects, sonst BBox
let minX: number, minY: number, maxX: number, maxY: number;
if (el.type === 'rect' || el.type === 'stage') {
minX = el.x - el.width / 2;
minY = el.y - el.height / 2;
maxX = el.x + el.width / 2;
maxY = el.y + el.height / 2;
} else if (
el.properties &&
el.properties.x1 !== undefined && el.properties.y1 !== undefined &&
el.properties.x2 !== undefined && el.properties.y2 !== undefined
) {
minX = Math.min(el.properties.x1 as number, el.properties.x2 as number);
minY = Math.min(el.properties.y1 as number, el.properties.y2 as number);
maxX = Math.max(el.properties.x1 as number, el.properties.x2 as number);
maxY = Math.max(el.properties.y1 as number, el.properties.y2 as number);
} else {
minX = el.x - el.width / 2;
minY = el.y - el.height / 2;
maxX = el.x + el.width / 2;
maxY = el.y + el.height / 2;
}
// Umrandung (gestrichelt wird via Linien-Segmenten simuliert):
const dashLen = 6;
const drawDashed = (ax: number, ay: number, bx: number, by: number) => {
const len = Math.hypot(bx - ax, by - ay);
const n = Math.max(1, Math.floor(len / dashLen));
for (let i = 0; i <= n; i += 2) {
const t0 = i / n;
const t1 = Math.min(1, (i + 1) / n);
g.moveTo(ax + (bx - ax) * t0, ay + (by - ay) * t0)
.lineTo(ax + (bx - ax) * t1, ay + (by - ay) * t1)
.stroke({ color: '#50fa7b', width: 1 / this.viewport.scale });
}
};
drawDashed(minX, minY, maxX, minY);
drawDashed(maxX, minY, maxX, maxY);
drawDashed(maxX, maxY, minX, maxY);
drawDashed(minX, maxY, minX, minY);
// 4 Eck-Handles (gefüllte Quadrate):
const corners = [
{ x: minX, y: minY }, { x: maxX, y: minY },
{ x: maxX, y: maxY }, { x: minX, y: maxY },
];
for (const c of corners) {
g.rect(c.x - HANDLE, c.y - HANDLE, HANDLE * 2, HANDLE * 2)
.fill({ color: '#50fa7b' })
.stroke({ color: '#1e1e2e', width: 1 / this.viewport.scale });
}
}
g.label = '__selection__';
this.overlayLayer.addChild(g);
}
setSnapPoints(pts: Array<{ x: number; y: number; type: string }>): void {
if (!this.overlayLayer || !this.pixiModule || pts.length === 0) return;
const GraphicsCtor = this.pixiModule.Graphics;
const g = new GraphicsCtor();
g.label = '__snaps__';
// Bestehende Snap-Marks entfernen:
for (const child of [...this.overlayLayer.children]) {
if ((child as Container).label === '__snaps__') {
try {
child.destroy({ children: true });
} catch {
// teardown-safe
}
}
}
// Snap-Punkt farbig nach Typ (Legacy-Ähnlich):
const colorByType: Record<string, string> = {
endpoint: '#ff79c6',
midpoint: '#8be9fd',
center: '#f1fa8c',
intersection: '#ffb86c',
grid: '#6272a4',
};
const scaleComp = 1 / this.viewport.scale; // konstante Bildschirmgröße
for (const pt of pts) {
const color = colorByType[pt.type] ?? '#bd93f9';
g.circle(pt.x, pt.y, 4 * scaleComp)
.fill({ color })
.stroke({ color: '#1e1e2e', width: 1 * scaleComp });
}
this.overlayLayer.addChild(g);
}
overlay(draw: (g: OverlayPainter) => void): void {
if (!this.overlayLayer) return;
const GraphicsCtor = this.pixiModule?.Graphics;
const TextCtor = this.pixiModule?.Text;
if (!GraphicsCtor || !TextCtor) return;
const g = new GraphicsCtor();
const painter: OverlayPainter = {
line: (a: Pt, b: Pt, color: string, width?: number) => {
g.moveTo(a.x, a.y).lineTo(b.x, b.y).stroke({ color, width: width ?? 1 });
},
rect: (x: number, y: number, w: number, h: number, strokeColor: string) => {
g.rect(x, y, w, h).stroke({ color: strokeColor });
},
circle: (center: Pt, radius: number, strokeColor: string) => {
g.circle(center.x, center.y, radius).stroke({ color: strokeColor });
},
text: (text: string, at: Pt, color: string, fontSize?: number) => {
const t = new TextCtor({ text, style: { fontSize: fontSize ?? 12, fill: color } });
t.position.set(at.x, at.y);
g.addChild(t);
},
};
draw(painter);
this.overlayLayer.addChild(g);
}
hitTest(_world: Pt, _tolerancePx: number): CADElement | null {
// Delegation an SpatialIndex folgt in C3.
return null;
}
/**
* Task C4: Debug-Info für Playwright-E2E-Assertions.
* Ermöglicht Testabfragen wie 'wie viele Elemente sind gerendert?'
*/
getDebugInfo(): { cachedElements: number; ready: boolean } {
return {
cachedElements: this.graphicsCache.size,
ready: this.initialized,
};
}
// ── Internes ─────────────────────────────────────────────
private applyViewportTransform(): void {
if (!this.root) return;
this.root.position.set(
-this.viewport.x * this.viewport.scale,
-this.viewport.y * this.viewport.scale,
);
this.root.scale.set(this.viewport.scale);
}
private buildGraphicsContainer(el: CADElement): Container | null {
const prims = elementToPrimitives(el);
if (prims.length === 0) return null;
const GraphicsCtor = this.pixiModule?.Graphics;
const TextCtor = this.pixiModule?.Text;
if (!GraphicsCtor || !TextCtor) return null;
const ContainerCtor = this.pixiModule!.Container;
const container = new ContainerCtor();
for (const prim of prims) {
const g = new GraphicsCtor();
switch (prim.kind) {
case 'line':
g.moveTo(prim.from.x, prim.from.y)
.lineTo(prim.to.x, prim.to.y)
.stroke({ color: prim.stroke, width: prim.strokeWidth });
break;
case 'rect':
g.rect(prim.x, prim.y, prim.w, prim.h);
if (prim.fill) g.fill({ color: prim.fill });
g.stroke({ color: prim.stroke, width: prim.strokeWidth });
break;
case 'circle':
g.circle(prim.center.x, prim.center.y, prim.radius);
if (prim.fill) g.fill({ color: prim.fill });
g.stroke({ color: prim.stroke, width: prim.strokeWidth });
break;
case 'polyline': {
if (prim.points.length === 0) break;
g.moveTo(prim.points[0].x, prim.points[0].y);
for (let i = 1; i < prim.points.length; i++) {
g.lineTo(prim.points[i].x, prim.points[i].y);
}
if (prim.close) g.closePath();
g.stroke({ color: prim.stroke, width: prim.strokeWidth });
break;
}
case 'arc': {
const startRad = (prim.startDeg * Math.PI) / 180;
const endRad = (prim.endDeg * Math.PI) / 180;
g.arc(prim.center.x, prim.center.y, prim.radius, startRad, endRad)
.stroke({ color: prim.stroke, width: prim.strokeWidth });
break;
}
case 'text': {
const t = new TextCtor({ text: prim.text, style: { fontSize: prim.fontSize, fill: prim.color } });
t.position.set(prim.at.x, prim.at.y);
g.addChild(t);
break;
}
}
container.addChild(g);
}
return container;
}
}
+324
View File
@@ -0,0 +1,324 @@
/**
* elementDrawers Reine Beschreibungs-Fabrik (Task C2).
*
* Wandelt CADElemente in eine Liste von Primitiven um (line/rect/circle/
* polyline/text/arc). Der PixiRenderer (C3) konsumiert diese Deskriptoren
* und überführt sie in konkrete Grafikobjekte.
*
* Vorteil dieser Zwischenschicht:
* - Vollständig unit-testbar OHNE WebGL/GPU
* - Backend-unabhängig (Pixi jetzt, Canvas2D oder andere später)
*
* Konventionen entsprechen dem Legacy RenderEngine.draw*:
* - rect: x/y = Zentrum der BBox
* - circle/arc: radius über properties.radius (Fallback width/2)
* - arc-Winkel in Grad, 0° = 3 Uhr, gegen den Uhrzeigersinn
*/
import type { CADElement } from '../../types/cad.types';
import type { Pt } from '../../tools/modification/geometry';
/** Zeichenprimitiv — kleinste darstellbare Einheit. */
export type ShapePrimitive =
| { kind: 'line'; from: Pt; to: Pt; stroke: string; strokeWidth: number }
| { kind: 'rect'; x: number; y: number; w: number; h: number; fill: string | null; stroke: string; strokeWidth: number }
| { kind: 'circle'; center: Pt; radius: number; fill: string | null; stroke: string; strokeWidth: number }
| { kind: 'polyline'; points: Pt[]; close: boolean; stroke: string; strokeWidth: number }
| { kind: 'text'; at: Pt; text: string; fontSize: number; color: string }
| { kind: 'arc'; center: Pt; radius: number; startDeg: number; endDeg: number; stroke: string; strokeWidth: number };
const FALLBACK_STROKE = '#e0e0e0';
function asPoint(p: unknown): Pt {
const q = p as { x?: number; y?: number };
return { x: Number(q?.x ?? 0), y: Number(q?.y ?? 0) };
}
function fillOrNull(raw: unknown): string | null {
return typeof raw === 'string' && raw !== 'none' ? raw : null;
}
/**
* Wandelt ein CADElement in seine Zeichenprimitive um.
* Gibt ein leeres Array zurück für unbekannte Typen bzw. Elemente,
* die nichts Sichtbares beitragen (z.B. leerer Text).
*/
export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: string; prompt: string; defaultValue?: string }>): ShapePrimitive[] {
const props = (el.properties ?? {}) as Record<string, unknown>;
const stroke = (props.stroke as string | undefined) ?? FALLBACK_STROKE;
const sw = (props.strokeWidth as number | undefined) ?? 1;
switch (el.type) {
case 'line': {
if (
props.x1 === undefined || props.y1 === undefined ||
props.x2 === undefined || props.y2 === undefined
) {
return [];
}
return [{
kind: 'line',
from: { x: props.x1 as number, y: props.y1 as number },
to: { x: props.x2 as number, y: props.y2 as number },
stroke,
strokeWidth: sw,
}];
}
case 'rect': {
// Legacy-BBox-Konvention: x/y ist das Zentrum des Rechtecks
return [{
kind: 'rect',
x: el.x - el.width / 2,
y: el.y - el.height / 2,
w: el.width,
h: el.height,
fill: fillOrNull(props.fill),
stroke,
strokeWidth: sw,
}];
}
case 'circle': {
const radius = (props.radius as number | undefined) ?? el.width / 2;
return [{
kind: 'circle',
center: { x: el.x, y: el.y },
radius,
fill: fillOrNull(props.fill),
stroke,
strokeWidth: sw,
}];
}
case 'arc': {
const radius = (props.radius as number | undefined) ?? el.width / 2;
return [{
kind: 'arc',
center: { x: el.x, y: el.y },
radius,
startDeg: (props.startAngle as number | undefined) ?? 0,
endDeg: (props.endAngle as number | undefined) ?? 360,
stroke,
strokeWidth: sw,
}];
}
case 'polyline':
case 'polygon': {
const pts = Array.isArray(props.points)
? (props.points as unknown[]).map(asPoint)
: [];
if (pts.length < 2) return [];
return [{
kind: 'polyline',
points: pts,
close: el.type === 'polygon',
stroke,
strokeWidth: sw,
}];
}
case 'text': {
const txt = String(props.text ?? '');
if (!txt) return []; // leeren Platzhalter nicht rendern
return [{
kind: 'text',
at: { x: el.x, y: el.y },
text: txt,
fontSize: (props.fontSize as number | undefined) ?? 12,
color: (props.color as string | undefined) ?? stroke,
}];
}
// ── Task C3: bisher im Pixi-Pfad fehlende Typen ──
case 'dimension': {
// Legacy-Konvention: x1..y2 in properties, BBox-Zentrum als x/y,
// Extension lines + Hauptlinie; Pfeilspitzen sind Detail (C3+)
const x1 = (props.x1 as number | undefined) ?? el.x - el.width / 2;
const y1 = (props.y1 as number | undefined) ?? el.y;
const x2 = (props.x2 as number | undefined) ?? el.x + el.width / 2;
const y2 = (props.y2 as number | undefined) ?? el.y;
const color = (props.color as string | undefined) ?? '#aaaaaa';
const dimColor = (props.dimColor as string | undefined) ?? color;
const label = String(props.value ?? props.text ?? '');
const out: ShapePrimitive[] = [
{ kind: 'line', from: { x: x1, y: y1 }, to: { x: x2, y: y2 }, stroke: dimColor, strokeWidth: 1 },
];
if (label) {
out.push({
kind: 'text',
at: { x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 12 },
text: label,
fontSize: 10,
color: dimColor,
});
}
return out;
}
case 'leader': {
// Legacy-Konvention: Linie p1→p2 + optionaler Text am Ende
const lx1 = (props.x1 as number | undefined) ?? el.x;
const ly1 = (props.y1 as number | undefined) ?? el.y;
const lx2 = (props.x2 as number | undefined) ?? el.x + el.width;
const ly2 = (props.y2 as number | undefined) ?? el.y + el.height;
const out: ShapePrimitive[] = [
{ kind: 'line', from: { x: lx1, y: ly1 }, to: { x: lx2, y: ly2 }, stroke, strokeWidth: sw },
];
const label = String(props.text ?? '');
if (label) {
out.push({
kind: 'text',
at: { x: lx2 + 4, y: ly2 - 4 },
text: label,
fontSize: (props.fontSize as number | undefined) ?? 12,
color: stroke,
});
}
return out;
}
case 'revcloud': {
// Revisionswolke: Punkte-Zug (Bogen-Detail folgt in C3+)
const pts = Array.isArray(props.points)
? (props.points as unknown[]).map(asPoint)
: [];
if (pts.length < 2) return [];
return [{
kind: 'polyline',
points: pts,
close: true,
stroke,
strokeWidth: sw,
}];
}
case 'chair': {
// Stuhl: gefülltes Quadrat in Element-Farbe + Umrandung
const fill = (props.fill as string | undefined) ?? '#4a90d9';
return [{
kind: 'rect',
x: el.x - el.width / 2,
y: el.y - el.height / 2,
w: el.width,
h: el.height,
fill,
stroke: (props.outlineColor as string | undefined) ?? '#2a5a99',
strokeWidth: 1,
}];
}
case 'table': {
// Tisch: Kreis in Element-Größe mit Füllung
const fill = (props.fill as string | undefined) ?? '#8b7355';
return [{
kind: 'circle',
center: { x: el.x, y: el.y },
radius: Math.max(el.width, el.height) / 2,
fill,
stroke: '#5a4a35',
strokeWidth: 1,
}];
}
case 'stage': {
// Bühne: Rechteck mit dunkler Füllung
return [{
kind: 'rect',
x: el.x - el.width / 2,
y: el.y - el.height / 2,
w: el.width,
h: el.height,
fill: (props.fill as string | undefined) ?? '#3a3a4a',
stroke,
strokeWidth: 1.5,
}];
}
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:
return [];
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* render/types.ts RenderAdapter-Schnittstelle (Task C1).
*
* Ziel-Architektur: ALLES Rendering läuft gegen diese schmale API.
* Implementierungen:
* - render/pixi/PixiRenderer.ts (WebGL, Task C2+)
* - tests/helpers/mockAdapter.ts (Headless-Recorder für Unit-Tests)
*
* Phase D/E-Erweiterungen (Plugins, Custom-Elemente) nutzen denselben Vertrag
* über drawOverlay/drawCustom.
*/
import type { CADElement, CADLayer } from '../types/cad.types';
import type { Pt } from '../tools/modification/geometry';
/** Aktueller Ausschnitt in Weltkoordinaten + Skalierung. */
export interface Viewport {
x: number;
y: number;
scale: number;
width: number;
height: number;
}
/** Zeichenraster-Konfiguration. */
export interface GridConfig {
enabled: boolean;
size: number;
color: string;
}
/** Hintergrundbild-Konfiguration (Spiegelung der Legacy RenderOptions-Felder). */
export interface BackgroundDrawConfig {
src?: string;
opacity?: number;
offsetX?: number;
offsetY?: number;
scale?: number;
rotation?: number;
}
/** Lineal-Konfiguration. */
export interface RulerConfig {
enabled: boolean;
unit: string;
scaleFactor: number;
}
/** Stil pro CAD-Layer (Farbe/Linientyp/Transparenz). */
export interface LayerStyle {
color: string;
lineType: string;
transparency: number;
visible: boolean;
}
/** Freihand-Zeichner für temporäre Overlays (Marquee, Tool-Hilfslinien). */
export interface OverlayPainter {
line(a: Pt, b: Pt, color: string, width?: number): void;
rect(x: number, y: number, w: number, h: number, strokeColor: string): void;
circle(center: Pt, radius: number, strokeColor: string): void;
text(text: string, at: Pt, color: string, fontSize?: number): void;
}
/** Selektionszustand für die Anzeige von Handles. */
export interface SelectionState {
selectedIds: string[];
/** Task C3-Finish: Elemente für Handle-Zeichnung (Renderer braucht BBox). */
elements?: CADElement[];
}
/**
* Der zentrale Rendervertrag. Implementierungen MÜSSEN alle Methoden bereitstellen,
* auch wenn einzelne initial No-Ops sind (z.B. drawBackground ohne Bild).
*/
export interface RenderAdapter {
/** Bindet den Renderer an ein Canvas-Element (einmalig). */
init(canvas: HTMLCanvasElement): void;
/** Reaktion auf Größenänderungen (DPR-bewusst). */
resize(width: number, height: number, dpr: number): void;
/** Setzt den aktuellen Ausschnitt; danach ist ein neuer Frame nötig. */
setViewport(vp: Viewport): void;
/// Gibt WebGL-/DOM-Ressourcen frei. */
destroy(): void;
/** Leert die Zeichenfläche mit einer Grundfarbe. */
clear(color: string): void;
/** Zeichnet das Raster. */
drawGrid(cfg: GridConfig): void;
/** Zeichnet das Hintergrundbild, falls konfiguriert. */
drawBackground(img: HTMLImageElement | null, cfg: BackgroundDrawConfig): void;
/** Zeichnet das Lineal. */
drawRuler(cfg: RulerConfig): void;
/** Zeichnet ein Element im Kontext seines Layers (Stil, Sichtbarkeit). */
drawElement(el: CADElement, layer: CADLayer, style: LayerStyle): void;
/** Entfernt ein Element aus dem Rendering (z.B. nach Löschen in Yjs). */
removeElement(id: string): void;
/** Entfernt ALLE gerenderten Elemente (Reset bei Dokumentwechsel). */
clearElements(): void;
/** Zeichnet ein Element in Transformation / Live-Vorschau. */
drawPreview(el: CADElement | null): void;
/** Synchronisiert die Selektionsanzeige (Handles, Umrandungen). */
setSelection(sel: SelectionState): void;
/** Snap-Markierungen setzen/entfernen. */
setSnapPoints(pts: Array<{ x: number; y: number; type: string }>): void;
/** Öffnet einen Overlay-Pass für freihand-Overlays (Tools). */
overlay(draw: (g: OverlayPainter) => void): void;
/** Treffer abfragen (Delegation an SpatialIndex; kein GPU-Picking). */
hitTest(world: Pt, tolerancePx: number): CADElement | null;
}
+115
View File
@@ -660,6 +660,7 @@ export interface GlobalBlock {
name: string;
block_data: string;
svg_data: string | null;
is_favorite: boolean;
created_at: string;
updated_at: string;
}
@@ -698,6 +699,17 @@ export async function renameGlobalBlock(token: string, id: string, name: string)
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> {
const res = await fetch(`${API_BASE}/api/global-blocks/${id}`, {
method: 'DELETE',
@@ -764,3 +776,106 @@ export async function aiChat(
}
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();
}
+276 -24
View File
@@ -1,27 +1,53 @@
/**
* 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 type { CADElement, CADLayer, CADProperties, ElementType } from '../types/cad.types';
export interface SkippedEntity {
type: string;
reason: string;
}
export interface DXFImportResult {
elements: CADElement[];
layers: CADLayer[];
warnings: string[];
skippedEntities: SkippedEntity[];
}
let idCounter = 0;
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.
*/
export function parseDXF(dxfString: string): DXFImportResult {
const parser = new DxfParser();
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 skippedEntities: SkippedEntity[] = [];
const layerMap = new Map<string, CADLayer>();
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) {
for (const entity of dxf.entities) {
if (entity?.type === 'INSERT') {
elements.push(...expandInsert(entity, layerMap));
continue;
}
const el = entityToCADElement(entity, layerMap);
if (el) {
elements.push(el);
} 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,
layerMap: Map<string, CADLayer>,
): CADElement | null {
@@ -84,13 +268,10 @@ function entityToCADElement(
const s = entity.vertices?.[0];
const e = entity.vertices?.[1];
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 {
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 },
};
}
@@ -98,9 +279,10 @@ function entityToCADElement(
const c = entity.center;
const r = entity.radius;
if (!c || r == null) return null;
// Center-Konvention wie beide Renderer: el.x/el.y IST das Zentrum
return {
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 },
};
}
@@ -108,25 +290,49 @@ function entityToCADElement(
const c = entity.center;
const r = entity.radius;
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 {
id: nextId(), type: 'arc', layerId: layer.id,
x: c.x - r, y: c.y - r, width: r * 2, height: r * 2,
properties: { ...baseProps, radius: r, startAngle: entity.startAngle, endAngle: entity.endAngle },
x: c.x, y: c.y, width: r * 2, height: r * 2,
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 'POLYLINE': {
const pts = (entity.vertices || []).map((v: any) => ({ x: v.x, y: v.y }));
if (pts.length < 2) return null;
const minX = Math.min(...pts.map((p: any) => p.x));
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 bbox = bboxOf(pts);
const isClosed = entity.shape === true;
const type: ElementType = isClosed ? 'polygon' : 'polyline';
return {
id: nextId(), type, layerId: layer.id,
x: minX, y: minY, width: maxX - minX, height: maxY - minY,
...bbox,
properties: { ...baseProps, points: pts },
};
}
@@ -139,16 +345,62 @@ function entityToCADElement(
properties: { ...baseProps, text: entity.text || '', fontSize: entity.height || 12 },
};
}
case 'INSERT': {
// Block reference — store as block_instance
case 'MTEXT': {
const pos = entity.position;
if (!pos) return null;
const text = cleanMText(String(entity.text ?? ''));
if (!text) return null;
return {
id: nextId(), type: 'block_instance', layerId: layer.id,
x: pos.x, y: pos.y, width: 0, height: 0,
properties: { ...baseProps, blockId: entity.name, scale: entity.scale || 1, rotation: entity.rotation || 0 },
id: nextId(), type: 'text', layerId: layer.id,
x: pos.x, y: pos.y, width: entity.width || 0, height: entity.height || 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': {
// Basic dimension support
const pts = entity.definitionPoint || entity.points;
+157 -64
View File
@@ -1,23 +1,44 @@
/**
* 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';
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 w = (code: number, value: string | number) => {
const w: Write = (code, value) => {
lines.push(String(code));
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
w(0, 'SECTION');
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, '$EXTMIN'); w(10, 0); w(20, 0);
w(9, '$EXTMAX'); w(10, 1000); w(20, 1000);
@@ -26,8 +47,6 @@ export function writeDXF(data: ProjectData): string {
// Tables section
w(0, 'SECTION');
w(2, 'TABLES');
// Layer table
w(0, 'TABLE');
w(2, 'LAYER');
w(70, data.layers.length);
@@ -41,15 +60,31 @@ export function writeDXF(data: ProjectData): string {
w(0, 'ENDTAB');
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
w(0, 'SECTION');
w(2, 'ENTITIES');
for (const el of data.elements) {
const layerName = getLayerName(data.layers, el.layerId);
writeEntity(w, el, layerName);
writeEntity(w, el, layerName, nameMap, false);
}
w(0, 'ENDSEC');
// EOF
@@ -58,10 +93,58 @@ export function writeDXF(data: ProjectData): string {
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(
w: (code: number, value: string | number) => void,
w: Write,
el: CADElement,
layerName: string,
nameMap: Map<string, string>,
inBlockDefinition: boolean,
): void {
const p = el.properties;
const stroke = (p.stroke as string) || '#ffffff';
@@ -77,68 +160,69 @@ function writeEntity(
break;
}
case 'circle': {
const cx = el.x + el.width / 2;
const cy = el.y + el.height / 2;
const r = p.radius ?? el.width / 2;
// App-Konvention: el.x/el.y IST das Zentrum (beide Renderer lesen es direkt)
w(0, 'CIRCLE');
w(8, layerName);
w(62, aci);
w(10, cx); w(20, cy); w(30, 0);
w(40, r);
w(10, el.x); w(20, el.y); w(30, 0);
w(40, p.radius ?? el.width / 2);
break;
}
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(8, layerName);
w(62, aci);
w(10, cx); w(20, cy); w(30, 0);
w(40, r);
w(50, radToDeg(p.startAngle ?? 0));
w(51, radToDeg(p.endAngle ?? 360));
w(10, el.x); w(20, el.y); w(30, 0);
w(40, p.radius ?? el.width / 2);
// App-Konvention: Winkel in Grad (0° = 3 Uhr, CCW) — direkt schreiben
w(50, p.startAngle ?? 0);
w(51, p.endAngle ?? 360);
break;
}
case 'rect': {
// Rectangle as LWPOLYLINE
w(0, 'LWPOLYLINE');
w(8, layerName);
w(62, aci);
w(90, 4);
w(70, 1); // closed
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);
// App-Konvention: rect x/y = Zentrum der BBox
const x0 = el.x - el.width / 2;
const y0 = el.y - el.height / 2;
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);
break;
}
case 'polygon':
case 'polyline': {
const pts = p.points ?? [];
w(0, 'LWPOLYLINE');
w(8, layerName);
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);
const pts = (p.points as Pt[] | undefined) ?? [];
if (pts.length >= 2) {
writePolylineR12(w, layerName, aci, pts.map((pt) => ({ x: pt.x, y: pt.y })), el.type === 'polygon');
}
break;
}
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(8, layerName);
w(62, aci);
w(10, el.x); w(20, el.y); w(30, 0);
w(40, p.fontSize ?? 12);
w(1, p.text ?? '');
w(1, (p.text as string) ?? '');
break;
}
case 'dimension': {
const pts = p.points ?? [];
const pts = (p.points as Pt[] | undefined) ?? [];
if (pts.length >= 2) {
// Draw dimension as LINE + TEXT
w(0, 'LINE');
w(8, layerName);
w(62, aci);
@@ -157,27 +241,41 @@ function writeEntity(
break;
}
case 'block_instance': {
const mapped = nameMap.get(String(p.blockId ?? ''));
if (!mapped) {
writeBBoxPolyline(w, el, layerName, aci);
break;
}
w(0, 'INSERT');
w(8, layerName);
w(62, aci);
w(2, p.blockId ?? 'BLOCK');
w(2, mapped);
w(10, el.x); w(20, el.y); w(30, 0);
w(41, p.scale ?? 1);
w(42, p.scale ?? 1);
w(50, radToDeg(p.rotation ?? 0));
const sx = typeof p.scaleX === 'number' ? p.scaleX : (typeof p.scale === 'number' ? p.scale : 1);
const sy = typeof p.scaleY === 'number' ? p.scaleY : (typeof p.scale === 'number' ? p.scale : 1);
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;
}
default:
// For chair, seating-row, etc. — export as LWPOLYLINE bounding box
w(0, 'LWPOLYLINE');
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);
// chair, seating-row, table, stage, revcloud, leader, image, … → BBox
writeBBoxPolyline(w, el, layerName, aci);
break;
}
}
@@ -191,7 +289,6 @@ function hexToACI(hex: string): number {
const r = parseInt(h.substring(0, 2), 16);
const g = parseInt(h.substring(2, 4), 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 > 200 && b < 100) return 2; // yellow
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 > 200 && b > 200) return 7; // white
if (r < 100 && g < 100 && b < 100) return 8; // gray
return 7; // default white
return 7;
}
function lineTypeToDXF(lt: string): string {
@@ -208,7 +305,3 @@ function lineTypeToDXF(lt: string): string {
if (lt === 'dotted') return 'DOT';
return 'CONTINUOUS';
}
function radToDeg(rad: number): number {
return (rad * 180) / Math.PI;
}
+270 -14
View File
@@ -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 EndpointCenter-Parametrisierung (SVG Spec F.6.5) gesampelt.
* Implizite Befehlswiederholung (L 10 0 20 0") und ML-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.
* 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 {
try {
@@ -79,6 +289,12 @@ export function importSVG(svgString: string): ImportResult {
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
const processElement = (node: Element) => {
const tag = node.tagName.toLowerCase();
@@ -101,38 +317,50 @@ export function importSVG(svgString: string): ImportResult {
break;
}
case 'rect': {
// App-Konvention (Task F8): rect x/y = Zentrum der BBox
const x = parseFloat(node.getAttribute('x') || '0') - vbX;
const y = parseFloat(node.getAttribute('y') || '0') - vbY;
const w = parseFloat(node.getAttribute('width') || '0');
const h = parseFloat(node.getAttribute('height') || '0');
elements.push({
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 },
});
break;
}
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 cy = parseFloat(node.getAttribute('cy') || '0') - vbY;
const r = parseFloat(node.getAttribute('r') || '0');
elements.push({
id: nextId(), type: 'circle', layerId: 'layer-0',
x: cx - r, y: cy - r, width: r * 2, height: r * 2,
properties: { stroke, strokeWidth, radius: r },
x: cx, y: cy, width: r * 2, height: r * 2,
properties: { stroke, strokeWidth, radius: r, fill: fill !== 'none' ? fill : undefined },
});
break;
}
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 cy = parseFloat(node.getAttribute('cy') || '0') - vbY;
const rx = parseFloat(node.getAttribute('rx') || '0');
const ry = parseFloat(node.getAttribute('ry') || '0');
elements.push({
id: nextId(), type: 'circle', layerId: 'layer-0',
x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2,
properties: { stroke, strokeWidth, radius: Math.max(rx, ry) },
});
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({
id: nextId(), type: 'polygon', layerId: 'layer-0',
...bboxOf(pts),
properties: { stroke, strokeWidth, points: pts, fill: fill !== 'none' ? fill : undefined },
});
}
break;
}
case 'polyline':
@@ -144,14 +372,26 @@ export function importSVG(svgString: string): ImportResult {
return acc;
}, []);
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({
id: nextId(), type: tag === 'polygon' ? 'polygon' : 'polyline', layerId: 'layer-0',
x: minX, y: minY, width: maxX - minX, height: maxY - minY,
properties: { stroke, strokeWidth, points: pts },
...bboxOf(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;
@@ -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.
*/
+202
View File
@@ -3,6 +3,15 @@
*/
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
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.
@@ -172,3 +181,196 @@ function hexToRgb(hex: string | undefined): ReturnType<typeof rgb> | null {
const b = parseInt(h.substring(4, 6), 16) / 255;
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;
}
}
}
+37
View File
@@ -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();
}
}
+136
View File
@@ -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);
}
+83 -4
View File
@@ -159,6 +159,13 @@ export const SEATING_TEMPLATES: SeatingTemplate[] = [
},
];
let seqCounter = 0;
/** Eindeutige, kollisionsfreie ID mit Präfix (Zeit + Sequenz + Zufall). */
function nextUid(prefix: string): string {
seqCounter += 1;
return `${prefix}_${Date.now().toString(36)}_${seqCounter.toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
export class SeatingService {
private generateId(): string {
return `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
@@ -189,6 +196,7 @@ export class SeatingService {
createSeatingRow(x: number, y: number, layerId: string, config: Partial<SeatingRowConfig> = {}): CADElement[] {
const cfg = { ...DEFAULT_ROW, ...config };
const elements: CADElement[] = [];
const rowId = nextUid('row');
const totalWidth = (cfg.count - 1) * cfg.spacing + cfg.chairWidth;
const startX = x - totalWidth / 2 + cfg.chairWidth / 2;
@@ -217,7 +225,7 @@ export class SeatingService {
outlineColor: '#2a5a99',
seatType: 'standard',
rowIndex: i,
rowId: `row_${Date.now()}`,
rowId,
},
});
}
@@ -233,9 +241,10 @@ export class SeatingService {
const startX = x - totalW / 2 + cfg.chairWidth / 2;
const startY = y - totalH / 2 + cfg.chairHeight / 2;
const rot = cfg.rotation * Math.PI / 180;
const blockId = `block_${Date.now()}`;
const blockId = nextUid('block');
for (let row = 0; row < cfg.rows; row++) {
const rowId = nextUid('row');
const offset = cfg.rowOffset * (row % 2);
for (let col = 0; col < cfg.cols; col++) {
const lx = startX + col * cfg.colSpacing + offset;
@@ -263,6 +272,7 @@ export class SeatingService {
rowIndex: row,
colIndex: col,
blockId,
rowId,
},
});
}
@@ -270,6 +280,47 @@ export class SeatingService {
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 */
createTable(x: number, y: number, layerId: string, config: Partial<TableConfig> = {}): CADElement {
const cfg = { ...DEFAULT_TABLE, ...config };
@@ -308,6 +359,7 @@ export class SeatingService {
stroke: '#1a2e3f',
strokeWidth: 2,
label: cfg.label,
height: cfg.height, // Bühnenhöhe in cm (Task E7: stage height attr)
},
};
}
@@ -342,8 +394,18 @@ export class SeatingService {
return [];
}
/** Count seats in a list of elements */
countSeats(elements: CADElement[]): { total: number; byRow: Record<string, number>; byBlock: Record<string, number> } {
/**
* Zählt Sitzplätze gruppiert nach Reihe und Block.
* byRow enthält Reihen aus freien Reihen UND Block-Reihen (seit BUG-1-Fix
* tragen Block-Stühle pro Reihe eine eigene rowId).
*/
countSeats(elements: CADElement[]): {
total: number;
byRow: Record<string, number>;
byBlock: Record<string, number>;
/** Platzhalter für künftige Zonen-Gruppierung (aktuell immer leer). */
byZone?: Record<string, number>;
} {
let total = 0;
const byRow: Record<string, number> = {};
const byBlock: Record<string, number> = {};
@@ -364,6 +426,23 @@ export class SeatingService {
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 */
getRowElements(elements: CADElement[], rowId: string): CADElement[] {
return elements.filter(el => el.type === 'chair' && el.properties.rowId === rowId);
+146
View File
@@ -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;
}
+66
View File
@@ -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;
}
+302
View File
@@ -2456,6 +2456,130 @@ body.drawer-open { overflow: hidden; }
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 {
background: var(--color-bg-secondary);
@@ -2672,3 +2796,181 @@ body.drawer-open { overflow: hidden; }
background: var(--color-accent);
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); }
+275 -19
View File
@@ -163,7 +163,14 @@ export function mirrorElement(el: CADElement, x1: number, y1: number, x2: number
* Offset element by a distance (creates a parallel copy).
* For lines: offset perpendicular. For circles/arcs: adjust radius.
*/
export function offsetElement(el: CADElement, distance: number): CADElement {
/**
* Versetzt ein Element senkrecht zur seiner Richtung.
* @param distance positiver Betrag des Versatzes
* @param side 1 = links der Laufrichtung (Standard), -1 = rechts
* bei circle/arc: 1 = Radius vergrößern, -1 = verkleinern
*/
export function offsetElement(el: CADElement, distance: number, side?: 1 | -1): CADElement {
const signed = distance * (side ?? 1);
const props = { ...el.properties };
if (el.type === 'line' && props.x1 !== undefined && props.y1 !== undefined && props.x2 !== undefined && props.y2 !== undefined) {
@@ -174,8 +181,8 @@ export function offsetElement(el: CADElement, distance: number): CADElement {
// Perpendicular unit vector
const nx = -dy / len;
const ny = dx / len;
const ox = nx * distance;
const oy = ny * distance;
const ox = nx * signed;
const oy = ny * signed;
props.x1 += ox;
props.y1 += oy;
props.x2 += ox;
@@ -184,7 +191,7 @@ export function offsetElement(el: CADElement, distance: number): CADElement {
}
if ((el.type === 'circle' || el.type === 'arc') && props.radius !== undefined) {
props.radius = Math.abs(props.radius + distance);
props.radius = Math.abs(props.radius + signed);
const d = props.radius * 2;
return { ...el, width: d, height: d, properties: props };
}
@@ -201,7 +208,7 @@ export function offsetElement(el: CADElement, distance: number): CADElement {
if (len === 0) return p;
const nx = -dy / len;
const ny = dx / len;
return { x: p.x + nx * distance, y: p.y + ny * distance };
return { x: p.x + nx * signed, y: p.y + ny * signed };
});
return { ...el, properties: props };
}
@@ -215,7 +222,15 @@ export function offsetElement(el: CADElement, distance: number): CADElement {
*/
export function trimElement(el: CADElement, boundary: CADElement): CADElement | null {
// Simplified: find intersection with boundary, trim line to that point
if (el.type !== 'line' || !el.properties.x1 || !el.properties.x2) return null;
// Hinweis: explizit auf undefined prüfen — Koordinate 0 ist gültig (nicht falsy behandeln)
const p = el.properties;
if (
el.type !== 'line' ||
p.x1 === undefined || p.y1 === undefined ||
p.x2 === undefined || p.y2 === undefined
) {
return null;
}
const intersect = findIntersection(el, boundary);
if (!intersect) return null;
@@ -247,10 +262,60 @@ export function trimElement(el: CADElement, boundary: CADElement): CADElement |
* Currently supports extending lines to a boundary intersection.
*/
export function extendElement(el: CADElement, boundary: CADElement): CADElement | null {
if (el.type !== 'line' || !el.properties.x1 || !el.properties.x2) return null;
// Explizit auf undefined prüfen — Koordinate 0 ist gültig (nicht falsy behandeln)
const p = el.properties;
if (
el.type !== 'line' ||
p.x1 === undefined || p.y1 === undefined ||
p.x2 === undefined || p.y2 === undefined
) {
return null;
}
const intersect = findIntersection(el, boundary);
if (!intersect) return null;
let intersect = findIntersection(el, boundary);
// Echtes CAD-Extend (Fallback): Wenn die Strecke die Kante NICHT berührt,
// suche Schnitte auf der VERLÄNGERUNG der Linie in Richtung des nahen Endes.
if (!intersect) {
const c = { x: boundary.x, y: boundary.y };
const r =
boundary.type === 'circle' || boundary.type === 'arc'
? ((boundary.properties?.radius as number | undefined) ?? boundary.width / 2)
: null;
if (r === null) return null; // nur Kreis/Bogen-Kanten erweiterbar
const candidates = lineCircleIntersectionsInfinite(
{ x: p.x1!, y: p.y1! },
{ x: p.x2!, y: p.y2! },
c,
r as number,
);
if (candidates.length === 0) return null;
// Wähle den Schnittpunkt, der an der Seite des NÄHEREN Endes liegt:
// Erst feststellen, welches Ende näher am Kreiszentrum liegt → dieses wird gestreckt.
const d1 = Math.hypot(p.x1! - c.x, p.y1! - c.y);
const d2 = Math.hypot(p.x2! - c.x, p.y2! - c.y);
const nearerIsStart = d1 <= d2;
const anchor = nearerIsStart ? { x: p.x1!, y: p.y1! } : { x: p.x2!, y: p.y2! };
intersect = candidates.reduce((best, cur) =>
Math.hypot(cur.x - anchor.x, cur.y - anchor.y) < Math.hypot(best.x - anchor.x, best.y - anchor.y) ? cur : best,
);
// Neu berechnete bbox/center auf Basis der Länge nach dem Strekken:
const nx1 = nearerIsStart ? intersect.x : p.x1!;
const ny1 = nearerIsStart ? intersect.y : p.y1!;
const nx2 = nearerIsStart ? p.x2! : intersect.x;
const ny2 = nearerIsStart ? p.y2! : intersect.y;
return {
...el,
x: (nx1 + nx2) / 2,
y: (ny1 + ny2) / 2,
width: Math.abs(nx2 - nx1),
height: Math.abs(ny2 - ny1),
properties: { ...p, x1: nx1, y1: ny1, x2: nx2, y2: ny2 },
};
}
const props = { ...el.properties };
// Extend the end that is closer to the intersection
@@ -300,22 +365,197 @@ export function filletElements(el1: CADElement, el2: CADElement, radius: number)
/**
* Find intersection point of two elements (lines only for now).
*/
function findIntersection(el1: CADElement, el2: CADElement): { x: number; y: number } | null {
/** Punkt im 2D-Raum. */
export interface Pt {
x: number;
y: number;
}
/**
* Alle Schnittpunkte einer Strecke p1p2 mit einem Kreis (Zentrum c, Radius r).
* Liefert nur Punkte innerhalb des Streckensegments (t [0,1]); [] wenn keine.
*/
export function lineCircleIntersections(p1: Pt, p2: Pt, c: Pt, r: number): Pt[] {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const fx = p1.x - c.x;
const fy = p1.y - c.y;
const a = dx * dx + dy * dy;
const b = 2 * (fx * dx + fy * dy);
const cc = fx * fx + fy * fy - r * r;
let discriminant = b * b - 4 * a * cc;
if (discriminant < 0) return []; // keine reale Lösung
discriminant = Math.sqrt(discriminant);
const t1 = (-b - discriminant) / (2 * a);
const t2 = (-b + discriminant) / (2 * a);
// Tangente: beide Lösungen identisch → nur einen Punkt liefern
const ts = Math.abs(t1 - t2) < 1e-7 ? [t1] : [t1, t2];
const eps = 1e-9;
const results: Pt[] = [];
for (const t of ts) {
if (t >= -eps && t <= 1 + eps) {
results.push({ x: p1.x + t * dx, y: p1.y + t * dy });
}
}
return results;
}
/**
* Schnittpunkte zweier Kreise (Zentren c1/c2, Radien r1/r2).
* Liefert 02 Punkte; [] wenn getrennt oder identisch.
*/
export function circleCircleIntersections(c1: Pt, r1: number, c2: Pt, r2: number): Pt[] {
const dVec = { x: c2.x - c1.x, y: c2.y - c1.y };
const d = Math.hypot(dVec.x, dVec.y);
if (d === 0) return []; // identische Zentren
if (d > r1 + r2 || d < Math.abs(r1 - r2)) return []; // getrennt / ineinander
const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d);
const hSq = r1 * r1 - a * a;
const h = Math.sqrt(Math.max(0, hSq));
const baseX = c1.x + (a * dVec.x) / d;
const baseY = c1.y + (a * dVec.y) / d;
if (h === 0) return [{ x: baseX, y: baseY }]; // tangentiale Berührung
return [
{ x: baseX + (h * dVec.y) / d, y: baseY - (h * dVec.x) / d },
{ x: baseX - (h * dVec.y) / d, y: baseY + (h * dVec.x) / d },
];
}
/**
* Liest die Arc-Parameter eines CADElements nach der Konvention von
* RenderEngine.drawArc: Zentrum = el.x/el.y, radius/startAngle/endAngle aus
* properties; Winkel in GRAD, 0° = 3 Uhr, gegen Uhrzeigersinn.
*/
function readArc(el: CADElement): { c: Pt; r: number; startDeg: number; endDeg: number } | null {
const r = (el.properties?.radius as number | undefined) ?? (el as unknown as { width?: number }).width! / 2;
if (!Number.isFinite(r) || r <= 0) return null;
return {
c: { x: el.x, y: el.y },
r,
startDeg: ((el.properties?.startAngle as number | undefined) ?? 0),
endDeg: ((el.properties?.endAngle as number | undefined) ?? 360),
};
}
/** Prüft, ob ein Winkel (Grad) innerhalb eines Bogens liegt (CCW von start bis end). */
function angleInArcRange(deg: number, startDeg: number, endDeg: number): boolean {
const norm = (v: number) => ((v % 360) + 360) % 360;
let a = norm(deg);
const s = norm(startDeg);
const e = norm(endDeg);
if (s <= e) return a >= s && a <= e;
return a >= s || a <= e; // Bogen über 0°
}
/**
* Wie lineCircleIntersections, aber OHNE Segmentbegrenzung (t ):
* findet Schnitte auch auf der VERLÄNGERUNG der Strecke Grundlage für
* echtes CAD-Extend (Task A4.10), bei dem das Ziel die Kante oft noch
* gar nicht berührt.
*/
export function lineCircleIntersectionsInfinite(p1: Pt, p2: Pt, c: Pt, r: number): Pt[] {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const fx = p1.x - c.x;
const fy = p1.y - c.y;
const a = dx * dx + dy * dy;
if (a < 1e-12) return [];
const b = 2 * (fx * dx + fy * dy);
const cc = fx * fx + fy * fy - r * r;
let disc = b * b - 4 * a * cc;
if (disc < 0) return [];
disc = Math.sqrt(disc);
const out: Pt[] = [];
for (const t of [(-b - disc) / (2 * a), (-b + disc) / (2 * a)]) {
out.push({ x: p1.x + t * dx, y: p1.y + t * dy });
}
return out;
}
/**
* Alle Schnittpunkte einer Strecke p1p2 mit dem Bogen eines Elements
* (Konvention wie RenderEngine.drawArc). Nur Treffer innerhalb des Segments
* UND im Winkelbereich des Bogens werden zurückgegeben.
*/
export function lineArcIntersection(p1: Pt, p2: Pt, el: CADElement): Pt[] {
const arc = readArc(el);
if (!arc) return [];
return lineCircleIntersections(p1, p2, arc.c, arc.r)
.filter((pt) => angleInArcRange((Math.atan2(pt.y - arc.c.y, pt.x - arc.c.x) * 180) / Math.PI, arc.startDeg, arc.endDeg));
}
function lineLineIntersections(el1: CADElement, el2: CADElement): Pt[] {
const p1 = el1.properties;
const p2 = el2.properties;
if (p1.x1 === undefined || p1.y1 === undefined || p1.x2 === undefined || p1.y2 === undefined) return null;
if (p2.x1 === undefined || p2.y1 === undefined || p2.x2 === undefined || p2.y2 === undefined) return null;
if (p1.x1 === undefined || p1.y1 === undefined || p1.x2 === undefined || p1.y2 === undefined) return [];
if (p2.x1 === undefined || p2.y1 === undefined || p2.x2 === undefined || p2.y2 === undefined) return [];
// Line-line intersection
const denom = (p1.x1 - p1.x2) * (p2.y1 - p2.y2) - (p1.y1 - p1.y2) * (p2.x1 - p2.x2);
if (Math.abs(denom) < 1e-10) return null;
const denom = (p1.x1! - p1.x2!) * (p2.y1! - p2.y2!) - (p1.y1! - p1.y2!) * (p2.x1! - p2.x2!);
if (Math.abs(denom) < 1e-10) return [];
const t = ((p1.x1 - p2.x1) * (p2.y1 - p2.y2) - (p1.y1 - p2.y1) * (p2.x1 - p2.x2)) / denom;
const x = p1.x1 + t * (p1.x2 - p1.x1);
const y = p1.y1 + t * (p1.y2 - p1.y1);
const t = ((p1.x1! - p2.x1!) * (p2.y1! - p2.y2!) - (p1.y1! - p2.y1!) * (p2.x1! - p2.x2!)) / denom;
const x = p1.x1! + t * (p1.x2! - p1.x1!);
const y = p1.y1! + t * (p1.y2! - p1.y1!);
return [{ x, y }];
}
return { x, y };
/**
* Erster Schnittpunkt zweier CAD-Elemente (line/circle/arc), intern für
* trim/extend/fillet. Bei mehreren Treffern wird der zu el1 nächstgelegene
* gewählt. null wenn keine Kombination unterstützt oder kein Schnitt.
*/
function findIntersection(el1: CADElement, el2: CADElement): { x: number; y: number } | null {
const types = new Set([el1.type, el2.type]);
// Linie immer als erstes Argument normalisieren (reihenfolgeunabhängig)
const [lineEl, otherEl] = el1.type === 'line' ? [el1, el2] : [el2, el1];
let candidates: Pt[] = [];
let refFromFirst = true; // Referenzpunkt von el1 nehmen?
if (types.has('line') && types.size === 1) {
candidates = lineLineIntersections(el1, el2);
refFromFirst = false; // beide sind Linien; Referenz unten via el1-Startpunkt
} else if (types.has('line') && types.has('circle')) {
candidates = lineCircleIntersections(
{ x: lineEl.properties.x1!, y: lineEl.properties.y1! },
{ x: lineEl.properties.x2!, y: lineEl.properties.y2! },
{ x: otherEl.x, y: otherEl.y },
(otherEl.properties.radius as number | undefined) ?? otherEl.width / 2,
);
} else if (types.has('line') && types.has('arc')) {
candidates = lineArcIntersection(
{ x: lineEl.properties.x1!, y: lineEl.properties.y1! },
{ x: lineEl.properties.x2!, y: lineEl.properties.y2! },
otherEl,
);
} else if (types.has('circle') && types.size === 1) {
candidates = circleCircleIntersections(
{ x: el1.x, y: el1.y },
(el1.properties.radius as number | undefined) ?? el1.width / 2,
{ x: el2.x, y: el2.y },
(el2.properties.radius as number | undefined) ?? el2.width / 2,
);
refFromFirst = false;
}
if (candidates.length === 0) return null;
// Nächstliegenden Treffer zu el1 wählen (Startpunkt bei Linie, sonst Zentrum)
const isFirstLine = el1.type === 'line';
const refX = refFromFirst || isFirstLine
? (isFirstLine ? el1.properties.x1 : el1.x)
: el1.x;
const refY = refFromFirst || isFirstLine
? (isFirstLine ? el1.properties.y1 : el1.y)
: el1.y;
let best = candidates[0];
for (const c of candidates) {
if (Math.hypot(c.x - refX!, c.y - refY!) < Math.hypot(best.x - refX!, best.y - refY!)) best = c;
}
return best;
}
/**
@@ -348,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 {
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);
}
+31 -1
View File
@@ -6,7 +6,8 @@ export type ElementType =
| 'line' | 'circle' | 'arc' | 'rect' | 'polygon'
| 'polyline' | 'text' | 'dimension' | 'block_instance' | 'chair'
| 'seating-row' | 'seating-block' | 'table' | 'stage'
| 'leader' | 'revcloud' | 'image';
| 'leader' | 'revcloud' | 'image'
| 'curtain' | 'spotlight' | 'barrier';
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 {
id: string;
name: string;
@@ -69,6 +80,25 @@ export interface BlockDefinition {
category: string;
elements: CADElement[];
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 {
+2
View File
@@ -94,6 +94,8 @@ export interface RibbonBarProps {
onCanvasBgColorChange?: (color: string) => void;
onGridColorChange?: (color: string) => void;
onToggleRuler?: () => void;
/** Task A4.13: meldet, ob ein V2-Werkzeug-ID aktuell das aktive Tool ist (Highlight). */
onIsV2ToolActive?: (toolId: string) => boolean;
}
export interface LeftSidebarProps {
+109
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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);
}
+25
View File
@@ -92,3 +92,28 @@ export function formatInputValue(
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)}`;
default:
return `${mmSquared.toFixed(0)} mm²`;
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Minimaler Level-basierter Logger.
* Ziel: direktes `console.log` im Produktivcode ersetzen (ROADMAP Teil-4-Regel),
* damit sich Logs zentral filtern/abschalten lassen.
*
* Nutzung:
* import { logger } from '../utils/logger';
* logger.info('Element erstellt', el.id);
*
* Level über localStorage['webcad.logLevel'] ('silent'|'error'|'warn'|'info'|'debug',
* Default: 'info'). `logger.debug` bleibt auf Default still.
*/
export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug';
const LEVEL_ORDER: Record<LogLevel, number> = {
silent: 100,
error: 40,
warn: 30,
info: 20,
debug: 10,
};
function readConfiguredLevel(): LogLevel {
try {
// Browser und jsdom haben localStorage; in reinen Node-Tests schlägt der Zugriff fehl
const raw = globalThis.localStorage?.getItem('webcad.logLevel');
if (raw && raw in LEVEL_ORDER) return raw as LogLevel;
} catch {
/* Umgebung ohne localStorage → Default */
}
return 'info';
}
let currentLevel: LogLevel = readConfiguredLevel();
/** Setzt das Level programmatisch (überschreibt localStorage bis zum Reload). */
export function setLogLevel(level: LogLevel): void {
currentLevel = level;
}
/** Aktuell aktives Log-Level. */
export function getLogLevel(): LogLevel {
return currentLevel;
}
class Logger {
/** Fehlerhafte Zustände — melden außer bei silent. */
error(...args: unknown[]): void {
if (LEVEL_ORDER[currentLevel] <= LEVEL_ORDER.error) console.error('[web-cad]', ...args);
}
/** Warnungen. */
warn(...args: unknown[]): void {
if (LEVEL_ORDER[currentLevel] <= LEVEL_ORDER.warn) console.warn('[web-cad]', ...args);
}
/** Normale Informationsmeldungen (Default-Level aktiv). */
info(...args: unknown[]): void {
if (LEVEL_ORDER[currentLevel] <= LEVEL_ORDER.info) console.log('[web-cad]', ...args);
}
/** Debug-Ausgaben — nur wenn Level debug konfiguriert ist. */
debug(...args: unknown[]): void {
if (LEVEL_ORDER[currentLevel] <= LEVEL_ORDER.debug) console.log('[web-cad][debug]', ...args);
}
}
export const logger = new Logger();
+170
View File
@@ -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');
});
});
+104
View File
@@ -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);
});
});
+45
View File
@@ -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"');
});
});
+173
View File
@@ -0,0 +1,173 @@
/**
* Regressions-Tests für bekannte Bugs BUG-1, BUG-1b, BUG-2.
* Diese Tests sind VOR dem jeweiligen Fix ROT (dokumentiertes Fehlverhalten)
* und müssen danach GRÜN sein.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SeatingService } from '../src/services/seatingService';
import { trimElement, offsetElement } from '../src/tools/modification/geometry';
import type { CADElement } from '../src/types/cad.types';
import { RenderEngine } from '../src/canvas/RenderEngine';
import { ZoomPanController } from '../src/canvas/ZoomPanController';
import { SpatialIndex } from '../src/canvas/SpatialIndex';
import { LayerManager } from '../src/canvas/LayerManager';
// ── Helfer (Muster aus tests/geometry.test.ts) ─────────────
function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement {
return {
id,
type: 'line',
layerId: 'layer-1',
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;
}
function makeCircle(id: string, cx: number, cy: number, r: number): CADElement {
return {
id,
type: 'circle',
layerId: 'layer-1',
x: cx,
y: cy,
width: r * 2,
height: r * 2,
properties: { radius: r },
} as unknown as CADElement;
}
// ── BUG-1: rowId-Kollision bei schnellem Erzeugen ───────────
describe('BUG-1: seating row ids', () => {
it('zwei direkt hintereinander erzeugte Reihen haben unterschiedliche rowIds', () => {
const svc = new SeatingService();
const a = svc.createSeatingRow(0, 0, 'layer-1');
const b = svc.createSeatingRow(0, 100, 'layer-1');
const ids = new Set([a[0].properties.rowId, b[0].properties.rowId]);
expect(ids.size).toBe(2);
});
});
// ── BUG-1b: Block-Reihen ohne eigene rowId ──────────────────
describe('BUG-1b: seating block row ids', () => {
it('jede Reihe eines Blocks hat eine eigene, definierte rowId', () => {
const svc = new SeatingService();
const els = svc.createSeatingBlock(0, 0, 'layer-1', { rows: 3, cols: 2 });
expect(els.length).toBe(6);
const rowIds = new Set(els.map((e) => e.properties.rowId));
expect(rowIds.size).toBe(3);
for (const rid of rowIds) expect(rid).toBeDefined();
});
});
// ── BUG-2: Trim an Kreis scheitert (findIntersection nur Gerade×Gerade) ──
describe('BUG-2: trim line at circle', () => {
it('trimElement(Linie, Kreis) liefert ein Ergebnis statt null', () => {
// Linie von (0,0) nach (200,0) schneidet Kreis (Zentrum 100,0, Radius 40)
// an x=60 und x=140 → Schnitt existiert eindeutig.
const line = makeLine('l1', 0, 0, 200, 0);
const circle = makeCircle('c1', 100, 0, 40);
const result = trimElement(line, circle);
expect(result).not.toBeNull();
});
});
// ── BUG-3: Offset ignorierte die Klickseite ─────────────────
describe('BUG-3: offset respects click side', () => {
it('side=-1 versetzt Linie entgegen der Normalenrichtung', () => {
// Linie (100,100)→(300,100): Laufrichtung +x.
// Normale (-dy,dx)=(0,1) zeigt in Canvas-Koordinaten nach UNTEN (+y).
const line = makeLine('l1', 100, 100, 300, 100);
const result = offsetElement(line, 20, -1);
expect(result.properties.y1).toBeCloseTo(80); // nach OBEN versetzt
expect(result.properties.y2).toBeCloseTo(80);
});
it('side=+1 versetzt Linie in Normalenrichtung (Default wie vorher)', () => {
const line = makeLine('l1', 100, 100, 300, 100);
const result = offsetElement(line, 20, 1);
expect(result.properties.y1).toBeCloseTo(120); // nach UNTEN versetzt
expect(result.properties.y2).toBeCloseTo(120);
});
it('ohne side bleibt Verhalten identisch zur alten API (Rueckwaertskompatibilitaet)', () => {
const line = makeLine('l1', 100, 100, 300, 100);
const result = offsetElement(line, 20);
expect(result.properties.y1).toBeCloseTo(120);
});
it('Kreis: side=-1 verkleinert Radius, side=+1 vergroessert', () => {
const circle = makeCircle('c1', 0, 0, 50);
expect(offsetElement(circle, 10, -1).properties.radius).toBeCloseTo(40);
expect(offsetElement(circle, 10, 1).properties.radius).toBeCloseTo(60);
});
});
// ── BUG-4: Hintergrundbild wurde pro Frame neu geladen ──────
describe('BUG-4: background image cached', () => {
let imageCount: number;
let OriginalImage: typeof Image;
beforeEach(() => {
imageCount = 0;
OriginalImage = global.Image;
// Bild-Stub: zaehlt Konstruktur-Aufrufe; simuliert Browser-Cache-Bild
// (complete=true sofort verfuegbar, onload als Mikrotask wie async decode)
class FakeImage {
complete = true;
naturalWidth = 800;
width = 800;
height = 600;
onload: (() => void) | null = null;
private internalSrc = '';
get src(): string {
return this.internalSrc;
}
set src(value: string) {
this.internalSrc = value;
queueMicrotask(() => this.onload?.());
}
constructor() {
imageCount += 1;
}
}
(global as { Image: unknown }).Image = FakeImage as unknown as typeof Image;
});
afterEach(() => {
(global as { Image: unknown }).Image = OriginalImage;
});
function createEngine(): RenderEngine {
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 600;
const zoomPan = new ZoomPanController(canvas);
const spatialIndex = new SpatialIndex();
const layerManager = new LayerManager();
return new RenderEngine(canvas, zoomPan, spatialIndex, layerManager);
}
it('lädt das Bild pro Quellwechsel nur EINMAL, weitere Frames nutzen den Cache', async () => {
const engine = createEngine();
const DATA_URL = 'data:image/png;base64,iVBORw0KGgo=';
engine.setOptions({ backgroundSrc: DATA_URL, backgroundOpacity: 0.5 });
// Frame 1: legt genau ein Image an und cachet es (complete=true)
engine.render();
expect(imageCount).toBe(1);
// Weitere Frames mit identischer src: dürfen KEIN neues Image erzeugen
engine.render();
engine.render();
engine.render();
expect(imageCount).toBe(1);
// Ausstehende Mikrotasks (simuliertes onload) abwarten, dann erneut prüfen:
await Promise.resolve();
engine.render();
expect(imageCount).toBe(1); // vorher (Bug): jedes render() erzeugte ein neues Image
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* CADDocument Tests (Task A2b).
* Kern-Verhalten: CRUD, Shallow-Merge, Transaktion = 1 Undo-Schritt,
* undo/redo-Stacks, deleteElements selektiv.
*/
import { describe, it, expect } from 'vitest';
import { CADDocument } from '../src/kernel/document/CADDocument';
import { createInMemoryDoc } from './helpers/inMemoryDoc';
import type { CADElement } from '../src/types/cad.types';
function makeEl(id: string, props: Record<string, unknown> = {}): CADElement {
return {
id,
type: 'rect',
layerId: 'layer-1',
x: 10,
y: 20,
width: 30,
height: 40,
properties: props,
} as unknown as CADElement;
}
function makeDoc(): { doc: CADDocument } {
const { ydoc } = createInMemoryDoc();
return { doc: new CADDocument(ydoc) };
}
describe('CADDocument', () => {
it('a) addElement → getAllElements enthaelt es', () => {
const { doc } = makeDoc();
doc.addElement(makeEl('e1'));
const all = doc.getAllElements();
expect(all.length).toBe(1);
expect(all[0].id).toBe('e1');
expect(doc.getElement('e1')).toBeDefined();
expect(doc.getElement('missing')).toBeUndefined();
});
it('b) transact um zwei addElements = EIN Undo-Schritt entfernt BEIDE', () => {
const { doc } = makeDoc();
doc.transact(() => {
doc.addElement(makeEl('a'));
doc.addElement(makeEl('b'));
});
expect(doc.getAllElements().length).toBe(2);
expect(doc.canUndo()).toBe(true);
expect(doc.undo()).toBe(true);
expect(doc.getAllElements().length).toBe(0); // beide weg mit einem undo!
expect(doc.canUndo()).toBe(false);
});
it('c) redo stellt beide Elemente wieder her', () => {
const { doc } = makeDoc();
doc.transact(() => {
doc.addElement(makeEl('a'));
doc.addElement(makeEl('b'));
});
doc.undo();
expect(doc.getAllElements().length).toBe(0);
expect(doc.canRedo()).toBe(true);
expect(doc.redo()).toBe(true);
expect(doc.getAllElements().length).toBe(2);
expect(doc.getElement('a')).toBeDefined();
expect(doc.getElement('b')).toBeDefined();
});
it('d) updateElement merged properties SHALLOW (bestehende Keys bleiben)', () => {
const { doc } = makeDoc();
doc.addElement(makeEl('m', { fill: 'red', rotation: 45, stroke: 'black' }));
doc.updateElement('m', { properties: { fill: 'blue' } });
const el = doc.getElement('m')!;
expect(el.properties.fill).toBe('blue'); // ueberschrieben
expect(el.properties.rotation).toBe(45); // erhalten
expect(el.properties.stroke).toBe('black'); // erhalten
});
it('d2) updateElement kann Top-Level-Felder setzen und wirft bei fehlender ID', () => {
const { doc } = makeDoc();
doc.addElement(makeEl('m'));
doc.updateElement('m', { x: 999 });
expect(doc.getElement('m')!.x).toBe(999);
expect(() => doc.updateElement('ghost', { x: 1 })).toThrow(/nicht gefunden/);
});
it('e) deleteElements entfernt nur die genannten IDs', () => {
const { doc } = makeDoc();
doc.transact(() => {
doc.addElement(makeEl('keep1'));
doc.addElement(makeEl('del1'));
doc.addElement(makeEl('del2'));
doc.addElement(makeEl('keep2'));
});
doc.deleteElements(['del1', 'del2', 'unbekannt']); // unbekannte still ignorieren
const ids = doc.getAllElements().map((e) => e.id).sort();
expect(ids).toEqual(['keep1', 'keep2']);
});
it('undo nach deleteElements stellt geloeschte Elemente wieder her', () => {
const { doc } = makeDoc();
doc.addElement(makeEl('x1'));
doc.addElement(makeEl('x2'));
doc.deleteElements(['x1']);
expect(doc.getAllElements().length).toBe(1);
doc.undo(); // Delete rueckgaengig
expect(doc.getAllElements().length).toBe(2);
expect(doc.getElement('x1')).toBeDefined();
});
it('undo/redo liefern false auf leeren Stacks', () => {
const { doc } = makeDoc();
expect(doc.undo()).toBe(false);
expect(doc.redo()).toBe(false);
});
});
+147
View File
@@ -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');
}
});
});
+138
View File
@@ -0,0 +1,138 @@
/**
* InteractionDispatcher Tests (Task A2).
* Prüft Event-Routing gegen ein Fake-Werkzeug ohne echte Registry:
* Reihenfolge downmoveup, ESC/Rechtsklick cancel, move nur bei aktiver Session,
* unbekanntes Tool kein Routing.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { InteractionDispatcher } from '../src/interaction/dispatcher';
import type { ToolExtensionV2 } from '../src/plugins/types';
import type { ZoomPanController } from '../src/canvas/ZoomPanController';
function makeFakeZoomPan(): ZoomPanController {
return {
screenToWorld: (sx: number, sy: number) => ({ x: sx * 2, y: sy * 3 }),
} as unknown as ZoomPanController;
}
function makeRecordingTool(id = 'fake'): { tool: ToolExtensionV2; log: string[] } {
const log: string[] = [];
const tool: ToolExtensionV2 = {
manifest: { id, label: 'Fake', icon: 'f', ribbonTab: 'tools' },
optionsSchema: [],
handlers: {
down: () => log.push('down'),
move: () => log.push('move'),
up: () => log.push('up'),
key: () => false,
cancel: () => log.push('cancel'),
},
};
return { tool, log };
}
function fire(canvas: HTMLCanvasElement, type: string, opts: Partial<PointerEventInit> = {}): PointerEvent {
const e = new PointerEvent(type, {
bubbles: true,
clientX: 100,
clientY: 50,
button: 0,
...opts,
});
if (type === 'pointerdown') canvas.dispatchEvent(e);
else window.dispatchEvent(e);
return e;
}
function fireKey(key: string): KeyboardEvent {
const e = new KeyboardEvent('keydown', { key, bubbles: true });
window.dispatchEvent(e);
return e;
}
describe('InteractionDispatcher', () => {
let canvas: HTMLCanvasElement;
afterEach(() => {
canvas?.remove();
});
function setup(tool?: ToolExtensionV2) {
canvas = document.createElement('canvas');
document.body.appendChild(canvas);
const dispatcher = new InteractionDispatcher(canvas, {
zoomPan: makeFakeZoomPan(),
toolLookup: tool ? () => tool : undefined,
});
return { dispatcher };
}
it('routet down→move→up in Reihenfolge an das aktive Tool', () => {
const { tool, log } = makeRecordingTool();
const { dispatcher } = setup(tool);
dispatcher.setActive('fake');
fire(canvas, 'pointerdown');
fire(window, 'pointermove');
fire(window, 'pointerup');
expect(log).toEqual(['down', 'move', 'up']);
expect(dispatcher.getActive()?.manifest.id).toBe('fake');
});
it('ignoriert move/up ohne vorheriges down (Session-Guard)', () => {
const { tool, log } = makeRecordingTool();
const { dispatcher } = setup(tool);
dispatcher.setActive('fake');
fire(window, 'pointermove'); // keine Session aktiv
fire(window, 'pointerup');
expect(log).toEqual([]);
});
it('ESC ruft cancel und beendet die Session', () => {
const { tool, log } = makeRecordingTool();
const { dispatcher } = setup(tool);
dispatcher.setActive('fake');
fire(canvas, 'pointerdown');
fireKey('Escape');
expect(log).toEqual(['down', 'cancel']);
fire(window, 'pointermove'); // Session beendet → kein move mehr
expect(log).toEqual(['down', 'cancel']);
});
it('Rechtsklick bricht ab, ohne down durchzureichen', () => {
const { tool, log } = makeRecordingTool();
const { dispatcher } = setup(tool);
dispatcher.setActive('fake');
fire(canvas, 'pointerdown', { button: 2 });
expect(log).toEqual(['cancel']);
});
it('setActive(null) deaktiviert Routing komplett', () => {
const { tool, log } = makeRecordingTool();
const { dispatcher } = setup(tool);
dispatcher.setActive('fake');
dispatcher.setActive(null);
fire(canvas, 'pointerdown');
fire(window, 'pointermove');
expect(log).toEqual([]);
});
it('liefert Weltkoordinaten aus zoomPan.screenToWorld', () => {
let received: { x: number; y: number } | null = null;
const tool: ToolExtensionV2 = {
manifest: { id: 'geo', label: 'G', icon: 'g', ribbonTab: 't' },
optionsSchema: [],
handlers: { down: (e) => (received = { ...e.world }) },
};
const { dispatcher } = setup(tool);
dispatcher.setActive('geo');
fire(canvas, 'pointerdown', { clientX: 100, clientY: 50 });
// fake screenToWorld: x*2, y*3 → (200, 150)
expect(received).toEqual({ x: 200, y: 150 });
});
});
+56
View File
@@ -0,0 +1,56 @@
/**
* documentService Tests (Task A4.11).
* Prüft Registrierung/Löschung des aktiven Dokuments und die
* Undo/Redo-Delegation an CADDocument.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { setActiveDocument, getActiveDocument, undoActive, redoActive } from '../src/kernel/document/documentService';
import { CADDocument } from '../src/kernel/document/CADDocument';
import { createInMemoryDoc } from './helpers/inMemoryDoc';
import type { CADElement } from '../src/types/cad.types';
function makeEl(id: string): CADElement {
return {
id,
type: 'rect',
layerId: 'l',
x: 0,
y: 0,
width: 1,
height: 1,
properties: {},
} as unknown as CADElement;
}
afterEach(() => {
setActiveDocument(null);
});
describe('documentService', () => {
it('registriert und entregistriert das aktive Dokument', () => {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
setActiveDocument(doc);
expect(getActiveDocument()).toBe(doc);
setActiveDocument(null);
expect(getActiveDocument()).toBeNull();
});
it('undoActive delegiert an das registrierte Dokument', () => {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
doc.addElement(makeEl('x1'));
setActiveDocument(doc);
expect(undoActive()).toBe(true); // Element wird entfernt
expect(doc.getAllElements().length).toBe(0);
expect(redoActive()).toBe(true); // wiederhergestellt
expect(doc.getAllElements().length).toBe(1);
});
it('undoActive liefert false ohne aktives Dokument', () => {
setActiveDocument(null);
expect(undoActive()).toBe(false);
expect(redoActive()).toBe(false);
});
});
+230
View File
@@ -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');
});
});
+187
View File
@@ -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 writeparse-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);
});
});
+174
View File
@@ -0,0 +1,174 @@
/**
* elementToPrimitives Tests (Task C2).
* Deckt alle unterstützten Typen + Legacy-Konventionen + Grenzfälle ab.
*/
import { describe, it, expect } from 'vitest';
import { elementToPrimitives } from '../src/render/pixi/elementDrawers';
import type { CADElement } from '../src/types/cad.types';
function el(id: string, type: string, props: Record<string, unknown> = {}, geom: Partial<CADElement> = {}): CADElement {
return {
id,
type,
layerId: 'l',
x: 0,
y: 0,
width: 10,
height: 10,
properties: props,
...geom,
} as unknown as CADElement;
}
describe('elementToPrimitives', () => {
it('line: aus properties.x1..y2 mit stroke/sw', () => {
const prims = elementToPrimitives(el('l', 'line', {
x1: 0, y1: 0, x2: 100, y2: 50, stroke: '#f00', strokeWidth: 3,
}));
expect(prims.length).toBe(1);
expect(prims[0]).toEqual({
kind: 'line', from: { x: 0, y: 0 }, to: { x: 100, y: 50 },
stroke: '#f00', strokeWidth: 3,
});
});
it('line ohne Koordinaten ergibt leere Liste', () => {
expect(elementToPrimitives(el('l', 'line', {}))).toEqual([]);
});
it('rect: x/y ist Zentrum der BBox (Legacy-Konvention)', () => {
const prims = elementToPrimitives(el('r', 'rect', { fill: '#123456' }, {
x: 100, y: 50, width: 80, height: 40,
}));
expect(prims.length).toBe(1);
const p = prims[0];
if (p.kind !== 'rect') throw new Error('erwartete rect');
expect(p.x).toBe(60); // 100 40
expect(p.y).toBe(30); // 50 20
expect(p.w).toBe(80);
expect(p.h).toBe(40);
expect(p.fill).toBe('#123456');
});
it('rect fill none wird zu null', () => {
const prims = elementToPrimitives(el('r', 'rect', { fill: 'none' }));
const p = prims[0];
if (p.kind !== 'rect') throw new Error('erwartete rect');
expect(p.fill).toBeNull();
});
it('circle: radius mit Fallback width/2', () => {
const withExplicit = elementToPrimitives(el('c', 'circle', { radius: 25, fill: '#abcdef' }, { x: 5, y: 5 }));
const p = withExplicit[0];
if (p.kind !== 'circle') throw new Error('erwartete circle');
expect(p.radius).toBe(25);
expect(p.center).toEqual({ x: 5, y: 5 });
expect(p.fill).toBe('#abcdef');
const byWidth = elementToPrimitives(el('c', 'circle', {}, { width: 120 }));
const pb = byWidth[0];
if (pb.kind !== 'circle') throw new Error('erwartete circle');
expect(pb.radius).toBe(60); // fallback
});
it('arc: Winkel in Grad wie Legacy', () => {
const prims = elementToPrimitives(el('a', 'arc', {
radius: 40, startAngle: 0, endAngle: 90,
}, { x: 0, y: 0 }));
const p = prims[0];
if (p.kind !== 'arc') throw new Error('erwartete arc');
expect(p.startDeg).toBe(0);
expect(p.endDeg).toBe(90);
expect(p.radius).toBe(40);
});
it('polyline/polygon: points-Array, close je nach Typ', () => {
const pts = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }];
const open = elementToPrimitives(el('pl', 'polyline', { points: pts }));
const closed = elementToPrimitives(el('pg', 'polygon', { points: pts }));
expect(open.length).toBe(1);
expect(closed.length).toBe(1);
if (open[0].kind !== 'polyline' || closed[0].kind !== 'polyline') {
throw new Error('erwartete polyline');
}
expect(open[0].close).toBe(false);
expect(closed[0].close).toBe(true);
});
it('text: leer nicht gerendert; gefuellt mit fontSize/color', () => {
expect(elementToPrimitives(el('t', 'text', { text: '' }))).toEqual([]);
const filled = elementToPrimitives(el('t', 'text', {
text: 'Bühne A', fontSize: 18, color: '#ffffff',
}, { x: 50, y: 75 }));
expect(filled.length).toBe(1);
const p = filled[0];
if (p.kind !== 'text') throw new Error('erwartete text');
expect(p.text).toBe('Bühne A');
expect(p.fontSize).toBe(18);
expect(p.color).toBe('#ffffff');
});
it('unbekannter Typ ergibt leere Liste (renderer-sicher)', () => {
expect(elementToPrimitives(el('x', 'definitely-unknown'))).toEqual([]);
});
it('dimension: Linie + Label aus value/text', () => {
const prims = elementToPrimitives(el('d', 'dimension', {
x1: 0, y1: 0, x2: 100, y2: 0, value: '250 cm',
}, { x: 50, y: 0 }));
expect(prims.length).toBe(2);
expect(prims[0].kind).toBe('line');
const txt = prims[1];
if (txt.kind !== 'text') throw new Error('erwartete text');
expect(txt.text).toBe('250 cm');
});
it('leader: Linie + Label am Ende', () => {
const prims = elementToPrimitives(el('ld', 'leader', {
x1: 10, y1: 10, x2: 80, y2: 60, text: 'A1', fontSize: 14,
}));
expect(prims.length).toBe(2);
expect(prims[0].kind).toBe('line');
const txt = prims[1];
if (txt.kind !== 'text') throw new Error('erwartete text');
expect(txt.text).toBe('A1');
expect(txt.fontSize).toBe(14);
});
it('revcloud: geschlossener Punktzug', () => {
const prims = elementToPrimitives(el('rc', 'revcloud', {
points: [{ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 50, y: 30 }],
}));
expect(prims.length).toBe(1);
const p = prims[0];
if (p.kind !== 'polyline') throw new Error('erwartete polyline');
expect(p.close).toBe(true);
});
it('chair: gefuelltes Rechteck mit Fallback-Farbe', () => {
const prims = elementToPrimitives(el('ch', 'chair', {}, { x: 50, y: 50, width: 20, height: 20 }));
const p = prims[0];
if (p.kind !== 'rect') throw new Error('erwartete rect');
expect(p.fill).toBe('#4a90d9');
expect(p.x).toBe(40);
expect(p.y).toBe(40);
});
it('table: Kreis mit Fallback-Farbe und Radius aus width', () => {
const prims = elementToPrimitives(el('tb', 'table', {}, { x: 0, y: 0, width: 60, height: 60 }));
const p = prims[0];
if (p.kind !== 'circle') throw new Error('erwartete circle');
expect(p.radius).toBe(30);
expect(p.fill).toBe('#8b7355');
});
it('stage: dunkles Rechteck', () => {
const prims = elementToPrimitives(el('st', 'stage', {}, { x: 0, y: 0, width: 200, height: 100 }));
const p = prims[0];
if (p.kind !== 'rect') throw new Error('erwartete rect');
expect(p.fill).toBe('#3a3a4a');
expect(p.w).toBe(200);
expect(p.h).toBe(100);
});
});
+137
View File
@@ -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');
});
});
+106
View File
@@ -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);
});
});
+90
View File
@@ -10,6 +10,9 @@ import {
offsetElement,
getElementBBox,
distance,
lineCircleIntersections,
circleCircleIntersections,
lineArcIntersection,
angleBetween,
} from '../src/tools/modification/geometry';
import type { CADElement } from '../src/types/cad.types';
@@ -293,3 +296,90 @@ describe('geometry angleBetween', () => {
expect(angleBetween({ x: 0, y: 0 }, { x: 0, y: 100 })).toBe(90);
});
});
describe('geometry lineCircleIntersections', () => {
const p1 = { x: 0, y: 0 };
const p2 = { x: 200, y: 0 };
const c = { x: 100, y: 0 };
it('findet zwei Schnittpunkte bei Sekante', () => {
const pts = lineCircleIntersections(p1, p2, c, 40);
expect(pts.length).toBe(2);
const xs = pts.map((p) => Math.round(p.x)).sort((a, b) => a - b);
expect(xs).toEqual([60, 140]);
});
it('findet genau einen Punkt bei Tangente', () => {
// Strecke entlang y=40 tangiert Kreis (100,0,r=40)
const pts = lineCircleIntersections({ x: 50, y: 40 }, { x: 150, y: 40 }, { x: 100, y: 0 }, 40);
expect(pts.length).toBe(1);
expect(pts[0].x).toBeCloseTo(100);
expect(pts[0].y).toBeCloseTo(40);
});
it('liefert leeres Array ohne Schnitt (Diskriminante < 0)', () => {
expect(lineCircleIntersections(p1, p2, { x: 100, y: 500 }, 10)).toEqual([]);
});
it('respektiert Segmentgrenzen t∈[0,1] (Verlaengerung schneidet nicht)', () => {
// Kreis liegt hinter dem Endpunkt der Strecke
expect(lineCircleIntersections({ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 100, y: 0 }, 30)).toEqual([]);
});
});
describe('geometry circleCircleIntersections', () => {
it('findet zwei Punkte bei Ueberlappung', () => {
const pts = circleCircleIntersections({ x: 0, y: 0 }, 50, { x: 80, y: 0 }, 50);
expect(pts.length).toBe(2);
for (const pt of pts) {
expect(pt.x).toBeCloseTo(40); // Symmetrieachse
expect(Math.abs(pt.y)).toBeCloseTo(30); // Höhe via Satz des Pythagoras
}
});
it('liefert einen Punkt bei Tangenz', () => {
const pts = circleCircleIntersections({ x: 0, y: 0 }, 50, { x: 100, y: 0 }, 50);
expect(pts.length).toBe(1);
expect(pts[0]).toEqual({ x: 50, y: 0 });
});
it('liefert leer bei getrennten Kreisen und identischen Zentren', () => {
expect(circleCircleIntersections({ x: 0, y: 0 }, 20, { x: 500, y: 0 }, 20)).toEqual([]);
expect(circleCircleIntersections({ x: 0, y: 0 }, 20, { x: 0, y: 0 }, 20)).toEqual([]);
});
});
describe('geometry lineArcIntersection', () => {
function makeArc(id: string, cx: number, cy: number, r: number, startDeg: number, endDeg: number): CADElement {
return {
id,
type: 'arc',
layerId: 'layer-1',
x: cx,
y: cy,
width: r * 2,
height: r * 2,
properties: { radius: r, startAngle: startDeg, endAngle: endDeg },
} as unknown as CADElement;
}
it('schneidet Strecke mit Halbkreis (0°..180°)', () => {
const arc = makeArc('a1', 100, 0, 40, 0, 180); // obere Hälfte (CCW von 3Uhr)
const pts = lineArcIntersection({ x: 60, y: 0 }, { x: 140, y: 40 }, arc);
// Linie von (60,0) nach (140,40): schneidet Bogen einmal oben
expect(pts.length).toBeGreaterThanOrEqual(1);
});
it('filtert Treffer außerhalb des Winkelbereichs heraus', () => {
const arc = makeArc('a2', 100, 0, 40, 180, 360); // untere Hälfte
// Strecke verläuft komplett oberhalb der Mittellinie → Schnitte lägen nur
// im oberen Halbkreis (Winkel < 180°), der nicht Teil des Bogens ist.
const pts = lineArcIntersection({ x: 60, y: 20 }, { x: 140, y: 40 }, arc);
expect(pts).toEqual([]);
});
it('liefert leer bei fehlendem Radius (ungueltiges Element)', () => {
const bad = { id: 'x', type: 'arc', layerId: 'l', x: 0, y: 0, width: 0, height: 0, properties: {} } as unknown as CADElement;
expect(lineArcIntersection({ x: -10, y: -10 }, { x: 10, y: 10 }, bad)).toEqual([]);
});
});
+244
View File
@@ -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);
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Smoke-Tests für die Test-Helfer aus Task A0.
* Stellt sicher, dass Logger, MockRenderEngine und InMemoryDoc funktionieren,
* BEVOR spätere Tasks auf ihnen aufbauen.
*/
import { describe, it, expect, vi } from 'vitest';
import { logger, setLogLevel } from '../src/utils/logger';
import { MockRenderEngine } from './helpers/mockAdapter';
import { createInMemoryDoc, makeTestLayer } from './helpers/inMemoryDoc';
import type { CADElement } from '../src/types/cad.types';
describe('A0 helpers logger', () => {
it('schreibt info im Default-Level in die Testumgebung', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
setLogLevel('info');
logger.info('smoke test message');
expect(spy).toHaveBeenCalledWith('[web-cad]', 'smoke test message');
spy.mockRestore();
});
it('debug bleibt bei Level info still und erscheint bei debug', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
setLogLevel('info');
logger.debug('hidden');
expect(spy).not.toHaveBeenCalled();
setLogLevel('debug');
logger.debug('visible');
expect(spy).toHaveBeenCalledWith('[web-cad][debug]', 'visible');
spy.mockRestore();
});
});
describe('A0 helpers MockRenderEngine', () => {
it('protokolliert Aufrufe mit Reihenfolge und Argumenten', () => {
const mock = new MockRenderEngine();
mock.render();
mock.hitTest(10, 20, 5);
mock.setPreviewElement(null);
expect(mock.countCalls('render')).toBe(1);
expect(mock.countCalls('hitTest')).toBe(1);
expect(mock.lastArgsOf('hitTest')).toEqual([10, 20, 5]);
expect(mock.calls.map((c) => c.method)).toEqual(['render', 'hitTest', 'setPreviewElement']);
});
it('liefert konfigurierbare hitTest-Ergebnisse', () => {
const mock = new MockRenderEngine();
const el = { id: 'e1', type: 'line' } as unknown as CADElement;
mock.nextHitTestResult = el;
expect(mock.hitTest(0, 0)).toBe(el);
mock.reset();
expect(mock.hitTest(0, 0)).toBeNull();
});
});
describe('A0 helpers createInMemoryDoc', () => {
it('erstellt netzwerkfreies Dokument mit Seeding', () => {
const { ydoc, addAll } = createInMemoryDoc();
ydoc.data.layers.set('layer-1', makeTestLayer() as never);
const el = { id: 'el1', type: 'rect', layerId: 'layer-1', x: 0, y: 0, width: 1, height: 1, properties: {} } as unknown as CADElement;
addAll([el]);
expect(ydoc.getElements().length).toBe(1);
expect(ydoc.getLayers().length).toBe(1);
});
it('clear entfernt alle Elemente', () => {
const { ydoc, addAll, clear } = createInMemoryDoc();
const el = { id: 'el1', type: 'rect', layerId: 'layer-1', x: 0, y: 0, width: 1, height: 1, properties: {} } as unknown as CADElement;
addAll([el]);
expect(ydoc.getElements().length).toBe(1);
clear();
expect(ydoc.getElements().length).toBe(0);
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* InMemoryDoc-Helper Netzwerkfreie YjsDocument-Fabrik für Tests.
*
* YjsDocument läuft bereits komplett In-Memory (Y.Doc ohne Provider);
* dieser Wrapper bietet bequemes Seeding und Reset für isolierte Tests.
* Genutzt ab A3 (Tool-Workflow-Tests) und A2b (CADDocument-Tests).
*/
import { YjsDocument } from '../../src/crdt/YjsDocument';
import type { CADElement } from '../../src/types/cad.types';
export interface SeededDoc {
ydoc: YjsDocument;
/** Fügt Elemente in EINER Transaktion hinzu (1 Undo-Schritt). */
addAll(elements: CADElement[]): void;
/** Entfernt alle Elemente/Layer-Inhalte (Dokument bleibt verwendbar). */
clear(): void;
}
/** Erzeugt ein frisches, leeres Test-Dokument. */
export function createInMemoryDoc(): SeededDoc {
const ydoc = new YjsDocument();
return {
ydoc,
addAll(elements) {
ydoc.doc.transact(() => {
for (const el of elements) ydoc.data.elements.set(el.id, el);
});
},
clear() {
ydoc.doc.transact(() => {
for (const id of Array.from(ydoc.data.elements.keys())) {
ydoc.data.elements.delete(id);
}
});
},
};
}
/** Basis-CADLayer für Tests. */
export function makeTestLayer(id = 'layer-1', name = 'Test-Layer') {
return {
id,
name,
visible: true,
locked: false,
color: '#ffffff',
lineType: 'solid',
transparency: 0,
sortOrder: 0,
parentId: null,
};
}
+69
View File
@@ -0,0 +1,69 @@
/**
* MockAdapter Headless-Doppel der RenderEngine für Workflow-Tests.
* Zeichnet nichts; protokolliert alle Aufrufe in `calls` (Reihenfolge getreu).
*
* Methodenumfang = genau das, was InteractionEngine derzeit an RenderEngine
* ruft (verifiziert in Task A0): hitTest, render, setActiveSnapPoint,
* setOptions, setPreviewElement, setSnapPoints.
* Wird in C1 (RenderAdapter-Interface) um die vollen Adapter-Methoden erweitert.
*/
import type { CADElement } from '../../src/types/cad.types';
export interface RecordedCall {
method: string;
args: unknown[];
}
export class MockRenderEngine {
/** Alle Aufrufe in Reihenfolge. */
readonly calls: RecordedCall[] = [];
/** Von Tests konfigurierbare hitTest-Rückgabe. */
nextHitTestResult: CADElement | null = null;
// ── Von InteractionEngine genutzte Fläche ────────────────
hitTest(x: number, y: number, tolerance?: number): CADElement | null {
this.calls.push({ method: 'hitTest', args: [x, y, tolerance] });
return this.nextHitTestResult;
}
render(): void {
this.calls.push({ method: 'render', args: [] });
}
setActiveSnapPoint(pt: unknown): void {
this.calls.push({ method: 'setActiveSnapPoint', args: [pt] });
}
setOptions(opts: Record<string, unknown>): void {
this.calls.push({ method: 'setOptions', args: [opts] });
}
setPreviewElement(el: CADElement | null): void {
this.calls.push({ method: 'setPreviewElement', args: [el] });
}
setSnapPoints(pts: unknown[]): void {
this.calls.push({ method: 'setSnapPoints', args: [pts] });
}
// ── Test-Helfer ──────────────────────────────────────────
/** Anzahl Aufrufe einer Methode. */
countCalls(method: string): number {
return this.calls.filter((c) => c.method === method).length;
}
/** Letzte aufgezeichnete Argumentliste einer Methode (oder undefined). */
lastArgsOf(method: string): unknown[] | undefined {
const found = this.calls.filter((c) => c.method === method);
return found.length ? found[found.length - 1].args : undefined;
}
/** Protokoll leeren. */
reset(): void {
this.calls.length = 0;
this.nextHitTestResult = null;
}
}
+93
View File
@@ -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();
});
});

Some files were not shown because too many files have changed in this diff Show More