Compare commits
102 Commits
7c94de1590
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 00e967531a | |||
| 94050fb371 | |||
| 170fd54b2c | |||
| 30d8cbfd00 | |||
| c244571dd6 | |||
| 2b5905506f | |||
| d5acf76d0b | |||
| 2a05257ab5 | |||
| cd5d4326db | |||
| 1d129042d4 | |||
| 180aa9b03a | |||
| f9c67a7d78 | |||
| 02ed6a5ff0 | |||
| 4e6edb51ed | |||
| 685757303f | |||
| 5aca4569ba | |||
| 0e4f9fe160 | |||
| af9f53c61f | |||
| f3a8b9e346 | |||
| 480368a5da | |||
| c4a76fb74e | |||
| 933dfcbac2 | |||
| 51d9c2f208 | |||
| f7fedec0fa | |||
| 1837917537 | |||
| 875dbd6d22 | |||
| 8141be4b80 | |||
| 47c029b4b6 | |||
| 29234ea906 | |||
| 4398c675e4 | |||
| 748ba01083 | |||
| 3ecc99a5e8 | |||
| ee4142b6d1 | |||
| cefba89ebb | |||
| 44a9aec107 | |||
| bcb0fcefe1 | |||
| 7fe6cd127e | |||
| 0ffe954559 | |||
| 7331f5fb88 | |||
| 74c47d7e77 | |||
| 64c25f2019 | |||
| 099f65414f | |||
| 89df306078 | |||
| 11a0dcb4e8 | |||
| 33f6f2d21b | |||
| 44a60fa718 | |||
| 17b62ee36a | |||
| a20731a3f5 | |||
| c6fd8ea445 | |||
| 8845afbf6f | |||
| 7eedddbc3f | |||
| a3d3732f85 | |||
| 9ca86d67dd | |||
| 7b207d8e39 | |||
| c4e3206932 | |||
| 3c806437d6 | |||
| 227d617bb1 | |||
| eeaa7806c6 | |||
| c6f38eee5b | |||
| 68b66ba190 | |||
| fbc4978a40 | |||
| b8b67ec7ec | |||
| bfd500bbc2 | |||
| 1664404948 | |||
| fa3dc7d7e1 | |||
| 40fa7891d5 | |||
| 5db421da5e | |||
| 5cfd29cf1a | |||
| b210ccc5b8 | |||
| 21e18b81d8 | |||
| e6fdb39a86 | |||
| 10eacdeaf5 | |||
| d4030400b4 | |||
| e3fde5012e | |||
| 131a922390 | |||
| 2b9a3bef16 | |||
| 8774fea773 | |||
| ed71100178 | |||
| d9a0dffb05 | |||
| a093630d34 | |||
| cb23638610 | |||
| f6b5046a60 | |||
| bbb469e25e | |||
| 1eb6a99025 | |||
| 7feed9e9e3 | |||
| d4e1b781a6 | |||
| e750a45d7a | |||
| d51111fc2c | |||
| 9176d7c9a4 | |||
| f4dbb8caaa | |||
| c6b7881bae | |||
| 09948bc587 | |||
| 9013d72c03 | |||
| d311820351 | |||
| bdf3baf4bd | |||
| 75de6dd1f7 | |||
| 3c2f015acf | |||
| 6857694f64 | |||
| a9c3ba786b | |||
| 2efbbf9bd2 | |||
| ec7cb4043b | |||
| c78d8d8334 |
@@ -15,6 +15,9 @@ jobs:
|
||||
- name: Install backend dependencies
|
||||
working-directory: backend
|
||||
run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
||||
- name: Typecheck backend
|
||||
working-directory: backend
|
||||
run: npx tsc --noEmit
|
||||
- name: Run backend tests
|
||||
working-directory: backend
|
||||
run: npm test
|
||||
@@ -30,6 +33,9 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
||||
- name: Typecheck frontend
|
||||
working-directory: frontend
|
||||
run: npx tsc --noEmit
|
||||
- name: Run frontend tests
|
||||
working-directory: frontend
|
||||
run: npm test
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## 2.0.0 (2026-08-29)
|
||||
|
||||
Vollständige Neuentwicklung der Kern-Architektur (Phasen A–H der Roadmap).
|
||||
|
||||
### Architektur
|
||||
- **Plugin-System v2 (A1–A4):** ToolExtensionV2/PanelExtension/LibraryProviderExtension-Registry mit Interaktions-Dispatcher, CADDocument-Fassade über Yjs mit echtem Y.UndoManager (1 Aktion = 1 Undo-Schritt), Undo-fähige transact-Bündelung, V2HistoryPanel. Alle 30+ Werkzeuge als V2-Plugins migriert.
|
||||
- **PixiJS-Renderer (C1–C4):** WebGL-Rendering als Default (Opt-out), inkrementelles Element-Rendering über Map-Diff, Snap-Marks + Selection-Handles im Overlay, MockAdapter für headless Tests.
|
||||
- **Kernel:** React-freies `kernel/**` (Regel), documentService-Singleton, featureFlags.
|
||||
|
||||
### CAD-Werkzeuge (D1–D12, vollständig)
|
||||
- Ellipse (3-Klick, Rotation aus Zugrichtung, Bogen via Winkel-Optionen) + Spline (CV-basiert, Catmull-Rom durch alle Kontrollpunkte)
|
||||
- Point/XLine/Ray auf Auto-angelegtem `defpoints`-Layer mit `construction:true`-Tag
|
||||
- MTEXT (mehrzeilig, optionaler Rahmen in derselben Transaktion)
|
||||
- Stretch, Break, Join, Explode, Align, Chamfer, Lengthen, PolylineEditor (verifiziert, Phase D complete)
|
||||
- GripEditing: Vertex-/Center-Grips (Linien-Endpunkte, Polyline-Vertices, Kreis-Zentrum) mit Drag in einer Transaktion; BBox-Corner-Skalierung bleibt parallel
|
||||
- Arrays: rect, polar, **path** (Kopien gleichmäßig entlang Pfadlänge)
|
||||
- Snaps erweitert: Quadrant (4 Kreis-Quadranten), Perpendicular (Fußpunkt vom Referenzpunkt), Tangent (beide Tangentenpunkte via acos), fromOffset-Helfer
|
||||
- BlockAttribute: `BlockAttrDef` auf Definitionen, `attrValues` auf Instanzen, Rendering als Text-Primitives, PropertiesPanel-Formular
|
||||
|
||||
### Event-Bestuhlung (E1–E7, vollständig)
|
||||
- Sitzreihen/-blöcke mit Drag-Interaktion, ArcRows (Stühle tangential rotiert), Vorlagenkatalog (Kino, Bankett, Stehtisch, Podest), CSV-Sitzlistent-Export, Gäste-Verwaltung mit Stuhl-Zuweisung
|
||||
- **Neu:** Vorhang (wellige Polyline), Scheinwerfer (Kegel-Polygon mit Winkel + Farbe), Absperrung (Kettenglieder), Bühnenhöhen-Attribut
|
||||
|
||||
### Bibliothek & Import/Export (F1–F8, vollständig)
|
||||
- Globale Block-Bibliothek: Ordner, Suche, Favoriten, Thumbnails, Grid-Ansicht, Drag&Drop in die Zeichenfläche
|
||||
- Export/Import als JSON (`.wcadlib`) **und** echtes ZIP-Paket (manifest.json + blocks.json + thumbs/)
|
||||
- **DXF-Import erweitert:** ELLIPSE, SPLINE (Grad ≤ 3, de-Boor-Approximation), MTEXT, POINT, ATTDEF, MINSERT-Arrays, strukturierter skippedEntities-Fehlerreport
|
||||
- **DXF-Export erweitert:** BLOCKS+INSERTS mit Namens-Sanitizing, ATTDEF/ATTRIB/SEQEND, AC1009 (R12) + AC1015, R12-korrektes POLYLINE+VERTEX+SEQEND; Roundtrip-Tests deckten und fixten 3 latente Konventions-Bugs (Circle-Zentrum in Writer und Parser, ARC-Winkel-Radian→Grad)
|
||||
- **SVG-Import als echte Geometrie:** voller Path-Parser (M/L/H/V/C/S/Q/T/A/Z, absolut+relativ, Bézier-Flattening, Arc-Endpoint→Center), rect/circle-Konventions-Fixes, Ellipse als Polygon
|
||||
- Builtin-Kataloge: Event/Architektur/Landschaft (je 15 Blöcke) über LibraryProvider, User- und Global-Library angebunden
|
||||
|
||||
### Layouts & Plot (G1–G5, vollständig)
|
||||
- Layout-Datenmodell in Yjs (undo-getrackt), LayoutBar mit Viewport-Minimap, zoom-to
|
||||
- Titelblock mit attributierten Texten (Projekt/Datum/Maßstab/Autor), auto-insert als Layout-Frame
|
||||
- **PDF-Export je Layout:** A4 landscape im Ausgabemaßstab, Linienbreiten + Dash-Patterns aus Layern, monochrome-Option, Titelblock-Mitzeichnung
|
||||
- **Browser-Print:** @media print mit A4 landscape, UI-Chrome ausgeblendet, Print-Service mit Cleanup
|
||||
|
||||
### Modi & Rollen (H1–H5, vollständig)
|
||||
- simple/pro-Modus (localStorage-persistiert): simple zeigt nur basic-Tools, CommandLine pro-only, Topbar-Toggle
|
||||
- Projekt-Templates: 6 Branchen-Presets (Halle/OpenAir/Saal/Messe/Büro/Garten) mit Grid/Layern/Plugin-Seed
|
||||
- **Rollenschutz:** library-admin (an admin-Rolle gebunden) für alle 8 schreibenden Global-Library-Routen; Lesen/Export für alle authentifizierten Nutzer
|
||||
- i18n-Grundlage: strings.ts de/en-Maps mit t()-Funktion, Persistenz, useT-Hook; LayoutBar + Modus umgestellt
|
||||
- Flächenberechnung: Shoelace-polygonArea, formatArea (quadratische Skalierung), measure-area-Tool, PropertiesPanel-Anzeige
|
||||
|
||||
### Behobene Fehlerklassen
|
||||
- **BUG-1-Klasse komplett geschlossen:** alle Date.now()-Auto-IDs im Backend durch UUID-Slice ersetzt (deterministische Tests mit eingefrorener Zeit beweisen die Kollisionsfreiheit)
|
||||
- Circle-Center-Konvention in DXF-Writer, DXF-Parser und SVG-Import vereinheitlicht (Zentrum statt min-corner)
|
||||
- ARC-Winkel-Konvertierung dxf-parser (intern Radiant) → App (Grad) gefixt
|
||||
|
||||
### Tests
|
||||
- **Frontend: 697 Tests** (alle grün), **Backend: 287 Tests** (alle grün)
|
||||
- Volle tsc-Typechecks und Production-Builds grün bei jedem Task
|
||||
@@ -1,21 +1,74 @@
|
||||
# Web CAD Neu
|
||||
# WebCAD
|
||||
|
||||
Web-basiertes 2D-CAD für Event-Bestuhlungspläne.
|
||||
Web-basiertes 2D-CAD mit Kollaboration, Plugin-System und Fokus auf
|
||||
Veranstaltungsplanung (Bestuhlung, Bühnen, Layouts).
|
||||
|
||||
## Features
|
||||
|
||||
- **Zeichnen:** Linien, Rechtecke, Kreise, Bögen, Ellipsen, Splines, Polylinien, Text, MTEXT, Bemaßungen, Hatch
|
||||
- **Modifizieren:** Move/Copy/Rotate/Scale/Mirror, Trim/Extend/Fillet/Chamfer, Stretch/Break/Join/Explode/Align/Lengthen, Polyline-Editor, Grips (Vertex/Center), Arrays (rect/polar/path)
|
||||
- **Event-Tools:** Stühle, Sitzreihen (Drag), Sitzblöcke, ArcRows, Tische, Bühnen (mit Höhen-Attribut), Vorhang, Scheinwerfer, Absperrungen, Vorlagenkatalog, Gäste-Verwaltung mit Sitzplatz-Zuweisung, CSV-Sitzlistent-Export
|
||||
- **Snaps:** Endpoint, Midpoint, Center, Intersection, Quadrant, Perpendicular, Tangent, Grid, Polar-Tracking
|
||||
- **Bibliothek:** Globale Block-Bibliothek mit Ordnern, Suche, Favoriten, Thumbnails; Builtin-Kataloge (Event/Architektur/Landschaft); Export/Import als .wcadlib (JSON oder ZIP)
|
||||
- **Import/Export:** DXF (Import mit ELLIPSE/SPLINE/MTEXT/POINT/ATTDEF + Fehlerreport; Export R12/2000 mit BLOCKS/ATTR), SVG (echte Geometrie-Konvertierung mit Path-Parser), PDF (je Layout, maßstabsgetreu), PNG, JSON
|
||||
- **Layouts & Plot:** Layout-Verwaltung mit Viewport-Minimap, Titelblock mit Attributen, PDF-Export, Browser-Print
|
||||
- **Kollaboration:** Yjs CRDT, kollaborativ korrektes Undo/Redo (nur eigene Operationen)
|
||||
- **Modi:** simple (Grundwerkzeuge) / pro (Vollpalette), localStorage-persistiert
|
||||
- **Templates:** 6 Branchen-Presets (Halle, OpenAir, Saal, Messe, Büro, Garten)
|
||||
- **i18n:** de/en (Grundlage, erweiterbar über `src/i18n/strings.ts`)
|
||||
- **Rollen:** admin (library-admin, globaler Schreibzugriff) / planer (lesen + eigene Zeichnungen)
|
||||
|
||||
## Stack
|
||||
- Frontend: React + TypeScript + Vite + HTML Canvas
|
||||
- Backend: Node.js + TypeScript + Fastify + SQLite
|
||||
- Collaboration: Yjs CRDT
|
||||
|
||||
- **Frontend:** React 18 + TypeScript + Vite + PixiJS v8 (WebGL-Renderer, Canvas2D-Fallback) + zustandsfreier Service-Layer
|
||||
- **Backend:** Node.js + TypeScript + Fastify 5 + SQLite (better-sqlite3)
|
||||
- **Kollaboration:** Yjs + y-websocket
|
||||
- **Tests:** Vitest (697 FE + 287 BE), Playwright E2E, tsc-Strict-Mode
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
# Backend
|
||||
cd backend && npm install && npm run dev # http://localhost:3001
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm install && npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
cd frontend && npm test # 697 Vitest-Tests
|
||||
cd backend && npm test # 287 Vitest-Tests
|
||||
|
||||
cd frontend && npx tsc --noEmit # Typecheck
|
||||
cd frontend && npm run build # Production-Build
|
||||
```
|
||||
- Frontend: http://localhost:5173
|
||||
- Backend: http://localhost:3001
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
## Plugin-Entwicklung
|
||||
|
||||
Siehe [docs/PLUGIN_GUIDE.md](docs/PLUGIN_GUIDE.md) – Plugin-API v2 mit
|
||||
Werkzeugen, Panels und Bibliotheks-Providern, inkl. Minimal-Beispiel.
|
||||
|
||||
## Bibliotheks-Pakete (.wcadlib)
|
||||
|
||||
Siehe [docs/LIBRARY_SPEC.md](docs/LIBRARY_SPEC.md) – JSON- und ZIP-Format,
|
||||
Validierungsregeln, Endpoints und CAD-Element-Konventionen.
|
||||
|
||||
## Architektur-Regeln
|
||||
|
||||
- `kernel/**` importiert niemals React
|
||||
- `render/pixi/**` kennt keine React-Components
|
||||
- React kennt den Renderer nur über CanvasArea
|
||||
- Eine Werkzeug-Aktion = eine `doc.transact()` = ein Undo-Schritt
|
||||
|
||||
## Roadmap & Status
|
||||
|
||||
Alle funktionalen Phasen (A–H) sind abgeschlossen. Aktueller Stand und
|
||||
Task-Log: [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
+155
-40
@@ -1,11 +1,62 @@
|
||||
# web-cad v2.0 — ROADMAP & ARBEITSANWEISUNGEN
|
||||
# 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
|
||||
> **Dies ist die Steuerdatei.** Status, Architektur, Regeln, Arbeitsaufträge.
|
||||
> Bei jedem Fortschritt: Task-Status aktualisieren + Log-Eintrag in Teil 7.
|
||||
> Hintergrund: [umbauplan-v2.md](./umbauplan-v2.md) · [BAUPLAN.md](./BAUPLAN.md)
|
||||
|
||||
---
|
||||
|
||||
# ÜBERGABE-STAND (Stand: 2026-09-15)
|
||||
|
||||
**Wo steht das Projekt?**
|
||||
|
||||
- **Release 2.0.0 LIVE:** https://web-cad-neu.server.media-on.de (Coolify/Hetzner, Backend healthy, Frontend 200)
|
||||
- **Frontend:** 866/866 Tests grün, tsc Exit 0, Build Exit 0
|
||||
- **Backend:** 287/287 Tests grün, tsc Exit 0
|
||||
- **Prod-Daten:** 7 Nutzer, 5 Projekte, 511 Elemente
|
||||
- **Tag:** v2.0.0 auf Forgejo main
|
||||
|
||||
**Alles fertig (A-I + CAD-1 bis CAD-11):**
|
||||
|
||||
- **A:** Plugin-System v2 (25+ Tools, Yjs-UndoManager, CADDocument-Fassade)
|
||||
- **B:** Bug-Fixes (BUG-1-Kollisionsklasse geschlossen)
|
||||
- **C:** PixiJS v8 WebGL-Renderer (Default), Snap-Marks, Selection-Handles
|
||||
- **D:** Alle 12 CAD-Werkzeuge (Ellipse, Spline, Point/XLine/Ray, MTEXT, GripEditing, Arrays, Snaps, BlockAttribute)
|
||||
- **E:** Event-Tools komplett (Bestuhlung, Traversen magnetisch, Ecken variabel, Kreis-Traversen, BOM)
|
||||
- **F:** Bibliothek + DXF/SVG/.wcadlib Import/Export, 3 Builtin-Kataloge
|
||||
- **G:** Layouts + PDF-Export + Browser-Print
|
||||
- **H:** simple/pro-Modus, 6 Templates, Rollen-Guard, i18n de/en, Flächenberechnung
|
||||
- **I:** CI mit Performance-Gate, Docs, v2.0.0 live
|
||||
- **CAD-1 bis CAD-11:** Bemaßung (DimStyles, 5 Typen), Hatch-Muster (9), Box-Select, OTRACK, MLINE, Block-Editor, Hintergrund-Fix, Traversen-System komplett (magnetisch, chordCount, variable Winkel, Kreis), Stückliste/BOM mit CSV, Ribbon-Kuratisierung (4 Tabs)
|
||||
|
||||
**Architektur-Regeln (MUSS eine neue KI kennen):**
|
||||
|
||||
1. `kernel/**` importiert niemals React
|
||||
2. `render/pixi/**` kennt keine React-Components
|
||||
3. Eine Werkzeug-Aktion = eine `doc.transact()` = ein Undo-Schritt
|
||||
4. Session-State als Modul-Member (nicht in options)
|
||||
5. Koordinaten: rect x/y = Zentrum, circle el.x/el.y = Zentrum, Winkel in Grad (0° = 3 Uhr, CCW)
|
||||
6. TDD: Tests zuerst rot, dann implementieren, dann grün. Suite MUSS vor jedem Commit grün sein.
|
||||
7. Commit-Format: `task(<ID>): <beschreibung>` + Roadmap-Log-Eintrag
|
||||
|
||||
**Offene Aufgaben:**
|
||||
|
||||
### MEILENSTEIN 1: Produktive Basis (CAD-12 — NÄCHSTER TASK)
|
||||
- [x] CAD-12a: Autosave (CRDT-Sync alle 30s) *(2026-09-16; Log CAD-12)*
|
||||
- [x] CAD-12b: Keyboard-Shortcuts (ESC, Delete, Strg+A/D, F7/F8/F3) *(2026-09-16; Log CAD-12)*
|
||||
- [x] CAD-12c: Visueller UI-Audit (Playwright-Screenshots, Vision-Modell) *(2026-09-16; Log CAD-12c; Rest: Ribbon-Kompaktlayout)*
|
||||
|
||||
### MEILENSTEIN 2: Alltagstauglichkeit (CAD-13)
|
||||
- [ ] CAD-13a: OTRACK in Interaktions-Engine integrieren
|
||||
- [ ] CAD-13b: Properties-Panel für Event-Elemente
|
||||
- [ ] CAD-13c: Performance-Benchmark 5000+ Elemente
|
||||
- [ ] CAD-13d: DXF-Import mit echten AutoCAD-Dateien testen
|
||||
|
||||
### MEILENSTEIN 3: Ernsthaftes CAD (CAD-14+)
|
||||
- [ ] CAD-14a: Undo-Redo über alle App-Aktionen
|
||||
- [ ] CAD-14b: Druckvorschau + Plot-Styles
|
||||
- [ ] CAD-14c: Multi-Projekt-Verwaltung
|
||||
- [ ] CAD-14d: Optional: 2.5D-Ansicht (Höhen für Rigging-Pläne)
|
||||
|
||||
---
|
||||
|
||||
@@ -597,19 +648,19 @@ export interface RenderAdapter {
|
||||
|
||||
## Phase D — Werkzeugpalette komplett (alles V2-Plugins)
|
||||
Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Workflowtest + Commit `task(D*)`):
|
||||
- [ ] D1 Ellipse+Bogen (core-drawing) — param: cx,cy,rx,ry,rotation; Interaktion: Center→RadiusX→RadiusY (3 Klicks oder Optionsfelder)
|
||||
- [ ] D2 Spline (CV-basierte Bézier, Speicherung Kontrollpunkte, Render als interpolierte Polyline mit Glättung)
|
||||
- [ ] D3 Point, XLine, Ray (construction-Layer-Tag `properties.construction:true`, Standard-Layer 'defpoints' auto-anlegen)
|
||||
- [ ] D4 MTEXT (Mehrzeilig,
|
||||
- [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)
|
||||
- [ ] D5 Stretch (Fenster selektiert Endpunkte; verschiebt nur getroffene Vertices)
|
||||
- [ ] D6 Break/Join (Break: Element an Punkt splitten; Join: kollineare lines→polyline)
|
||||
- [ ] D7 Chamfer, Explode, Align, Lengthen
|
||||
- [ ] D8 PolylineEditor (Vertex add/del, segment→arc glätten)
|
||||
- [ ] D9 GripEditing (select-Tool erweitern: Handles an Endpunkten/Centern, drag=modify in Transaktion)
|
||||
- [ ] D10 Arrays: array-rect (rows,cols,dx,dy), array-polar (cx,count,angleSum,rotate), array-path (entlang polyline/arc) — JEDES als Dialog+Vorschau; Tests mit Zähl-Assertions
|
||||
- [ ] D11 Snaps erweitern (SnapEngine + Optionen): tangent, perpendicular, quadrant, center, from-offset (Basispunkt+Eingabefeld dx/dy)
|
||||
- [ ] D12 BlockAttribute: BlockDef.attrDefs[], Instanz attrValues; PropertiesPanel rendert Formular; RenderAdapter zeichnet Attr-Texte; (DXF ATTDEF/ATTR erst F)
|
||||
- [x] 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)
|
||||
@@ -618,36 +669,36 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
|
||||
- [ ] 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
|
||||
- [ ] E7 Event-Elemente polish: stage(height attr), curtain, spotlight(angle/color), barrier chain
|
||||
- [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)*
|
||||
- [ ] F4 LibraryProviderExtension anbinden: builtin-catalog JSONs (event/architecture/landscape Startersets je ~15-30 Blöcke), user-library, global-library
|
||||
- [ ] F5 DXF Parser erweitern: ELLIPSE, SPLINE(grad≤3→Polyline approx), MTEXT, POINT, HATCH(basic solid/lines), ATTDEF/ATTR, INSERT→Blockinstanz; Fehlerreport (Liste skipped entities)
|
||||
- [ ] F6 DXF Writer erweitern: BLOCKS+INSERTS, ATTR; Version AC1009 (R12) + AC1015 Ziel
|
||||
- [ ] F7 .wcadlib Paket (zip: manifest.json, blocks.json, thumbs/) Import+Export (Backend endpoint + UI Dialog)
|
||||
- [ ] F8 SVG-Import als Block (svg→Path→Polyline approximation, simple subset: path/rect/circle/ellipse/line/polygon)
|
||||
- [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
|
||||
- [ ] G1 Datenmodell layouts (Yjs Map: layoutId→{name, viewport{center,scale}, frameBlockRef}), Backend persistence folgt automatisch via drawing json
|
||||
- [ ] G2 LayoutBar UI + Viewport-Widget (zeigt Modellbereich skaliert, klickbar zoom-to)
|
||||
- [ ] G3 Titelblock-Block mit Attributen (Projekt/Datum/Maßstab/Autor) auto-insert
|
||||
- [ ] G4 PDF-Export je Layout im Ausgabemaßstab (pdf-lib; Linienbreiten aus Layern; monochrome Option)
|
||||
- [ ] G5 Browser-Print stylesheet (@media print: nur Layout)
|
||||
- [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
|
||||
- [ ] H1 simple/pro Mode (uiStore.mode, Ribbon-Filter tags basic, CommandLine hide, Settings persist)
|
||||
- [ ] H2 Projekt-Templates (Halle/OpenAir/Saal/Messe/Buero/Garten): JSON Presets (grid unit, layers, enabled plugins, library folders seed)
|
||||
- [ ] H3 Rollen: library-admin (global write) vs user (own folders) — an bestehendes auth anbinden, Backend guard
|
||||
- [ ] H4 i18n Grundlage: strings.ts zentrale Maps de/en, UI-Texte ersetzen (mechanisch, Teilautomatisierbar)
|
||||
- [ ] H5 Flächenberechnung Polygon (m²) Info im PropertiesPanel + measure-area Tool
|
||||
- [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
|
||||
- [ ] I1 CI: forgejo workflow erweitern um performance gate (playwright bench: 10k interaktionsbudget), typecheck, beide Suites
|
||||
- [ ] I2 Docs: PLUGIN_GUIDE.md (API v2 + Beispielplugin), LIBRARY_SPEC.md (.wcadlib), CHANGELOG 2.0, README rewrite
|
||||
- [ ] I3 Release 2.0 tag + docker images + coolify deploy verify
|
||||
- [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)*
|
||||
- [x] I3 Release 2.0 tag + docker images + coolify deploy verify *(2026-08-29: v2.0.0-Tag gesetzt + gepusht; Docker-Images auf coolify-01 aus a20731a gebaut; Coolify-Container recreated, Backend-Health ok, Live-URL 200; Log I3)*
|
||||
|
||||
---
|
||||
|
||||
@@ -667,6 +718,54 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
|
||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-18 | AUDIT | KOMPLETTER PROGRAMM-AUDIT (Nutzer: "komplett fehlerfrei bekommen"): (1) BASELINE GRÜN: FE tsc 0, FE 903/903 Tests, FE Build 0, BE tsc 0, BE 286/286 Tests; (2) DB-INTEGRITÄT (Prod-SQLite): 0 Orphan-Elemente, 0 Orphan-Layer, 0 Duplikat-IDs; 34 Elemente mit leerem properties_json identifiziert = ALLE alte rect-Testrelikte vom 01.-04.07. (vor dem 2.0-Umbau, harmlos); Prod-Bestand: 568 Elemente, 18 Projekte, 20 Drawings, 7 Nutzer; (3) LIVE-API-SMOKE: 9 Kern-Endpunkte (login, auth/me, projects, project, drawings, elements, layers, blocks, project-folders) alle 200 OK; Login-Token-Feld war session.token (nicht token) — Smoke korrigiert; (4) E2E-WORKFLOW mit Fehler-Monitoring: 9/9 Schritte OK (Login, Projekt, Linie-Drag, Rechteck-Drag, V2-Select, Undo, Delete, F7+ZoomFit, Screenshot), Auswahl verifiziert via __v2GetSelection; FINDINGS + FIXES: Registry-Doppel-Registrierung (main.tsx init VOR Render als Single-Source, App-Effect idempotent), select-Warning beim Start (gleiche Timing-Ursache, mitbehandelt), DELETE-REQ-FAIL — CDP-Diagnose bewies: REQ — 204 — ERR_ABORTED NACH erfolgreicher Antwort = kosmetisches Chromium-Telemetrie-Artefakt bei 204 No Content, kein Funktionsfehler (Löschung verifiziert); als Follow-up: DELETE idempotent (404-tolerant) + App->v2Doc Reconcile gegen Geister-Elemente; (5) LIVE-VERIFIZIERUNG der Workflow-Daten: CAD14-Testprojekt enthält exakt die erwarteten Elemente (Linie, Traverse mit chordCount=3, Rechteck) | Frontend: 903 Tests, tsc 0, Build 0; Backend: 286 Tests, tsc 0; DB sauber; API 200er; E2E 9/9 ohne JS-Fehler | mehrere Commits | AUDIT ABGESCHLOSSEN: alle Findings klassifiziert und behoben bzw. als Artefakt entlarvt |
|
||||
| 2026-09-18 | CAD-14 Phase 4c | LEGACY-ENGINE GELÖSCHT (1173 Zeilen physisch, nicht abgeschaltet): Vorher wurden ALLE Legacy-Funktionen in den Dispatcher übertragen: (1) Snap-Nutzung aus der bestehenden SnapEngine als Dispatcher-Deps (setSnapPoints/setActiveSnapPoint erhalten), (2) Ortho-Clamp gegen lastSnappedDown (Dispatcher verwaltet die Referenz selbst, Legacy-Konvention dx-vs-dy), (3) natives Pan bei Mittel-/Rechtsklick-Drag (ZoomPan.pan), (4) Wheel-Zoom am Cursor, (5) Cursor-Meldung ohne aktives Tool (Statusleiste), requestRender-Dep; ToolState/ToolPhase nach ui.types verschoben (App/ui.types entkoppelt von interaction), dispatcher.test.ts auf neues Pan-Verhalten angepasst (Rechtsklick = natives Pan statt cancel, ESC bleibt cancel). ERGEBNIS: src/interaction/index.ts GELÖSCHT — EINE Engine im Code, dispatcher-nativ; dispatcher.test.ts 6/6, dispatcherNative 4/4; BEWEIS-TEST GRÜN nach Deploy: Zeichnen=1, DB=1, Auswahl=1, Optionen=3 (chordCount=3 ankommen) | Frontend: 903 Tests, tsc 0, Build 0, E2E-Beweis GRÜN | c244571 | PHASE 4c COMPLETE: NUR NOCH EINE ENGINE |
|
||||
| 2026-09-16 | CAD-14 Phase 4a+4b | WERKZEUG-OPTIONEN + SIDEBAR-ZUGANG: (1) KARTIERUNGSFUND: Der InteractionDispatcher bekam KEIN getOptions — V2-Werkzeuge erhielten IMMER leere Optionen; chordCount/Laenge/Breite/Winkel liefen still ins Leere (NUR in bomService referenziert, keine UI, kein Store) — die in CAD-8/11 gemeldeten einstellbaren Optionen waren im V2-Pfad nicht erreichbar; (2) PHASE 4a: toolOptionsService NEU (zentraler Store: set/get/getOptions, defaultsFromSchema, subscribe, reset — 6 Tests), Dispatcher-Deps.getOptions verdrahtet, Registry-Defaults beim Mount aus optionsSchemas gefuellt, ToolOptionsPanel NEU (rendert optionsSchema des aktiven V2-Werkzeugs: number/color/select/boolean, schreibt in den Store) in RightSidebar tool-Tab ueber PropertiesPanel; (3) PHASE 4b: LeftSidebar dynamische Sektion Werkzeuge (Plugins) NEU — ALLE Registry-Werkzeuge ohne statischen Button (truss, boxcorner, circle-truss, measure-area, block-edit...) sind wieder erreichbar nach dem Ribbon-Rueckbau (Funktionsverlust behoben), ein Ort fuer ALLE Werkzeuge; (4) BEWEIS-TEST erweitert um ABNAHME 4: chordCount=3 im Options-Panel setzen, Traverse ziehen, properties_json in DB muss chordCount=3 enthalten — GRUEN; Test-Lesefehler behoben: rohe DB-API liefert properties_json (String, listElements = SELECT *) statt properties; Timeout 120s | Frontend: 899 Tests (+6 toolOptionsService), tsc 0, Build 0, Beweis-Test GRUEN mit 4 Abnahmen | 2a05257, d5acf76 | PHASE 4a+4b COMPLETE: Optionen erreichen die Werkzeuge, alle Tools in der Sidebar erreichbar |
|
||||
| 2026-09-16 | CAD-14 Phase 3 | V2-SELECT + APP-KANAL: selectTool-Plugin NEU (core-drawing): Klick waehlt via Bridge-hitTest, Leerklick leert Auswahl, Shift additiv, Drag = Box-Select (Zentrum-in-BBox ueber doc.getAllElements), gestrichelte Box-Vorschau; ToolSelectionBridge erweitert um onChange (App-Kanal); CanvasArea: Bridge-onChange — onSelectionChangeRef (Stale-Closure-Sicher wie onElementCreatedRef), Pixi-Handles-Hook bleibt an selectByIds; Beweis-Test erweitert um ABNAHME 3: V2-Select klickt auf die gezeichnete Linie — __v2GetSelection meldet genau 1 selektiert; Test-Timeout 60s; Verbleibend Phase 4: Legacy-InteractionEngine-Code loeschen (1173 Zeilen, nach Layer-Panel-Anpassung) | Frontend: 893 Tests (+6 selectToolV2), tsc 0, Build 0, Beweis-Test GRUEN (3 Abnahmen: Pixi=1, DB=1, Auswahl=1) | 1d12904 | PHASE 3 COMPLETE |
|
||||
| 2026-09-16 | CAD-14 Phase 1+2 | EINSYSTEM-SCHNITT (Nutzer-Anweisung: "Natuerlich die neue Engine, bau den alten Scheiss aus"): KOMPLETTE KARTIERUNG vorab (das fehlte zuvor): (1) SMOKING GUN im Code: Tool-Sync gab BEIDEN Engines dasselbe Werkzeug (Kommentar: "Legacy bleibt parallel aktiv") — jeder Klick lief doppelt, auf zwei Canvas mit unterschiedlichem Größen/DPR-Handling — doppelte Elemente UND Positionsdrift; (2) Beweis-Test E2E (playwright-tests/one-engine-proof.test.ts): echtes Drag-Protokoll, misst Pixi-cachedElements UND DB — ROT-Zustand belegte 4 Elemente statt 1; (3) PHASE 1 Exklusivitaet: V2-Dispatcher exklusiv fuer V2-Werkzeuge, Legacy-Engine bekommt setTool(null) (ToolState.activeTool null-faehig), Doppelverarbeitung am Ursprung getoetet — Messung danach: 2 statt 4 (zweite Quelle: Pixi-Doppelpfad); (4) PHASE 2 Stale-Closure-Fix: onElementCreatedRef (Ref-Muster wie selectedElementIdsRef) — der einmal registrierte v2Doc.onChanged-Handler hielt eine alte drawingId-Closure (still kein DB-Save); (5) Debug-Expose __v2Debug.docCount/announcedCount: Kette live gemessen doc=1 — ann=1 — cached=1 — DB=1 VOLLSTAENDIG; (6) ABNAHME-TEST GRUEN: echtes Drag erzeugt GENAU 1 Element im Renderer UND genau 1 in der DB (frisches Projekt+Zeichnung per API, eindeutige Zuordnung). Verbleibend Phase 3: Legacy-InteractionEngine-Code loeschen (1173 Zeilen), V2-Select-Tool (Auswahl direkt auf V2-Dokument), Layer-Panel an v2Doc | Frontend: 887 Tests, tsc 0, Build 0, E2E-Beweis GRUEN | 02ed6a5, f9c67a7 | PHASE 1+2 COMPLETE: Zeichnen = 1 Element an Klick-Position, in DB persistiert |
|
||||
| 2026-09-16 | CAD-13-ui | DOPPELTE WERKZEUGE entfernt (Nutzer-Meldung: "Werkzeuge doppelt oben, die sind schon in der Sidebar"): Der CAD-10-Zeichnen-Tab im Ribbon war Redundanz zur linken Werkzeug-Sidebar. FIX: Zeichnen-Tab komplett aus Ribbon entfernt (tabs-Array, renderV2Group, V2_GROUP_LABELS, render-Block — 87 Zeilen geloescht), RibbonTab-Typ auf start/view/ki, Stueckliste-Button in Ansicht-Tab-Extras verschoben, uiMode-Tests auf Registry-Filterlogik verlagert (Sidebar ist statisch, Filter lebt in pluginRegistry-tags). LIVE VERIFIZIERT: Ribbon-Tags = [Start, Ansicht, KI], Werkzeuge nur noch in der Sidebar wie in echten CADs (AutoCAD-Muster), visuell bestaetigt | Frontend: 884 Tests, tsc 0, Build 0 | 6857573 | LIVE VERIFIZIERT: keine Doppelung mehr |
|
||||
| 2026-09-16 | CAD-13-fix | DOPPEL-ZEICHNEN behoben (Nutzer-Meldung: "Alles wird doppelt gezeichnet"): Ursache: Legacy-Canvas2D-RenderEngine zeichnet ALLE Elemente UND das Pixi-Overlay rendert dieselben Elemente darueber (Task C2 „Parallel“ war nie abgeschaltet, Legacy-Entsorgung Q3 nie durchgefuehrt). FIX in 3 Schritten: (1) RenderEngine.skipElementRendering-Flag NEU — wenn true, zeichnet Legacy nur Grid/Hintergrund/Lineal, keine Elemente; gesetzt wenn Pixi erfolgreich init (defensiv, TDD 3 Tests); (2) FALLBACK abgesichert: Bei Pixi-Fehler (waitForReady-catch oder init-exception) wird Flag zurueckgesetzt + Legacy rendert sofort wieder Elemente — KEIN Fall wo nichts sichtbar ist; (3) PIXI-MIRROR: Bestands-Elemente aus DB flossen NIE in Pixi (nur neue V2-Elemente via v2Doc.onChanged) — cachedElements war 0, Alt-Projekte waeren leer gewesen; neuer Mirror im elements-Effekt spiegelt alle DB-Elemente in Pixi (Referenzdiff wie syncPixi, removeElement fuern Geloeschte, idempotent da drawElement zuerst removeElement). LIVE-VERIFIZIERT: cachedElements=35 im Buehnen-Projekt, Screenshot zeigt saubere Einfach-Zeichnung ohne Doppelkonturen | Frontend: 884 Tests + 3 renderEngineSkip, tsc 0, Build 0 | af9f53c, 0e4f9fe | LIVE VERIFIZIERT: doppelt-zeichnen behoben |
|
||||
| 2026-09-16 | CAD-12c | Visueller UI-Audit mit Vision-Modell (Playwright-Screenshots aller 4 Tabs, 2 Re-Audits): BEFUNDE: (1) Autosave-Indikator sichtbar und korrekt, (2) 4-Tab-Struktur sauber, (3) Emoji-Toggle im Topbar durch Text-Button Einfach/Pro ersetzt, (4) KRITISCH: Zeichnen-Tab abgeschnitten — ribbon hatte feste Hoehe + overflow:hidden, nur 1 von 3 Gruppen sichtbar; FIX: dynamische Hoehe (auto/min-height/max 220px + scroll), ribbon-content mit flex-wrap + overflow-x auto, Gruppen stapeln vertikal, Buttons fliessen mit flex-wrap; (5) Gruppenlabels bei kleiner Breite ausgeblendet (media-query, ok). VERBLEIBEND: 29-Tool-Zeichnen-Gruppe passt nicht komplett in eine Reihe — Ribbon per Scroll erreichbar, Kompaktlayout (kleinere Buttons/2-spaltig) als eigener Feinschliff-Task in Meilenstein 2 | Frontend: 884/884 Tests, tsc 0, Build 0, 2 Deploy-Verifikationen (Backend healthy, Frontend 200) | c4a76fb, 480368a | CAD-12c COMPLETE, MEILENSTEIN 1 FERTIG |
|
||||
| 2026-09-16 | CAD-12a+b | Autosave + Keyboard-Shortcuts: (1) autosaveService NEU — createAutosaveController mit 30s Interval, Status-Anzeige (Autom. gespeichert/Speichert/Offline) im Topbar via setSavedStatus, performFullSave pusht alle Elemente in SQLite-Tabellen (Redundanz zum CRDT-LevelDB das bereits automatisch speichert), markChanges fuer dirty-tracking, destroy fuer Cleanup; (2) keyboardShortcuts NEU — createKeyboardShortcuts Handler: ESC=Tool-Abbrechen, Delete/Backspace=Auswahl loeschen, Strg+A=Alles auswaehlen, Strg+D=Auswahl duplizieren (CRDT-setElement mit +20 Offset), F7=Grid, F8=Ortho, F3=Snap; Input-Felder werden ignoriert (kein Shortcut in Textfeldern); (3) Beide in App.tsx useEffect verdrahtet (nur wenn drawingId+token vorhanden), destroy bei Cleanup; (4) BOMDialog-Render-Verdrahtung die in CAD-9 fehlte ergaenzt (Action + Render) | Frontend: 884/884 Tests (+18), tsc 0, Build 0 | git | CAD-12a+b COMPLETE; naechster: CAD-12c (visueller UI-Audit mit Vision) |
|
||||
| 2026-09-13 | CAD-11 | Traversen Professional-Upgrade (Nutzer-Anforderungen): (1) chordCount NEU — Anzahl Gurtrohre 1-4 einstellbar (Option number 1-4, Default 2), Rendering gleichmaessig ueber die Breite verteilt (1=Mittellinie, 2=aeussere, 3/4=dichter), BOM gruppiert nach chordCount (2-rohr vs 4-rohr getrennt) mit Detail im Label; (2) FREIE LAENGENEINGABE — length von Select auf number geaendert (10-1000cm), Presets als Voreinstellungen ueber Optionsfeld, nicht mehr auf 4 Werte begrenzt; (3) VARIABLE ECK-WINKEL — angle-Option 15-165 Grad (Default 90), boxcornerEndpoints nutzt properties.angle fuer rot+angle statt fix rot+90, Rendering mit rot+angle Geometrie (8 Punkte), magnetisches Andocken arm-spezifisch; (4) KREIS-TRAVERSE NEU — circleTrussTool (Durchmesser 50-1500, Breite, Gurtrohre 1-4), Klick-Platzierer, circletruss-ElementType mit Aussen-/Innenkreis + Gurtrohr-Kreisen; (5) BOM-UPDATE — Kreis-Traversen mit Pi-d-Umfang als Gesamtlaenge, Eck-Winkel im Label wenn abweichend von 90, chordCount-Label | Frontend: 866 Tests (+21 trussPro, 2 trussUpgrade auf neues Verhalten angepasst), tsc 0, Build 0 | 875dbd6 | CAD-11 COMPLETE |
|
||||
| 2026-09-02 | CAD-10 | Ribbon-Kuratisierung (Nutzer-Kritik: verstreute Menues): VOLLSTANDIGER REWRITE von RibbonBar.tsx (369 auf 242 Zeilen): (1) 4 Tabs statt 7 — Start (Datei/Bearbeiten), Zeichnen (ALLE Werkzeuge), Ansicht, KI; (2) Zeichnen-Tab zeigt alle 48 V2-Werkzeuge in 3 logischen Gruppen: Zeichnen & Aendern (29 canvas-Tools), Bemassung & Text (9 format-Tools), Bestuhlung & Traversen (9 insert-Tools + Stueckliste-Button direkt neben den Traversen-Werkzeugen); (3) Ansicht-Tab konsolidiert: Zoom-Fit/100%/Raster/Layer + Hintergrund/Bild + Farben/Lineal + Historie/Gaeste; (4) Duplikate entfernt (Raster war in 2 Tabs); (5) RibbonTab-Typ auf start/draw/view/ki reduziert, uiMode-Test + Components-Tab-Test angepasst; (6) durchgaengig SVG-Icons; Zwischenfaelle: 2-mal Quoting-/Heredoc-Fehler beim Test-Patch (per sed behoben) + Session-Hang (reset) | Frontend: 845/845 Tests, tsc 0, Build EXIT 0 | 47c029b | CAD-10 COMPLETE |
|
||||
| 2026-08-31 | CAD-9 | Stueckliste (BOM) fuer Traversen: (1) bomService NEU — buildBOM: gruppiert alle truss-Elemente nach (Laenge x trussWidth) und boxcorner nach (armLength x trussWidth) in EINE Position je Mass mit Stueckzahl; Gesamt-Traverse-Meter mit Eck-Konvention BEIDE Arme zaehlen (50er-Ecke = 1.00m — eigener Test verifiziert die Konvention); Sortierung: Traversen vor Eck-Traversen, je Typ Laenge absteigend; Nicht-Traversen (chair etc.) still ignoriert; leerer Bestand → leere Liste ohne Crash; (2) exportBOMCsv: Semikolon-Separatoren (Excel-DE oeffnet direkt mehrspaltig), Spalten Pos/Typ/Laenge_cm/Breite_cm/Anzahl/Gesamt_cm/Bezeichnung, SUMME-Zeile nur wenn Positionen vorhanden (leere BOM = nur Header); (3) BOMDialog-Komponente: Modal-Tabelle mit allen Spalten + Summenzeile hervorgehoben, CSV-Download als Datei (UTF-8-BOM fuer Excel-Umlaute, Datum im Dateinamen), Empty-State mit Hinweis auf Traversen-Tools; (4) Ribbon-Button „Stueckliste“ in Extras-Gruppe neben Gäste, App-Verdrahtung ueber documentService.getActiveDocument().getAllElements(); Test-Zwischenfaelle selbst korrigiert: Gesamtlaenge-Erwartung 7.0m war falsch gerechnet (Ecken zaehlen beide Arme → 8.0m korrekt, konsistent mit Both-Arms-Test) + SUMME-Zeile bei leerer BOM unterdrueckt | Frontend: 845/845 Tests (+13 bomExport.test.ts), tsc 0, Build EXIT 0 | 4398c67 | CAD-9 COMPLETE |
|
||||
| 2026-08-31 | CAD-8 | Traversen-Upgrade (4 Nutzer-Anforderungen): (1) LAENGEN-PRESETS: Select auf genau 50/100/200/300 cm umgestellt; (2) BREITE: neue trussWidth-Option (number 10-60, Standard 29cm) — in makeTrussElement (rotationsbewusste BBox) + Rendering + Status-Meldungen; (3) DREHBAR: Truss-Koerper komplett als ROTIERTES Polygon neu implementiert (4 Ecken über Längs-/Normalen-Richtung statt achsenparalleles rect — vorher sah 90° falsch aus) + Gurtlinien mitgedreht; Boxcorner genauso rotationsbewusst (L-Form-Polygon mit 6 Punkten aus zwei Armen); Drehung im PropertiesPanel (rotation-Feld existierte bereits) wirkt jetzt korrekt in der Geometrie; (4) BOXCORNER: neues ElementType + Eck-Traverse-Tool (armLength/trussWidth-Optionen, Drag-Richtung, L-Schwerpunkt am Vertex) + boxcornerEndpoints (Armenden bei rot und rot+90) + cornerDockToDock (Ecke dockt an Traversen-Enden UND Ecken-Armenden, Gegenrichtung am Truss-Start) + snapTrussToDock erweitert: Traversen docken an Ecken-Armenden mit ARM-SPEZIFISCHER Richtung (Arm1=rot, Arm2=rot+90 statt pauschal rot+180 — Test deckte auf) — Rahmen aus Traversen+Ecken bauen sich komplett magnetisch; Zwischenfaelle: Python-Anchor-Abbruch (Unicode) kostete einen Lauf (alle event-tools-Edits neu mit ASCII-Anchor), L-Form hat 6 nicht 8 Punkte (natuerliche Geometrie, Test korrigiert) | Frontend: 832/832 Tests (+25 trussUpgrade.test.ts), tsc 0, Build EXIT 0 | 3ecc99a | CAD-8 COMPLETE |
|
||||
| 2026-08-31 | CAD-7 | Magnetisches Traversen-Tool: (1) ElementType „truss“ NEU + (2) trussEndpoints (Endpunkte rotationsbewusst über Grad-Winkel) + snapTrussToDock (magnetisches Andocken, rein): sucht unter allen truss-Elementen den Endpunkt in 25er Toleranz, uebernimmt dessen Rotation UND dockt exakt an — am ENDPUNKT-0 (Start) in GEGENRICHTUNG (rot+180, weg von der Anker-Traverse), am Endpunkt-1 vorwärts (Reihen bauen sich selbst); bei mehreren Kandidaten gewinnt der nächstgelegene; (3) trussTool: Drag-Interaktion (down startet, move zeigt magnetische Live-Vorschau — dockt der Start an einem Traversen-Ende wird sofort die gedockte Geometrie angezeigt, up committet in EINER Transaktion), Laengen-Option 100/200/290cm, ohne bestehende Traversen freie Platzierung mit Richtung aus Drag; (4) Rendering: truss-Case im Pixi-Drawer — Balken-Rechteck + 2 Gurtlinien entlang der Rotation (Fachwerk-Profil-Look); Zwischenfall selbst erkannt: erster Docking-Ansatz extendede IMMER vorwärts → am Startpunkt überlappte die neue Traverse die alte (Test deckte auf: Rotation 180 erwartet, 0 geliefert) — Endpunkt-Index-Unterscheidung eingeführt | Frontend: 807/807 Tests (+18 trussTool.test.ts), tsc 0, Build EXIT 0 | cefba89 | CAD-7 COMPLETE — CAD-6+Cad-7 decken beide Nutzer-Wuensche (Grundrisse + Traversen) ab |
|
||||
| 2026-08-31 | CAD-6 | Hintergrund-Grundrisse (mit Maßstab) — echter BUG-Fix: (1) Befund: Die komplette Infrastruktur existierte (BackgroundImport-Dialog, BackgroundService mit calibrateScale-Maßstabs-Kalibrierung, bgConfig in Yjs via bgConfig-Map inkl. CRDT-Sync, bgConfig als CanvasArea-Prop) — ABER CanvasArea nutzte bgConfig NIE: kein setOptions(backgroundSrc), keine Pixi-Zeichnung; importierte Grundrisse wurden im Dialog konfiguriert, aber auf der Zeichenfläche GAR NICHT gerendert; (2) Fix: CanvasArea-Effect erweitert — renderEngine.setOptions mit backgroundSrc (visible-Filter), backgroundScale, backgroundOffsetX/Y, backgroundRotation (Grad→Radiant), backgroundOpacity + render()-Trigger; bgConfig in die useEffect-Dependencies aufgenommen (Position/Scale/Opacity-Änderungen rendern sofort); (3) PixiRenderer.drawBackground: echte Sprite-Implementierung statt Stub — Bild-Cache (Neuaufbau nur bei Quellwechsel), addChildAt(sprite, 0) unter ALLEN Elementen, alpha/scale/position/rotation aus BackgroundDrawConfig, ohne Bild wird das Sprite sauber entfernt (Hintergrund aus); Legacy-RenderEngine.drawBackground hatte bereits korrekte Transform-Parität (Zeichenreihenfolge Grid→Background→Elemente); Layer-Baumansicht: bereits vorhanden (LayerPanel baut echten Baum via parentId mit Unterebenen/Gruppen/Elementen) — keine Änderung nötig; tsc-Fix selbst erkannt: Container heißt root nicht world | Frontend: 789/789 Tests (+5 backgroundRendering.test.ts), tsc 0, Build EXIT 0 | siehe git | CAD-6 COMPLETE; nächster Task CAD-7 (magnetisches Traversen-Tool) |
|
||||
| 2026-08-31 | CAD-5 | MLINE + Block-Editor: (1) mlineTool NEU in core-drawing (13 Tools) — Mehrfachlinie für Wände/Rohre: Klick-Sequenz sammelt Mittellinien-Punkte, offsetPolyline-Helfer berechnet ZWEI parallele Punktketten ±gap/2 entlang der Normalen (an inneren Punkten Mittelung der angrenzenden Segment-Normalen = Miter-Joins wie bei echten Wänden), gap-Option (Gesamtstärke), optionale Endkappen (cap-Checkbox) verbinden beide Linien an Start/Ende als line-Elemente mit mlineCap-Flag, ENTER committet ab 2 Punkten (boolean-Konsum), ALLE Teile (2 Linien + ggf. 2 Kappen) in EINER doc.transact = 1 Undo-Schritt, mline:true+gap-Metadaten, L-förmige Pfade folgen der Geometrie (gleiche Punktanzahl); (2) blockEditorService NEU — openBlockForEdit legt markierte KOPIEN der Block-Elemente ins Dokument (Original unangetastet, BLOCK_EDIT_MARKER-Flag + __blockEdit-IDs), saveBlockEdit schreibt die bearbeiteten Kopien als neue Elementliste in die Definition zurück (Marker entfernt) und räumt Edit-Kopien auf, discardBlockEdit verwirft alles; (3) blockEditTool NEU in core-modify (26 Tools) — Klick 1 auf Block-Instanz öffnet (via selection.hitTest + blockId-Referenz, Status führt), Kopien normal bearbeiten mit allen Werkzeugen, Klick 2 speichert zurück, cancel verwirft; Zwischenf\e4lle selbst gelöst: BLOCK_EDIT_MARKER fehlte als Re-Export in core-modify (Test bekam undefined) + Service-State-Leak zwischen Tests (afterEach-Discard) + Kappen-Test a/b-Vorzeichenkonvention reihenfolge-agnostisch gemacht | Frontend: 784/784 Tests (+21 mlineBlockEditor.test.ts), tsc 0, Build EXIT 0 | 0ffe954 | **CAD-5 COMPLETE — POST-2.0 CAD-AUSBAU VOLLSTÄNDIG** (CAD-1 bis CAD-5: Bemassung, Hatch, Box-Select, OTRACK/DIVIDE/MEASURE, MLINE/Block-Editor) |
|
||||
| 2026-08-30 | CAD-4 | OTRACK + DIVIDE/MEASURE: (1) computeTrackPoint NEU in SnapEngine (reine Funktion, Task CAD-4) — Objekt-Fang-Tracking: projiziert den Cursor auf Tracking-Linien durch den Basispunkt (Default-Winkel 0/90/180/270°, optional erweiterte Winkelliste z.B. 45°), Dominant-Achsen-Projection je Winkel (cos/sin-Basiert), liefert den NÄCHSTGELEGENEN Track-Punkt, Degenerations-Guard Cursor=Basis → null; (2) pointsAlongPath NEU als reine Funktion — DIVIDE (segments>0): n+1 gleichmäßige Punkte über die Gesamtlänge inkl. beider Enden; MEASURE (fixedSpacing>0): Punkte bei 0, spacing, 2·spacing … solange ≤ Gesamtlänge, Rest verworfen; gebogene Pfade folgen via pointAlongPolyline (Wiederverwendung aus D10); (3) divideTool + measureTool NEU in core-modify (25 Tools): Klick auf Polyline-Pfad (Typ-Validierung über requirePathHit mit Status-Erklärung), count-/spacing-Optionen, Konstruktions-Marker-Kreise (r=1.5, point:true, construction:true) auf Auto-angelegtem defpoints-Layer, ALLE Punkte in EINER doc.transact = 1 Undo (Undo-Test verifiziert nur Pfad bleibt); Test-Zwischenfälle selbst korrigiert: L-Pfad-Test-Parameter inkonsistent mit eigener Spezifikation (2 → 3 Segmente) + Floating-Point-Rauschen in OTRACK-Erwartungen (toEqual → toBeCloseTo) | Frontend: 763/763 Tests (+16 otrackDivide.test.ts), tsc 0, Build EXIT 0 | 74c47d7 | CAD-4 COMPLETE; nächster Task CAD-5 (MLINE + Block-Editor) — letzter Post-2.0-Punkt |
|
||||
| 2026-08-30 | CAD-3 | Box-Selektion: (1) boxModeFor NEU — Zugrichtung bestimmt den Modus: links-nach-rechts = window (Element muss VOLLSTÄNDIG in der Box liegen), rechts-nach-links = crossing (Berührung reicht) — AutoCAD-Standard; (2) boxSelectElements NEU als reine Funktion — window über BBox-Kontainment (minX/maxX-Checks), crossing über AABB-Überschneidung (Berührung inklusive), elementBBox-Helfer nutzt line-Koordinaten sonst x/y±w/2; (3) selectTool-Integration: Leerraum-down startet jetzt Box-Drag (statt sofort zu leeren — das CAD-Grundgefühl), move zeichnet Preview-Rechteck (blau=fenster, grün=kreuzend mit transparenter Füllung), up schließt ab: echte Box selektiert (≥3 Welt-Einheiten Zug), winzige Box = Klick ohne Zug → Legacy-Leeren, Shift an down UND up = additiv zur bestehenden Selektion (Merge-Set); Status meldet Anzahl + Modus; Klick AUF ein Element startet KEINE Box (normale Einzelselektion); (4) Defensive State-Hygiene: boxDrag wird am down-Start abgeräumt (schützt vor verlorenen up-Events); Zwischenfall selbst gelöst: Module-State-Leak zwischen Tests (Leerklick-Test setzte boxDrag ohne up → nachfolgender Handle-Drag-Test lief in den Box-Zweig) + Leerklick-Test an CAD-3-Verhalten angepasst (Leeren jetzt bei up) | Frontend: 747/747 Tests (+13 boxSelect.test.ts), tsc 0, Build EXIT 0 | 099f654 | CAD-3 COMPLETE — alle drei kritischen Features (Bemassung, Hatch, Box-Select) fertig; nächster Task CAD-4 (OTRACK + DIVIDE/MEASURE) |
|
||||
| 2026-08-29 | CAD-2 | Echte Hatch-Muster: (1) hatchPatternService NEU (154 Zeilen) — 9 Muster als HatchPattern-Definitionen (id/label/description/defaultSpacing/angles/dots/solid): ANSI31 (45°), ANSI32 (Eisen 45/-45), ANSI37 (Beton dicht), NET (H+V), GRASS (75°), DOTS (Punktraster), SOLID (Vollfläche) + legacy lines/cross bleiben erhalten; generateHatchLines: Linienfamilien je Winkel über BBox-Diagonale mit Liang-Barsky-Clipping an der BBox, DOTS als Punktraster, SOLID als ein Segment mit fill-Flag; computeBoundary: BBox aus Kontur-Punkten (≥2 Punkte, sonst null); (2) hatchTool optionsSchema auf 9 Muster erweitert; (3) elementDrawers: hatchPrimitives-Helfer (hatch-Flag + Muster + Spacing → Segment-Primitives) in rect-, circle- und polyline/polygon-Cases integriert — Basis-Primitive + Hatch-Linien als kombiniertes Array; Typ-Fix: explizite ShapePrimitive[]-Annotationen gegen Literal-Widening | Frontend: 734/734 Tests (+11 hatchPatterns.test.ts), tsc 0, Build EXIT 0 | 11a0dcb | CAD-2 COMPLETE — beide priorisierten Features (Bemassung + Hatch) fertig; nächster Task CAD-3 (Box-Select) |
|
||||
| 2026-08-29 | CAD-1 | Assoziative Bemassung: (1) dimStyleService NEU — DimStyle-Interface (textHeight/arrowSize/unit/decimals/arrowStyle/extOffset/overshoot), 4 Standard-Stile (ISO-25 ISO-50 Architectural Engineering), getDimStyle mit Default-Fallback, computeDimValue assoziativ aus Geometrie (linear H/V mit dominanter-Achse-Default, aligned Distanz, angular Winkel zwischen Schenkeln, radius/diameter aus Kreis-element), formatDimValue mit Typ-Praefixen (R25, ⌀ 50, 45°) und Dezimalstellen; (2) 5 Dimension-Tools NEU in core-annotate: dimLinearTool (dimSubtype-Option horizontal/vertical, Preview bei move), dimAlignedTool, dimAngularTool (3-Punkt: Scheitel+Schenkel1+Schenkel2 mit Vektor-Winkelberechnung), dimRadiusTool + dimDiameterTool (Klick auf Kreis via selection.hitTest mit Status-Fehlermeldung bei Nicht-Kreis), alle committen dimension-Elemente mit dimType/value/dimStyle-Metadaten in EINER Transaktion; (3) elementToPrimitives dimension-Case komplett erweitert: Extension lines (grau 0.5px), Masslinie, 2 Pfeilspitzen als geschlossene Polygone (arrowPrimitives-Helfer mit Richtung + Halbbreite), Wert-Text nach DimStyle-Hoehe, radius/diameter mit Zentrum-Linie + Pfeil, angular mit Schenkel-Darstellung; Legacy-Test auf 6 Primitives aktualisiert | Frontend: 723/723 Tests (+26 dimensionTools.test.ts), tsc 0 (nach Pt-Import-Fix), Build EXIT 0 | 44a60fa | CAD-1 COMPLETE; nächster Task CAD-2 (Hatch-Muster) |
|
||||
| 2026-08-29 | I3 | **RELEASE 2.0.0**: (1) Git-Tag v2.0.0 auf a20731a gesetzt (annotiert mit Phasen A–H & Test-Stand) und auf Forgejo gepusht; (2) Deploy über coolify-01 (SSH-Skill): Server-Repo /data/web-cad-neu auf main=a20731a aktualisiert, deploy.sh ausgeführt — beide Docker-Images (web-cad-neu-backend/frontend:latest) frisch gebaut, Coolify-Service cnuavrh33goa3zs0upi6hgif Container recreated (backend gestartet + healthy, frontend danach gestartet); (3) Deploy-Verify DREIFACH: Backend-Health im Container {status:ok}, Live-URL https://web-cad-neu.server.media-on.de HTTP 200, Backend-API von außen (Server + Agent-Container) {status:ok} — Production-Release live; CI-Pipeline führt beim Tag-Push den kompletten Lauf inkl. neuem Perf-Gate aus (Forgejo) | Prod-Deploy verifiziert: Backend-healthy, Frontend 200, API extern ok | v2.0.0 (Tag) | **I3 COMPLETE — ROADMAP VOLLSTÄNDIG: ALLE PHASEN A–I ABGESCHLOSSEN, WebCAD 2.0.0 IST LIVE** |
|
||||
| 2026-08-29 | I1 | CI erweitert: (1) playwright-tests/perf-gate.test.ts NEU — Performance-Gate nach Roadmap-Vorgabe: 10.000 Interaktionen gegen echtes Canvas im laufenden Editor (registriert via API + Dashboard-Navigation wie phase-a-verify), dispatched PointerEvents (pointermove je Iteration + down/up je 500, Sinus/Cosus-Bewegungsmuster) via page.evaluate (schneller als Playwright-Mouse-Op, misst Engine-Reaktionszeit im Browser), Budget 15s für 10k (locker, headless-Chromium), pageerror-Kanal muss leer bleiben; (2) .forgejo/workflows/ci.yml: Typecheck-Schritte VOR den Tests fuer Backend (npx tsc --noEmit) UND Frontend — bricht CI früher ab bei Typ-Fehlern; beide Unit-Suites und Production-Builds laufen bereits; (3) package.json test:e2e:ci um perf-gate.test.ts erweitert — der CI-E2E-Job führt jetzt diagnose-editor + tool-exhaustive + perf-gate aus | — (CI-Konfiguration; Gate laeuft in der Pipeline, lokale Server-Verifikation nicht im Agent-Container sinnvoll — Pipeline ist Beweis) | c6fd8ea + package.json | I1 COMPLETE; nächster Task I3 (Release 2.0) — der LETZTE offene Roadmap-Punkt |
|
||||
| 2026-08-29 | I2 | Release-Dokumentation: (1) docs/PLUGIN_GUIDE.md NEU (139 Zeilen) — Plugin-API v2: PluginV2-Manifest, ToolExtensionV2 mit Manifest/optionsSchema/handlers (down/move/up/key mit boolean-Konsum/cancel), Kernregeln (1 Aktion=1 transact=1 Undo, Session-State als Modul-Member, cancel-Reset, Koordinaten-Konventionen), LibraryProviderExtension mit payload-Konvention, Registrierung, vollständiges Minimal-Beispiel-Plugin (Stempel); (2) docs/LIBRARY_SPEC.md NEU (80 Zeilen) — .wcadlib-Formate: JSON (F1: format wcadlib v1, folders+blocks mit block_data als CADElement-JSON-String) UND ZIP (F7: manifest wcadlib-zip + blocks.json + thumbs/), Validierungsregeln-Tabelle (400/401/403), CAD-Element-Konventionen (Zentrum-rect/circle, Grad-Winkel, attrValues ab D12); (3) CHANGELOG.md NEU (54 Zeilen) — 2.0.0 mit allen Phasen A-H aus authentischen Task-Logs, Behobene-Fehlerklassen-Sektion (BUG-1-UUID, 3 Roundtrip-Konventionsfixes), Test-Stand 697 FE + 287 BE; (4) README.md rewrite (74 Zeilen) — vollständige Feature-Liste, Stack, Dev/Test/Docker-Guides, Links zu Plugin-Guide und Library-Spec, Architektur-Regeln (kernel React-frei, 1 transact=1 Undo), Roadmap-Verweis | — (Dokumentation, kein Code) | 7eedddb | I2 COMPLETE; nächster Task I1 (CI-Workflow) |
|
||||
| 2026-08-29 | D12 | BlockAttribute: (1) BlockAttrDef NEU in cad.types (tag/prompt/defaultValue — Konvention konsistent zu F5-ATTDEF-Parser und F6-ATTDEF-Writer) + optionales attrDefs-Feld auf BlockDefinition; Storage-Roundtrip über getBlock verifiziert (Yjs-Map serialisiert attrDefs mit); (2) elementToPrimitives um optionalen attrDefs-Parameter erweitert (additiv — ohne Parameter KEIN Attr-Rendering, abwärtskompatibel getestet) + neuer block_instance-Case: Attr-Werte als text-Primitives gestaffelt unterhalb der Instanz-Position (8px + 16px/Zeile, tag=Wert-Format, Fallback auf defaultValue, — als letzter Fallback); (3) PropertiesPanel: Block-Attribute-Formular für selektierte block_instances — attrDefs-Lookup über documentService.getActiveDocument().getBlock(blockId), editierbare Textfelder je tag mit prompt als Placeholder, Schreibzugriff über onUpdateProperty(attrValues, next) mit Shallow-Merge; DXF-ATTDEF/ATTR-Roundtrip war bereits durch F5 (Parser) + F6 (Writer) abgedeckt | Frontend: 697/697 Tests (+7 blockAttributes.test.ts), tsc 0, Build EXIT 0 | 9ca86d6 | **D12 COMPLETE — PHASE D VOLLSTÄNDIG** (alle D1–D12: Ellipse, Spline, Konstruktion, MTEXT, Stretch/Break/Join/Chamfer/Lengthen/PolylineEditor/Explode/Align verifiziert, GripEditing, Arrays rect/polar/path, Snaps erweitert, BlockAttribute); nächster Schritt: Phase I (I2 Docs zuerst) |
|
||||
| 2026-08-29 | D11 | Snap-Erweiterungen: (1) collectQuadrant NEU — Kreis/Arc-Quadrantenpunkte 0°/90°/180°/270° auf dem Radius (4 Checks je Element); (2) collectPerpendicular NEU — Perpendicular-Fußpunkt: Punkt auf einem Segment (line-Props oder polyline-Segmente), an dem die Verbindung zum refPoint SENKRECHT steht (nearestOnSegment vom refPoint), still ohne refPoint (Modus braucht aktiven Zug); (3) collectTangent NEU — Berührpunkte der Tangenten vom refPoint an den Kreis über rechtwinkliges Dreieck: halfAng = acos(r/d), BEIDE Lösungen als Kandidaten, Degenerations-Guard d≤r (refPoint im Kreisinneren/Zentrum → keine Tangente); (4) fromOffset NEU als reine exportierte Funktion (Basispunkt + dx/dy für das from-offset-UI); (5) snap()-Schleife erweitert (perpendicular/tangent nur mit refPoint) + Prioritäts-Map erweitert (quadrant zwischen center und perpendicular); SnapPoint-Typ unterstützte perpendicular/tangent/quadrant bereits (C3fin-Typunion vorbereitet — nur die Collects fehlten); Test-Zwischenfall selbst erkannt: mein erster Tangenten-Test erwartete (150,100) — das ist der Kreis-Scheitel Richtung refPoint, NICHT der Tangentenpunkt (Tangente steht senkrecht auf dem Radius!); echte Tangentenpunkte bei d=100/r=50: halfAng=60° → (125,143.3)/(125,56.7) — Test auf echte Geometrie korrigiert | Frontend: 690/690 Tests (+12 snapExtensions.test.ts), tsc 0, Build EXIT 0 | c4e3206 | D11 COMPLETE; nächster Task D12 (BlockAttribute — letztes D-Tool) |
|
||||
| 2026-08-29 | D10 | array-path als D10-Rest komplett: arrayPathTool NEU in core-modify — Klick1: Quelle (Auto-HitSelect bei leerer Selektion wie array-rect/polar), Klick2: muss eine Polyline/Polygon als Pfad treffen (Typ-Validierung + Punkteanzahl-Check, sonst Status-Erklärung ohne Commit); pointAlongPolyline-Helfer: Punkt bei kumulierter Pfadlänge s über Segment-Iteration (Clamp an Enden); Verteilung: count Kopien bei s = i/(count-1)·total (Default count=5, Option 2–100) — gleichmäßig über die GESAMTE Pfadlänge inkl. Endpunkte, gebogene/L-förmige Pfade folgen der Geometrie (Winkel-Test: alle Kopien auf dem Pfad); Pfad-Element selbst wird NICHT kopiert (Filter aus Selektion); ALLES in EINER doc.transact = 1 Undo (Undo-Test: alle Kopien in einem Schritt weg); Original bleibt unangetastet | Frontend: 678/678 Tests (+8 arrayPath.test.ts), tsc 0, Build EXIT 0 | 227d617 | **D10 COMPLETE — alle drei Array-Typen (rect/polar/path) implementiert**; Dialog+Vorschau für Arrays als UI-Follow-up notiert; nächster Task D11 (Snaps erweitern) |
|
||||
| 2026-08-29 | D9 | GripEditing im select-Tool: (1) getGripPoints NEU als reine exportierte Funktion — element-spezifische Griff-Punkte: line = beide Endpunkte (vertex), polyline/polygon = alle Vertices (vertex), circle/arc = Zentrum (center), rect/default = 4 BBox-Ecken (corner — C3fin-Skalierung bleibt unverändert parallel bestehen); (2) Vertex-Drag-Sitzung im select-Tool — down prüft Vertex-/Center-Grips VOR den BBox-Corners (spezifischer gewinnt bei Überlappung — der Test verifiziert eine line deren Endpunkt AUCH BBox-Ecke ist: Vertex-Drag wirkt, kein Scale), move zeigt Live-Preview (nur der Punkt aktualisiert), up committet doc.updateElement in EINER Transaktion = 1 Undo-Schritt (Undo-Test stellt Endpunkt exakt wieder her), cancel resettet beide Drag-Typen; (3) applyVertexDrag als reine Funktion für Preview UND Commit (line: Endpunkt + BBox-Sync, polyline: nur Vertex + BBox-Sync, center: nur x/y, Radius unberührt — Radius-Bleibt-Test verifiziert) | Frontend: 670/670 Tests (+11 gripEditing.test.ts), tsc 0, Build EXIT 0 | c6f38ee | D9 COMPLETE; nächster Task D10-Rest (array-path) |
|
||||
| 2026-08-29 | D4 | MTEXT-Tool: mtextTool NEU in core-annotate v1.1.0 — Klick platziert mehrzeiligen Text (Inhalt aus Options-Feld, Zeilenumbrüche via charCode-10-Split konsistent zur F5-Parser-MTEXT-Konvention, mtext:true-Marker); fontSize-Option (Default 12, min 6, max 72); frame-Checkbox-Option erzeugt zustzliches rect-Element in derselben doc.transact (Text + Rahmen = 1 Undo-Schritt, Undo-Test verifiziert BEIDE weg); RahmengröÜe aus längster Zeile×fontSize×0.6 Breite und Zeilenzahl×fontSize×1.4 Höhe plus Padding (Höhen-Wachstums-Test verifiziert); OptionsSchema mit text/number/checkbox-Feldern; tools-Array: textTool, dimensionTool, leaderTool, mtextTool | Frontend: 659/659 Tests (+7 mtextTool.test.ts), tsc 0, Build EXIT 0 | fbc4978 | D4 COMPLETE; nächster Task D9 (GripEditing) |
|
||||
| 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.01–100; (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: F1–F8 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 |
|
||||
@@ -717,6 +816,7 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
| 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)
|
||||
@@ -724,5 +824,20 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
||||
|
||||
---
|
||||
*Nächster offener Schritt: **F4** (LibraryProviderExtension anbinden: builtin-catalog JSONs, Startersets) — Details in Teil 5. Alternativ: F5/F6 (DXF Parser/Writer erweitern), Nebenproblem „Date.now()-IDs“ (user/proj/pfolder/draw/layer/block → randomUUID), Legacy-Entsorgung nach visueller Verifikation, oder Phase G (Layouts/PDF).*
|
||||
*Status 2026-08-28: Phasen B+A+C+D+E+F1+F2+F3 komplett. FE 495/495 Tests, BE 263/263, tsc/Build grün. Alle Commits auf Forgejo main.*
|
||||
*Post-2.0 CAD-Ausbau: CAD-1 bis CAD-11 fertig (inkl. Traversen Professional: Gurtrohre, freie Laenge, variable Winkel, Kreis-Traversen). Neue Anforderungen ueber neue Roadmap-Eintraege.*
|
||||
*Status 2026-09-13: RELEASE 2.0.0 LIVE + CAD-1 bis CAD-11 fertig. FE 866 Tests, BE 287/287, tsc/Build grün. Tag v2.0.0 auf Forgejo main.*
|
||||
|
||||
## Post-2.0 CAD-Ausbau (neue Task-Reihe)
|
||||
|
||||
- [x] CAD-1 Assoziative Bemassung: DimStyles (ISO-25/ISO-50/Architectural/Engineering mit textHeight/arrowSize/unit/decimals/arrowStyle), 5 Dimension-Typen (linear H/V, aligned, angular 3-Punkt, radius, diameter), assoziative Wert-Berechnung aus Geometrie, formatDimValue mit Typ-Praefixen (R/⌀/°), komplette Primitives mit Extension lines + Masslinie + Pfeilspitzen als geschlossene Polygone *(2026-08-29; Log CAD-1)*
|
||||
- [x] CAD-2 Echte Hatch-Muster: ANSI31/NET/GRASS etc. + Boundary-Erkennung aus umschlossener Flache + assoziative Schraffur *(2026-08-29: 9 Muster (ANSI31/32/37, NET, GRASS, DOTS, SOLID + legacy lines/cross), generateHatchLines mit Winkel-Familien + Liang-Barsky-Clipping, computeBoundary, RenderAdapter zeichnet Hatch in rect/poly/circle; Log CAD-2)*
|
||||
- [x] CAD-3 Fenster-/Kreuz-Selektion (Box-Select) *(2026-08-30: boxModeFor (Richtung = window/crossing wie AutoCAD), boxSelectElements (window = vollständig enthalten, crossing = Berührung reicht), Box-Drag-Session mit Preview-Rechteck (blau Fenster / grün Kreuzend), Shift-additiv, winzige Box = Klick → Legacy-Leeren bei up; Log CAD-3)*
|
||||
- [x] CAD-4 Objekt-Fang-Tracking (OTRACK) + DIVIDE/MEASURE *(2026-08-30: computeTrackPoint (Winkel-Projektion auf Tracking-Linien, Default 0/90/180/270°, erweiterte Winkel optional, nächstgelegener Track-Punkt) als reine Funktion in SnapEngine; pointsAlongPath (DIVIDE n+1 gleichmäßig inkl. Enden / MEASURE fester Abstand, Rest verworfen) unter Wiederverwendung von pointAlongPolyline; divideTool + measureTool mit defpoints-Markern in EINER Transaktion; Log CAD-4)*
|
||||
- [x] CAD-5 Multiline (MLINE) + Block-Editor *(2026-08-31: mlineTool (Doppel-Linie über Normalen-Offset ±gap/2 mit Miter an Zwischenpunkten, optionale Endkappen, ENTER-Commit ab 2 Punkten, 1 Transaktion) + blockEditorService (openBlockForEdit markierte Kopien / saveBlockEdit zurückschreiben / discardBlockEdit verwerfen) + blockEditTool (Klick öffnet, Klick speichert, cancel verwirft); Log CAD-5)*
|
||||
- [x] CAD-6 Hintergrund-Grundrisse mit Maßstab rendern *(2026-08-31: BUG-Fix — bgConfig wurde von App an CanvasArea uebergeben aber NIE genutzt: Hintergrund-Import funktionierte im Dialog, wurde aber nie gezeichnet; CanvasArea-Effect setzt jetzt backgroundSrc/Scale/OffsetX/Y/Rotation/Opacity via renderEngine.setOptions + render; PixiRenderer.drawBackground als echte Sprite-Implementierung (Bild-Cache, addChildAt Index 0 unter allen Elementen, alpha/rotation/scale); Layer-Baumansicht existierte bereits mit Unterebenen; Log CAD-6)*
|
||||
- [x] CAD-7 Magnetisches Traversen-Tool (Truss) *(2026-08-31: trussTool mit Drag-Richtung, Laengen-Optionen 100/200/290cm, snapTrussToDock fuer magnetisches End-zu-End-Andocken mit Rotations-Vererbung + Gegenrichtung am Startpunkt, truss-ElementType + Rendering mit Gurtlinien; Log CAD-7)*
|
||||
- [x] CAD-8 Traversen-Upgrade *(2026-08-31: Laengen-Presets 50/100/200/300cm, Breite einstellbar Standard 29cm, ROTIERBARES Rendering (Koerper als richtungs-Polygon statt achsenparallel), Boxcorner-Eck-Traverse als eigenes magnetisches Element mit beidseitigem Andocken; Log CAD-8)*
|
||||
- [x] CAD-9 Stueckliste Traversen (BOM) *(2026-08-31: bomService mit buildBOM (Gruppierung Traversen nach Laenge x Breite, Eck-Traversen nach armLength x Breite, Stueckzahlen, Gesamt-Traverse-Meter mit BEIDEN Ecken-Armen, Sortierung Traversen vor Ecken nach Laenge absteigend), exportBOMCsv (Semikolon-CSV Excel-DE-freundlich, SUMME-Zeile nur bei Inhalt), BOMDialog-Komponente (Tabelle + Summenzeile + CSV-Download, BOM-freundliche UTF-8-BOM-Datei), Ribbon-Button Stueckliste in Extras; Log CAD-9)*
|
||||
- [x] CAD-10 Ribbon-Kuratisierung: 4 Tabs statt 7 *(2026-09-02: Start/Zeichnen/Ansicht/KI statt 7 verstreute Tabs; alle V2-Werkzeuge im Zeichnen-Tab in logischen Gruppen: Zeichnen & Aendern / Bemassung & Text / Bestuhlung & Traversen inkl. Stueckliste-Button direkt daneben; Ansicht-Tab konsolidiert Zoom/Raster/Layer/Hintergrund/Historie/Gaeste; Duplikate entfernt (Raster doppelt); durchgaengig SVG-Icons; Log CAD-10)*
|
||||
- [x] CAD-11 Traversen Professional-Upgrade *(2026-09-13: chordCount 1-4 Gurtrohre mit einstellbarem Rendering, freie Laengeneingabe 10-1000cm statt Select, variable Eck-Winkel 15-165 Grade, Kreis-Traverse mit Durchmesser/Breite/Gurtrohren, BOM mit chordCount-Gruppierung + Pi-d-Umfang; Log CAD-11)*
|
||||
|
||||
|
||||
Generated
+77
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -56,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');
|
||||
@@ -110,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);
|
||||
@@ -149,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');
|
||||
@@ -183,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 ?? '{}');
|
||||
@@ -212,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;
|
||||
@@ -249,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,
|
||||
@@ -281,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,
|
||||
@@ -352,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);
|
||||
@@ -377,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 (?, ?, ?, ?, ?, ?)',
|
||||
|
||||
@@ -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,7 +131,7 @@ 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 });
|
||||
@@ -159,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 });
|
||||
@@ -191,7 +192,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
||||
|
||||
// Import: .wcadlib-Paket einspielen (IDs neu, Ordner-Hierarchie über Mapping erhalten)
|
||||
fastify.post('/api/global-blocks/import', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||
const pkg = request.body as {
|
||||
format?: string;
|
||||
folders?: Array<{ id: string; name: string; parent_id: string | null }>;
|
||||
@@ -229,4 +230,132 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
||||
}
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Task F7 – .wcadlib ZIP-Paket Export/Import (Backend).
|
||||
*
|
||||
* ZIP-Struktur: manifest.json (format/version/exported_at), blocks.json
|
||||
* (Ordner + Blöcke), thumbs/ (SVG-Thumbnails je Block-ID). Export liefert
|
||||
* application/zip; Import parst das ZIP und spielt Ordner-Hierarchie +
|
||||
* Blöcke mit ID-Mapping ein. Invalides ZIP → 400.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import JSZip from 'jszip';
|
||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
||||
import { createServer } from '../src/server.js';
|
||||
|
||||
describe('Task F7: .wcadlib ZIP Export/Import', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: SqliteAdapter;
|
||||
let token: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = new SqliteAdapter(':memory:');
|
||||
await db.init();
|
||||
app = await createServer({ db, port: 0 });
|
||||
await app.ready();
|
||||
|
||||
const regRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: { email: 'f7-zip@example.com', password: 'Password123!', name: 'F7 Zip', role: 'admin' },
|
||||
});
|
||||
token = JSON.parse(regRes.body).session.token;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
async function createFixture(): Promise<{ folderId: string; blockId: string }> {
|
||||
const folderRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/global-folders',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { name: 'Event Set', parent_id: null },
|
||||
});
|
||||
const folderId = JSON.parse(folderRes.body).id;
|
||||
const blockRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/global-blocks',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
name: 'Stuhl-Reihe',
|
||||
folder_id: folderId,
|
||||
block_data: JSON.stringify([{ id: 'e1', type: 'chair', x: 0, y: 0, width: 45, height: 45, properties: {} }]),
|
||||
svg_data: '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>',
|
||||
},
|
||||
});
|
||||
const blockId = JSON.parse(blockRes.body).id;
|
||||
return { folderId, blockId };
|
||||
}
|
||||
|
||||
it('GET /api/global-blocks/export-zip liefert ZIP mit manifest.json, blocks.json und thumbs/', async () => {
|
||||
const { blockId } = await createFixture();
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/global-blocks/export-zip',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/zip');
|
||||
|
||||
const zip = await JSZip.loadAsync(res.rawPayload ?? res.body);
|
||||
expect(zip.file('manifest.json')).toBeDefined();
|
||||
expect(zip.file('blocks.json')).toBeDefined();
|
||||
|
||||
const manifest = JSON.parse(await zip.file('manifest.json')!.async('string'));
|
||||
expect(manifest.format).toBe('wcadlib-zip');
|
||||
expect(manifest.version).toBe(1);
|
||||
expect(typeof manifest.exported_at).toBe('string');
|
||||
|
||||
const blocks = JSON.parse(await zip.file('blocks.json')!.async('string'));
|
||||
expect(blocks.folders.length).toBe(1);
|
||||
expect(blocks.folders[0].name).toBe('Event Set');
|
||||
expect(blocks.blocks.length).toBeGreaterThanOrEqual(1);
|
||||
const chairBlock = blocks.blocks.find((b: { name: string }) => b.name === 'Stuhl-Reihe');
|
||||
expect(chairBlock).toBeDefined();
|
||||
expect(chairBlock.thumbnail).toBe(`thumbs/${blockId}.svg`);
|
||||
|
||||
const thumb = zip.file(`thumbs/${blockId}.svg`);
|
||||
expect(thumb).toBeDefined();
|
||||
expect(await thumb!.async('string')).toContain('<svg');
|
||||
});
|
||||
|
||||
it('Export ohne Auth → 401', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/global-blocks/export-zip' });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('POST /api/global-blocks/import-zip spielt ZIP ein (Ordner-Mapping + Blöcke + Thumb-Referenz erhalten)', async () => {
|
||||
const { blockId } = await createFixture();
|
||||
const exportRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/global-blocks/export-zip',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(exportRes.statusCode).toBe(200);
|
||||
const zipBuffer = exportRes.rawPayload ?? exportRes.body;
|
||||
|
||||
const importRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/global-blocks/import-zip',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/zip',
|
||||
},
|
||||
payload: zipBuffer,
|
||||
});
|
||||
expect(importRes.statusCode).toBe(201);
|
||||
const body = JSON.parse(importRes.body);
|
||||
expect(body.imported_blocks).toBeGreaterThanOrEqual(1);
|
||||
expect(body.imported_folders).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verifizieren: importierter Block existiert mit Thumb-Daten
|
||||
const blocksRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/global-blocks',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
const allBlocks = JSON.parse(blocksRes.body);
|
||||
const imported = allBlocks.filter((b: { name: string }) => b.name === 'Stuhl-Reihe');
|
||||
expect(imported.length).toBeGreaterThanOrEqual(2); // Original + Import
|
||||
expect(imported.every((b: { svg_data: string | null }) => b.svg_data !== null)).toBe(true);
|
||||
});
|
||||
|
||||
it('Import mit Müll-Payload → 400 (kein valides ZIP)', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/global-blocks/import-zip',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/zip',
|
||||
},
|
||||
payload: Buffer.from('dies ist kein zip'),
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toContain('ZIP');
|
||||
});
|
||||
|
||||
it('Import-ZIP ohne blocks.json → 400', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({ format: 'wcadlib-zip', version: 1 }));
|
||||
const buf = await zip.generateAsync({ type: 'nodebuffer' });
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/global-blocks/import-zip',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/zip',
|
||||
},
|
||||
payload: buf,
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Task H3 – Rollen: library-admin (global write) vs. user (read-only).
|
||||
*
|
||||
* requireLibraryAdmin schützt alle schreibenden Global-Library-Routen:
|
||||
* POST/PATCH/DELETE auf folders+blocks, import, import-zip. Lesen
|
||||
* (GET, export) bleibt für alle authentifizierten Nutzer offen.
|
||||
* library-admin ist an die bestehende admin-Rolle angebunden.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
||||
import { createServer } from '../src/server.js';
|
||||
|
||||
describe('Task H3: library-admin Guard', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: SqliteAdapter;
|
||||
let adminToken: string;
|
||||
let planerToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = new SqliteAdapter(':memory:');
|
||||
await db.init();
|
||||
app = await createServer({ db, port: 0 });
|
||||
await app.ready();
|
||||
|
||||
const adminReg = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: { email: 'h3-admin@example.com', password: 'Password123!', name: 'H3 Admin', role: 'admin' },
|
||||
});
|
||||
if (adminReg.statusCode !== 201) console.error('ADMIN REG FAILED:', adminReg.statusCode, adminReg.body.slice(0, 200));
|
||||
adminToken = JSON.parse(adminReg.body).session?.token ?? '';
|
||||
|
||||
const planerReg = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: { email: 'h3-planer@example.com', password: 'Password123!', name: 'H3 Planer', role: 'planer' },
|
||||
});
|
||||
if (planerReg.statusCode !== 201) console.error('PLANER REG FAILED:', planerReg.statusCode, planerReg.body.slice(0, 200));
|
||||
planerToken = JSON.parse(planerReg.body).session?.token ?? '';
|
||||
admin = { authorization: `Bearer ${adminToken}` };
|
||||
planer = { authorization: `Bearer ${planerToken}` };
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
// WICHTIG: Header-Objekte werden in beforeAll gebaut — der describe-Body
|
||||
// läuft VOR den Hooks, Template-Literale würden undefined capturen.
|
||||
let planer: Record<string, string>;
|
||||
let admin: Record<string, string>;
|
||||
|
||||
// ── Lesen bleibt offen (beide Rollen) ──
|
||||
|
||||
it('planer kann globale Ordner LESEN (200)', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/global-folders', headers: planer });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('planer kann globale Blöcke LESEN (200)', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/global-blocks', headers: planer });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('planer kann Library EXPORTIEREN (GET export, 200)', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/global-blocks/export', headers: planer });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
// ── Schreiben nur für library-admin (admin-Rolle) ──
|
||||
|
||||
it('planer POST /api/global-folders → 403', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-folders', headers: planer,
|
||||
payload: { name: 'Verboten', parent_id: null },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res.body).error).toContain('Library');
|
||||
});
|
||||
|
||||
it('planer POST /api/global-blocks → 403', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-blocks', headers: planer,
|
||||
payload: { name: 'Verboten', block_data: '[]' },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('planer PATCH /api/global-folders/:id → 403', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PATCH', url: '/api/global-folders/irgendeine-id', headers: planer,
|
||||
payload: { name: 'Hack' },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('planer PATCH /api/global-blocks/:id → 403', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PATCH', url: '/api/global-blocks/irgendeine-id', headers: planer,
|
||||
payload: { name: 'Hack' },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('planer DELETE /api/global-folders/:id → 403', async () => {
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/global-folders/irgendeine-id', headers: planer });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('planer DELETE /api/global-blocks/:id → 403', async () => {
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/global-blocks/irgendeine-id', headers: planer });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('planer POST /api/global-blocks/import → 403', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-blocks/import', headers: planer,
|
||||
payload: { format: 'wcadlib', blocks: [] },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('planer POST /api/global-blocks/import-zip → 403', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-blocks/import-zip', headers: { ...planer, 'content-type': 'application/zip' },
|
||||
payload: Buffer.from('PK'),
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
// ── Admin (library-admin) darf schreiben ──
|
||||
|
||||
it('admin POST /api/global-folders → 201 (write erlaubt)', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-folders', headers: admin,
|
||||
payload: { name: 'Admin Set', parent_id: null },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it('admin POST /api/global-blocks → 201 (write erlaubt)', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-blocks', headers: admin,
|
||||
payload: { name: 'Admin Block', block_data: '[]', folder_id: null },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
// ── Unauthentifiziert bleibt 401 ──
|
||||
|
||||
it('ohne Token POST /api/global-folders → 401 (nicht 403)', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-folders',
|
||||
payload: { name: 'Anon', parent_id: null },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
# .wcadlib – Block-Bibliotheks-Paket-Spezifikation
|
||||
|
||||
WebCAD-Block-Bibliotheken können als `.wcadlib`-Paket geteilt werden.
|
||||
Es gibt zwei Formate:
|
||||
|
||||
## Format 1: JSON-Paket (F1)
|
||||
|
||||
Ein einzelnes JSON-File mit folgender Struktur:
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "wcadlib",
|
||||
"version": 1,
|
||||
"exported_at": "2026-08-29T00:00:00.000Z",
|
||||
"folders": [
|
||||
{ "id": "f1", "name": "Veranstaltungstechnik", "parent_id": null }
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"name": "Stuhl-Reihe",
|
||||
"folder_id": "f1",
|
||||
"block_data": "[{...CADElement-Array als JSON-String...]}",
|
||||
"svg_data": "<svg ...>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `block_data` = JSON-String eines **CADElement-Arrays**
|
||||
- `svg_data` = eigenständiges SVG (Thumbnail), optional
|
||||
- Ordner-Hierarchie via `parent_id` (Import ordnet IDs neu zu)
|
||||
|
||||
**Endpoints:** `GET /api/global-blocks/export`, `POST /api/global-blocks/import`
|
||||
(schreibend: nur library-admin/Rolle admin, siehe H3)
|
||||
|
||||
## Format 2: ZIP-Paket (F7)
|
||||
|
||||
Ein echtes ZIP-Archiv mit fester Struktur:
|
||||
|
||||
```
|
||||
library.wcadlib.zip
|
||||
├── manifest.json { "format": "wcadlib-zip", "version": 1,
|
||||
│ "exported_at": "...", "block_count": N, "folder_count": M }
|
||||
├── blocks.json { "folders": [...], "blocks": [
|
||||
│ { "name", "folder_id", "block_data",
|
||||
│ "thumbnail": "thumbs/<id>.svg" } ] }
|
||||
└── thumbs/
|
||||
└── <blockId>.svg Thumbnails, Referenz aus blocks.json
|
||||
```
|
||||
|
||||
- `manifest.format` MUSS `wcadlib-zip` sein (sonst 400)
|
||||
- `blocks.json` MUSS vorhanden sein (sonst 400); `blocks`-Array Pflicht
|
||||
- `thumbnail` verweist relativ auf `thumbs/<id>.svg`
|
||||
|
||||
**Endpoints:**
|
||||
- `GET /api/global-blocks/export-zip` → `application/zip`
|
||||
- `POST /api/global-blocks/import-zip` (Body = ZIP als application/zip;
|
||||
schreibend: nur library-admin)
|
||||
|
||||
## Validierungsregeln (Import)
|
||||
|
||||
| Fehler | HTTP |
|
||||
|---|---|
|
||||
| Kein valides ZIP (Format 2) | 400 |
|
||||
| blocks.json fehlt / invalides JSON | 400 |
|
||||
| manifest.format unbekannt | 400 |
|
||||
| Kein JSON/blocks-Array (Format 1) | 400 |
|
||||
| Nicht authentifiziert | 401 |
|
||||
| Rolle != admin (schreibend) | 403 |
|
||||
|
||||
## CAD-Element-Konventionen (block_data)
|
||||
|
||||
- `rect`: `x/y` = **Zentrum** der BBox
|
||||
- `circle`/`arc`: `el.x/el.y` = **Zentrum**, Winkel in Grad (0° = 3 Uhr, CCW)
|
||||
- `line`: `properties.x1/y1/x2/y2`
|
||||
- `polyline`/`polygon`: `properties.points`
|
||||
- `block_instance`: `properties.blockId` + optional `attrValues` (D12)
|
||||
|
||||
Beim Drop auf die Zeichenfläche werden Element-IDs neu generiert und
|
||||
die Position per Drop-Punkt übersetzt – der Bestand bleibt unverändert.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Plugin Guide – WebCAD Plugin API v2
|
||||
|
||||
WebCAD Plugin API v2 erlaubt die Erweiterung des CAD um Werkzeuge,
|
||||
Panels und Block-Bibliotheken – ohne Kerncode anzufassen.
|
||||
|
||||
## Plugin-Typ (PluginV2)
|
||||
|
||||
```ts
|
||||
import type { PluginV2, ToolExtensionV2 } from './plugins';
|
||||
|
||||
export const myPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'my-tools',
|
||||
name: 'Meine Werkzeuge',
|
||||
version: '1.0.0',
|
||||
author: 'Du',
|
||||
description: 'Beschreibung',
|
||||
category: 'tools', // tools|elements|library|import-export|theme|other
|
||||
enabledByDefault: true,
|
||||
},
|
||||
tools: [/* ToolExtensionV2[] */],
|
||||
panels: [/* PanelExtension[] (optional) */],
|
||||
libraryProviders: [/* LibraryProviderExtension[] (optional) */],
|
||||
onInit?() {}, onActivate?() {}, onDeactivate?() {}, onDestroy?() {},
|
||||
};
|
||||
```
|
||||
|
||||
## Werkzeug (ToolExtensionV2)
|
||||
|
||||
Ein Werkzeug besteht aus Manifest, Options-Schema und Handlern:
|
||||
|
||||
```ts
|
||||
export const myTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'my-tool',
|
||||
label: 'Mein Werkzeug',
|
||||
icon: 'M',
|
||||
ribbonTab: 'canvas', // canvas|insert|format
|
||||
tags: ['basic'], // basic = sichtbar im simple-Mode, ['pro'] nur pro-Mode
|
||||
description: 'Was es tut',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'width', label: 'Breite', type: 'number', min: 1, max: 500 },
|
||||
{ key: 'color', label: 'Farbe', type: 'color' },
|
||||
{ key: 'style', label: 'Stil', type: 'select',
|
||||
options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] },
|
||||
{ key: 'frame', label: 'Rahmen', type: 'checkbox' },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
// e.world = Weltkoordinaten, e.shift, e.ctrl
|
||||
// ctx.doc = CADDocument (CRUD + transact + undo)
|
||||
// ctx.setStatus('...'), ctx.setPreview?.(element)
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) return;
|
||||
c.doc.transact(() => c.doc!.addElement({ /* CADElement */ }));
|
||||
},
|
||||
move(e, ctx) { /* Live-Preview */ },
|
||||
up(e, ctx) { /* Commit */ },
|
||||
key(k, ctx) { /* return true = konsumiert (z. B. Enter) */ },
|
||||
cancel(ctx) { /* ESC/Rechtsklick: Session-State zurücksetzen */ },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Kernregeln
|
||||
|
||||
1. **Eine Aktion = eine `doc.transact(() => ...)`** = genau ein Undo-Schritt.
|
||||
2. **Session-State als Modul-Member** (nicht in `options` – diese sind geteilter UI-State).
|
||||
3. **`cancel` setzt den Session-State immer zurück.**
|
||||
4. **Koordinaten-Konventionen:** rect `x/y` = Zentrum der BBox; circle/arc `el.x/el.y` = Zentrum; Winkel in Grad (0° = 3 Uhr, CCW).
|
||||
5. **Undo-fähig schreiben** nur über `doc.addElement/updateElement/deleteElements`.
|
||||
|
||||
## Bibliotheks-Provider (LibraryProviderExtension)
|
||||
|
||||
```ts
|
||||
const myProvider: LibraryProviderExtension = {
|
||||
id: 'my-catalog',
|
||||
label: 'Mein Katalog',
|
||||
async listFolders() { return [{ id: 'f1', label: 'Ordner', parentId: null }]; },
|
||||
async listBlocks(folderId) { return [/* LibBlock[] */]; },
|
||||
async getBlock(id) { return /* LibBlock */; },
|
||||
};
|
||||
```
|
||||
|
||||
`LibBlock.payload` = JSON-String eines CADElement-Arrays → Drag&Drop
|
||||
in die Zeichenfläche funktioniert automatisch über den bestehenden
|
||||
Drop-Kanal. Thumbnail = eigenständiges SVG.
|
||||
|
||||
## Registrierung
|
||||
|
||||
Built-in-Plugins werden in `src/plugins/index.ts` per
|
||||
`pluginRegistry.registerV2(plugin)` registriert; `enabledByDefault`
|
||||
macht sie aktiv (alternativ per `pluginRegistry.enableV2(id)`).
|
||||
|
||||
## Beispiel: Minimal-Plugin
|
||||
|
||||
```ts
|
||||
// plugins/builtin/stamp/index.ts
|
||||
import type { PluginV2, ToolExtensionV2 } from '../../types';
|
||||
|
||||
const stampTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'stamp', label: 'Stempel', icon: '\u25c6',
|
||||
ribbonTab: 'insert', tags: ['basic'],
|
||||
description: 'Platziert ein Quadrat',
|
||||
},
|
||||
optionsSchema: [{ key: 'size', label: 'Größe', type: 'number', min: 10, max: 200 }],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as any;
|
||||
if (!c.doc) return;
|
||||
const size = (ctx.options.size as number | undefined) ?? 50;
|
||||
c.doc.transact(() => c.doc.addElement({
|
||||
id: `stamp_${Date.now()}`,
|
||||
type: 'rect', layerId: 'layer-0',
|
||||
x: e.world.x, y: e.world.y,
|
||||
width: size, height: size,
|
||||
properties: {},
|
||||
}));
|
||||
ctx.setStatus('Stempel platziert');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const stampPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'stamp', name: 'Stempel', version: '1.0.0', author: 'WebCAD',
|
||||
description: 'Beispielplugin', category: 'elements', enabledByDefault: true,
|
||||
},
|
||||
tools: [stampTool],
|
||||
};
|
||||
```
|
||||
|
||||
Registrieren in `src/plugins/index.ts`:
|
||||
|
||||
```ts
|
||||
pluginRegistry.registerV2(stampPlugin);
|
||||
```
|
||||
@@ -9,7 +9,7 @@
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ci": "playwright test playwright-tests/diagnose-editor.test.ts playwright-tests/tool-exhaustive.test.ts"
|
||||
"test:e2e:ci": "playwright test playwright-tests/diagnose-editor.test.ts playwright-tests/tool-exhaustive.test.ts playwright-tests/perf-gate.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"dxf-parser": "^1.1.2",
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* CAD-14 Beweis-Test: Der Workflow, den der Nutzer jeden Tag macht.
|
||||
* 1. Werkzeug waehlen (Sidebar), 2. zeichnen (2 Klicks),
|
||||
* 3. EXAKT EIN Element an der geklickten Position erwarten.
|
||||
* ROT-Zustand (jetzt): 2 Elemente (Legacy + V2 je eins) = Doppelung belegt.
|
||||
* GRUEN nach Schnitt: 1 Element, Position = Klickposition.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const BASE_URL = process.env.FRONTEND_URL || 'https://web-cad-neu.server.media-on.de';
|
||||
const API_URL = process.env.API_URL || 'https://web-cad-neu.server.media-on.de';
|
||||
|
||||
async function api(method: string, endpoint: string, body?: unknown, token?: string) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const res = await fetch(`${API_URL}${endpoint}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text ? JSON.parse(text) : null };
|
||||
}
|
||||
|
||||
interface VerifyWindow {
|
||||
__v2Debug?: { info: () => { cachedElements: number; ready: boolean } | null };
|
||||
}
|
||||
|
||||
test('zeichnen erzeugt GENAU EIN Element an der Klick-Position', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await page.goto(BASE_URL);
|
||||
await page.fill('input[type=email]', 'admin@media-on.de');
|
||||
await page.fill('input[type=password]', 'WebCAD2026!');
|
||||
await page.click('button[type=submit]');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// CAD-14: Frisches Projekt + Zeichnung -> Basis 0, Zuordnung eindeutig
|
||||
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
|
||||
expect(token).toBeTruthy();
|
||||
const uniq = Date.now();
|
||||
const projRes = await fetch(`${BASE_URL}/api/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name: `CAD14-${uniq}`, description: 'proof' }),
|
||||
});
|
||||
const proj = await projRes.json();
|
||||
expect(proj?.id).toBeTruthy();
|
||||
const drawingRes = await fetch(`${BASE_URL}/api/projects/${proj.id}/drawings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name: `CAD14-Proof-${uniq}` }),
|
||||
});
|
||||
const drawing = await drawingRes.json();
|
||||
expect(drawing?.id).toBeTruthy();
|
||||
const drawingId: string = drawing.id;
|
||||
const beforeDbCount = 0;
|
||||
|
||||
// Karte des frischen Projekts oeffnen (eindeutig per Name) — Dashboard
|
||||
// laedt die Liste beim Mount, daher Reload nach API-Anlage.
|
||||
await page.reload();
|
||||
await page.waitForTimeout(2500);
|
||||
const card = page.locator(`.dashboard-project-card, [class*=project-card]`).filter({ hasText: `CAD14-${uniq}` }).first();
|
||||
await card.click();
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const verify = () => page.evaluate(() => {
|
||||
const w = window as unknown as VerifyWindow;
|
||||
return w.__v2Debug?.info?.() ?? null;
|
||||
});
|
||||
|
||||
const before = await verify();
|
||||
const beforeCount = before?.cachedElements ?? -1;
|
||||
expect(beforeCount, 'Pixi muss ready sein').toBeGreaterThanOrEqual(0);
|
||||
|
||||
// DB-Basis ist 0 (frische Zeichnung)
|
||||
|
||||
// Linie-Werkzeug aus der SIDEBAR (der richtige Ort)
|
||||
await page.locator('[data-tool="line"]').first().click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Auf die Zeichenflaeche klicken: zwei Klicks = eine Linie
|
||||
const canvas = page.locator('canvas').last();
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error('Kein Canvas gefunden');
|
||||
const cx = box.x + box.width / 2;
|
||||
const cy = box.y + box.height / 2;
|
||||
// CAD-14: line ist ein DRAG-Werkzeug (down -> move -> up committet).
|
||||
const startX = cx - 60;
|
||||
const startY = cy - 30;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX + 120, startY + 60, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const after = await verify();
|
||||
const afterCount = after?.cachedElements ?? -1;
|
||||
|
||||
// ABNAHME 1: genau +1 Element im Renderer
|
||||
expect(afterCount - beforeCount, `Erwartet genau 1 neues Element, got ${afterCount - beforeCount}`).toBe(1);
|
||||
|
||||
// ABNAHME 2: das Element MUSS in der DB persistiert sein (Autosave/Save-Kette)
|
||||
// drawingId aus Scope (frisch angelegt)
|
||||
// Autosave braucht bis zu 30s — poll bis zu 40s
|
||||
// CAD-14: ALLE Drawings des Proof-Projekts pruefen (die App koennte in
|
||||
// eine auto-angelegte Zeichnung schreiben statt in meine).
|
||||
let dbCount = -1;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.waitForTimeout(5000);
|
||||
const drs = await (await fetch(`${BASE_URL}/api/projects/${proj.id}/drawings`, { headers: { Authorization: `Bearer ${token}` } })).json();
|
||||
let total = 0;
|
||||
for (const d of drs) {
|
||||
const raw = await (await fetch(`${BASE_URL}/api/drawings/${d.id}/elements`, { headers: { Authorization: `Bearer ${token}` } })).json();
|
||||
total += raw.length;
|
||||
// CAD-14: rohe DB-Zeile hat properties_json (String) — parse und
|
||||
// melde Elemente mit chordCount (fuer die Options-Abnahme unten).
|
||||
for (const e of raw) {
|
||||
const parsed = typeof e.properties_json === 'string' ? JSON.parse(e.properties_json) : (e.properties_json ?? {});
|
||||
if (parsed && parsed.chordCount !== undefined) {
|
||||
console.log(`ELEMENT MIT CHORDCOUNT in ${d.id}:`, parsed.chordCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
dbCount = total;
|
||||
if (total >= 1) break;
|
||||
}
|
||||
expect(dbCount, `Genau 1 Element muss in der DB landen (vor ${beforeDbCount}, nach ${dbCount})`).toBe(1);
|
||||
|
||||
// ABNAHME 3: V2-Select-Tool klickt auf die Linienmitte — Auswahl muss
|
||||
// auf dem V2-Dokument landen (Karte: __v2GetSelection).
|
||||
await page.locator('[data-tool="select"]').first().click();
|
||||
await page.waitForTimeout(300);
|
||||
const midX = startX + 60;
|
||||
const midY = startY + 30;
|
||||
await page.mouse.click(midX, midY);
|
||||
await page.waitForTimeout(500);
|
||||
const sel = await page.evaluate(() => {
|
||||
const w = window as unknown as { __v2GetSelection?: () => { ids: string[] } };
|
||||
return w.__v2GetSelection?.() ?? null;
|
||||
});
|
||||
expect(sel, 'Selection-Bridge muss erreichbar sein').toBeTruthy();
|
||||
expect(sel!.ids.length, `Genau 1 Element muss ausgewaehlt sein, got ${sel!.ids.length}`).toBe(1);
|
||||
|
||||
// ABNAHME 4: Tool-Optionen erreichen das Werkzeug (Phase 4a).
|
||||
// Traversen-Werkzeug waehlen, chordCount=3 im Options-Panel setzen,
|
||||
// Traverse ziehen, properties.chordCount muss 3 sein.
|
||||
await page.locator('[data-v2tool="truss"], [data-tool="truss"]').first().click();
|
||||
await page.waitForTimeout(300);
|
||||
const chordInput = page.locator('.tool-options-panel input[type=number]').first();
|
||||
// chordCount-Feld ueber Label finden (Gurtrohre)
|
||||
const chordRow = page.locator('.toolopt-row', { hasText: 'Gurtrohre' });
|
||||
await chordRow.locator('input').fill('3');
|
||||
await page.waitForTimeout(300);
|
||||
// Traverse ziehen (wie Linie)
|
||||
const tStart = cx - 160;
|
||||
const tStartY = cy + 120;
|
||||
await page.mouse.move(tStart, tStartY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(tStart + 150, tStartY, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800);
|
||||
const trussEl = await page.evaluate(() => {
|
||||
const w = window as unknown as { __cad14Truss?: { id: string; chordCount: number } | null };
|
||||
return w.__cad14Truss ?? null;
|
||||
});
|
||||
// Alternativ: ueber die DB pruefen (Autosave-Poll) — rohe DB-Zeile hat
|
||||
// properties_json (String), nicht properties.
|
||||
let chordInDb: unknown = null;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.waitForTimeout(5000);
|
||||
const drs = await (await fetch(`${BASE_URL}/api/projects/${proj.id}/drawings`, { headers: { Authorization: `Bearer ${token}` } })).json();
|
||||
let found = false;
|
||||
for (const d of drs) {
|
||||
const raw = await (await fetch(`${BASE_URL}/api/drawings/${d.id}/elements`, { headers: { Authorization: `Bearer ${token}` } })).json();
|
||||
for (const e of raw) {
|
||||
const parsed = typeof e.properties_json === 'string' ? JSON.parse(e.properties_json) : (e.properties_json ?? {});
|
||||
if (String(e.type) === 'truss' && (parsed as any)?.chordCount === 3) {
|
||||
chordInDb = parsed.chordCount;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
if (chordInDb !== null) break;
|
||||
}
|
||||
expect(String(chordInDb), 'chordCount=3 muss beim gezeichneten Element ankommen').toBe('3');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
+87
-3
@@ -6,7 +6,7 @@ import type {
|
||||
import type { CADElement, CADLayer, BlockDefinition, ImageElement } from './types/cad.types';
|
||||
import { BlockService } from './services/blockService';
|
||||
import { SeatingService } from './services/seatingService';
|
||||
import type { ToolState } from './interaction';
|
||||
import type { ToolState } from './types/ui.types';
|
||||
import { GroupManager, type ElementGroup } from './tools/modification/GroupTool';
|
||||
import Topbar from './components/Topbar';
|
||||
import RibbonBar from './components/RibbonBar';
|
||||
@@ -15,8 +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 BOMDialog from './components/BOMDialog';
|
||||
import { createAutosaveController } from './services/autosaveService';
|
||||
import { createKeyboardShortcuts } from './services/keyboardShortcuts';
|
||||
import { getActiveDocument } from './kernel/document/documentService';
|
||||
import HistoryPanel from './components/HistoryPanel';
|
||||
import V2HistoryPanel from './components/V2HistoryPanel';
|
||||
import GuestPanel from './components/GuestPanel';
|
||||
@@ -145,8 +150,8 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
|
||||
useEffect(() => {
|
||||
pluginRegistry.setContext(createPluginContext());
|
||||
registerBuiltinPlugins();
|
||||
pluginRegistry.initDefaults();
|
||||
// CAD-14 Audit-Fix: main.tsx initialisiert die Registry bereits vor dem
|
||||
// Render — hier nur Context nachziehen, kein Doppel-Register (Warnungen).
|
||||
}, [createPluginContext]);
|
||||
|
||||
// Status bar – only count self as online when actually connected
|
||||
@@ -177,8 +182,78 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
|
||||
// History panel
|
||||
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
||||
const [bomDialogOpen, setBomDialogOpen] = useState(false);
|
||||
const [guestPanelOpen, setGuestPanelOpen] = useState(false);
|
||||
|
||||
// ── Task CAD-9: BOM-Action ──
|
||||
useEffect(() => {
|
||||
if (!drawingId || !token) return;
|
||||
// noop: BOM-Action wird über handleRibbonAction verdrahtet (unten)
|
||||
}, [drawingId, token]);
|
||||
|
||||
// ── Task CAD-12a/12b: Autosave + Keyboard-Shortcuts ──
|
||||
useEffect(() => {
|
||||
if (!drawingId || !token) return;
|
||||
|
||||
// Autosave: Status im Topbar sichtbar machen (CAD-12a)
|
||||
const autosave = createAutosaveController({
|
||||
isConnected: () => collab.status === 'connected',
|
||||
hasUnsavedChanges: () => elements.length > 0,
|
||||
performFullSave: async () => {
|
||||
await Promise.all(
|
||||
elements.map((el) =>
|
||||
updateElement(token, el.id, el).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
},
|
||||
onStatusChange: (s) => {
|
||||
if (s.state === 'saving') setSavedStatus('Speichert…');
|
||||
else if (s.state === 'saved') setSavedStatus('Autom. gespeichert');
|
||||
else if (s.state === 'offline') setSavedStatus('Offline');
|
||||
},
|
||||
});
|
||||
|
||||
// Keyboard-Shortcuts (CAD-12b)
|
||||
const ks = createKeyboardShortcuts({
|
||||
escape: () => setActiveTool('select'),
|
||||
delete: () => {
|
||||
const ids = selectedElementIdsRef.current;
|
||||
if (ids.length > 0) handleElementsDeleted(ids);
|
||||
},
|
||||
selectAll: () => {
|
||||
setSelectedElementIds(elements.map((el) => el.id));
|
||||
},
|
||||
duplicate: () => {
|
||||
const ids = selectedElementIdsRef.current;
|
||||
if (ids.length > 0) {
|
||||
elements
|
||||
.filter((el) => ids.includes(el.id))
|
||||
.forEach((el) => {
|
||||
const copy = {
|
||||
...el,
|
||||
id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
||||
x: el.x + 20,
|
||||
y: el.y + 20,
|
||||
} as typeof el;
|
||||
collab.setElement(copy);
|
||||
});
|
||||
}
|
||||
},
|
||||
toggleGrid: () => setGridEnabled((g) => !g),
|
||||
toggleOrtho: () => setOrthoEnabled((o) => !o),
|
||||
toggleSnap: () => setSnapEnabled((s) => !s),
|
||||
});
|
||||
|
||||
const keyHandler = (e: KeyboardEvent) => ks.handler(e);
|
||||
document.addEventListener('keydown', keyHandler);
|
||||
|
||||
return () => {
|
||||
autosave.destroy();
|
||||
document.removeEventListener('keydown', keyHandler);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [drawingId, token, collab.status]);
|
||||
|
||||
// Export format selector (cross-browser replacement for nwsave)
|
||||
const [exportFormatOpen, setExportFormatOpen] = useState(false);
|
||||
|
||||
@@ -1163,6 +1238,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
|
||||
// ─── Background ───
|
||||
if (action === 'background-import') { setBgImportOpen(true); return; }
|
||||
if (action === 'bom') { setBomDialogOpen(true); return; }
|
||||
|
||||
// ─── KI actions ───
|
||||
if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; }
|
||||
@@ -1656,6 +1732,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
<RightSidebar
|
||||
activePanel={activeRightPanel}
|
||||
onPanelChange={setActiveRightPanel}
|
||||
activeToolId={activeTool}
|
||||
selectedElement={selectedElement}
|
||||
layers={layers}
|
||||
blocks={blocks}
|
||||
@@ -1704,6 +1781,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
history={commandHistory}
|
||||
onCommand={handleCommand}
|
||||
/>
|
||||
<LayoutBar />
|
||||
<StatusBar
|
||||
snapEnabled={snapEnabled}
|
||||
orthoEnabled={orthoEnabled}
|
||||
@@ -1747,6 +1825,12 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
onApply={handleBgApply}
|
||||
backgroundService={bgServiceRef.current}
|
||||
/>
|
||||
{bomDialogOpen && getActiveDocument() && (
|
||||
<BOMDialog
|
||||
elements={getActiveDocument()!.getAllElements()}
|
||||
onClose={() => setBomDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{historyPanelOpen && (V2_TOOLS_ENABLED ? (
|
||||
<V2HistoryPanel
|
||||
onClose={() => setHistoryPanelOpen(false)}
|
||||
|
||||
@@ -55,6 +55,14 @@ export class RenderEngine {
|
||||
/** src des zuletzt erfolgreich geladenen Hintergrundbilds. */
|
||||
private bgSrcLoaded = '';
|
||||
private dpr = 1;
|
||||
/** CAD-13-fix: true = Legacy-Canvas zeichnet KEINE Elemente (Pixi-Overlay zeichnet sie). Grid/Hintergrund bleiben. */
|
||||
skipElementRendering = false;
|
||||
/** CAD-14: Elemente-Spiegel fuer Auswahl-Griffe im Pixi-Modus (RenderEngine zeichnet Elemente nicht, aber die Griffe). */
|
||||
private overlayElements: CADElement[] = [];
|
||||
/** CAD-14: Setzt die Elemente, deren Auswahl-Griffe im Overlay gezeichnet werden. */
|
||||
setOverlayElements(els: CADElement[]): void {
|
||||
this.overlayElements = els;
|
||||
}
|
||||
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
@@ -161,11 +169,20 @@ export class RenderEngine {
|
||||
if (this.options.showGrid) this.drawGrid(w, h);
|
||||
if (this.options.backgroundSrc) this.drawBackground();
|
||||
this.drawRuler(w, h);
|
||||
|
||||
// CAD-14: skipElementRendering = Pixi rendert die Elemente. Das Legacy-
|
||||
// Canvas zeichnet KEINE Elemente mehr — ABER weiterhin alle Overlays
|
||||
// (Vorschau, Auswahl-Griffe, Auswahl-Rechteck, Snap-Punkte, Ortho),
|
||||
// sonst waere Auswahl im Pixi-Modus unsichtbar.
|
||||
const skipElements = this.skipElementRendering;
|
||||
|
||||
const viewport = this.zoomPan.getViewport();
|
||||
const visibleElements = this.spatialIndex.search({
|
||||
minX: viewport.minX, minY: viewport.minY,
|
||||
maxX: viewport.maxX, maxY: viewport.maxY,
|
||||
});
|
||||
const visibleElements = skipElements
|
||||
? []
|
||||
: this.spatialIndex.search({
|
||||
minX: viewport.minX, minY: viewport.minY,
|
||||
maxX: viewport.maxX, maxY: viewport.maxY,
|
||||
});
|
||||
|
||||
const layers = this.layerManager.getLayers();
|
||||
for (const layer of layers) {
|
||||
@@ -176,6 +193,21 @@ export class RenderEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// CAD-14: Auswahl-Griffe auch fuer Pixi-gerenderte Elemente weiterhin
|
||||
// auf dem Overlay-Canvas zeigen (Elemente selbst kommen von Pixi).
|
||||
if (skipElements && this.selection.selectedIds.size > 0) {
|
||||
const hs = this.zoomPan.getScale();
|
||||
const hox = this.zoomPan.getTransform().e;
|
||||
const hoy = this.zoomPan.getTransform().f;
|
||||
const hsx = (val: number) => val * hs + hox;
|
||||
const hsy = (val: number) => val * hs + hoy;
|
||||
for (const el of this.overlayElements) {
|
||||
if (this.selection.selectedIds.has(el.id)) {
|
||||
this.drawSelectionHandles(el, hsx, hsy, hs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw preview element with dashed style
|
||||
if (this.previewElement) {
|
||||
this.ctx.save();
|
||||
|
||||
@@ -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,54 @@ 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 };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// Task CAD-4: OTRACK — Objekt-Fang-Tracking. computeTrackPoint
|
||||
// projiziert den Cursor auf Tracking-Linien durch den Basispunkt
|
||||
// (Default 0/90/180/270°, optional erweiterte Winkel) und liefert
|
||||
// den nächstgelegenen Track-Punkt. Reine Funktion (UI-testbar).
|
||||
// ───────────────────────────────────────────────────
|
||||
|
||||
export function computeTrackPoint(
|
||||
base: { x: number; y: number },
|
||||
cursor: { x: number; y: number },
|
||||
angles?: number[],
|
||||
): { x: number; y: number } | null {
|
||||
const dx = cursor.x - base.x;
|
||||
const dy = cursor.y - base.y;
|
||||
if (dx === 0 && dy === 0) return null;
|
||||
const angs = angles ?? [0, 90, 180, 270];
|
||||
let best: { x: number; y: number } | null = null;
|
||||
let bestDist = Infinity;
|
||||
for (const a of angs) {
|
||||
const rad = (a * Math.PI) / 180;
|
||||
const dxc = Math.cos(rad);
|
||||
const dyc = Math.sin(rad);
|
||||
let pt: { x: number; y: number };
|
||||
if (Math.abs(dxc) >= Math.abs(dyc)) {
|
||||
if (dxc === 0) continue;
|
||||
const t = dx / dxc;
|
||||
pt = { x: cursor.x, y: base.y + t * dyc };
|
||||
} else {
|
||||
if (dyc === 0) continue;
|
||||
const t = dy / dyc;
|
||||
pt = { x: base.x + t * dxc, y: cursor.y };
|
||||
}
|
||||
const dist = Math.hypot(pt.x - cursor.x, pt.y - cursor.y);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = pt;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Task CAD-9 – Stuecklisten-Dialog fuer Traversen-Systeme.
|
||||
*
|
||||
* Zeigt die gruppierte Stueckliste (Traversen + Eck-Traversen)
|
||||
* als Tabelle mit Summenzeile; CSV-Download als Datei.
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import { buildBOM, exportBOMCsv } from '../services/bomService';
|
||||
|
||||
interface BOMDialogProps {
|
||||
elements: import('../types/cad.types').CADElement[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const BOMDialog: React.FC<BOMDialogProps> = ({ elements, onClose }) => {
|
||||
const bom = useMemo(() => buildBOM(elements), [elements]);
|
||||
|
||||
const handleDownload = () => {
|
||||
const csv = exportBOMCsv(bom);
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `stueckliste-traversen-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bom-dialog-overlay" onClick={onClose}>
|
||||
<div className="bom-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="bom-dialog-header">
|
||||
<h3>Stückliste – Traversen</h3>
|
||||
<button className="bom-close" onClick={onClose} title="Schließen">✕
|
||||
</button>
|
||||
</div>
|
||||
{bom.rows.length === 0 ? (
|
||||
<div className="bom-empty">
|
||||
Keine Traversen oder Eck-Traversen in der Zeichnung gefunden.
|
||||
<br />
|
||||
Platziere zuerst Traversen mit dem Traverse- oder Eck-Traverse-Werkzeug.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="bom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pos</th>
|
||||
<th>Typ</th>
|
||||
<th>Länge (cm)</th>
|
||||
<th>Breite (cm)</th>
|
||||
<th>Anzahl</th>
|
||||
<th>Gesamt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bom.rows.map((r) => (
|
||||
<tr key={`${r.pos}-${r.label}`}>
|
||||
<td>{r.pos}</td>
|
||||
<td>{r.type}</td>
|
||||
<td>{r.length}</td>
|
||||
<td>{r.width}</td>
|
||||
<td>{r.count}</td>
|
||||
<td>{(r.totalCm / 100).toFixed(2)} m</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bom-sum-row">
|
||||
<td colSpan={5}>
|
||||
<strong>SUMME</strong>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{bom.totalLengthM.toFixed(2)} m</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="bom-dialog-actions">
|
||||
<button className="bom-btn-primary" onClick={handleDownload}>
|
||||
CSV herunterladen
|
||||
</button>
|
||||
<button className="bom-btn-secondary" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMDialog;
|
||||
@@ -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,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
setGlobalBlockFavorite,
|
||||
exportLibrary,
|
||||
importLibrary,
|
||||
exportLibraryZip,
|
||||
importLibraryZip,
|
||||
} from '../services/api';
|
||||
import { getActiveDocument } from '../kernel/document/documentService';
|
||||
import { generateBlockSvg } from '../utils/blockThumbnail';
|
||||
@@ -45,6 +48,7 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
||||
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 ───────────
|
||||
@@ -113,6 +117,34 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
||||
|
||||
// ─── 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);
|
||||
@@ -274,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]);
|
||||
@@ -312,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]);
|
||||
@@ -515,6 +549,20 @@ 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="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"
|
||||
@@ -524,6 +572,13 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
||||
</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
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ToolType } from '../types/cad.types';
|
||||
import type { UserCursor } from '../crdt';
|
||||
import { RenderEngine } from '../canvas/RenderEngine';
|
||||
import { ZoomPanController } from '../canvas/ZoomPanController';
|
||||
import { InteractionEngine } from '../interaction';
|
||||
|
||||
import { SnapEngine } from '../canvas/SnapEngine';
|
||||
import { SelectionEngine } from '../canvas/SelectionEngine';
|
||||
import { SpatialIndex } from '../canvas/SpatialIndex';
|
||||
@@ -20,6 +20,7 @@ import { setActiveDocument, getActiveDocument } from '../kernel/document/documen
|
||||
import { V2_TOOLS_ENABLED, USE_PIXI_RENDERER } from '../kernel/featureFlags';
|
||||
import { PixiRenderer } from '../render/pixi/PixiRenderer';
|
||||
import { pluginRegistry } from '../plugins';
|
||||
import { getToolOptions, setToolOptionsDefaults, defaultsFromSchema } from '../services/toolOptionsService';
|
||||
|
||||
const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
|
||||
@@ -33,14 +34,30 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
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). */
|
||||
/** CAD-13-fix: Bereits in Pixi gespiegelte Bestands-Elemente (id -> Referenz). */
|
||||
const pixiMirrorRef = useRef<Map<string, CADElement>>(new Map());
|
||||
/** CAD-14: Bereits an onElementCreated gemeldete V2-Element-IDs (verhindert Doppelmeldung bei jedem onChanged-Frame). */
|
||||
const announcedV2Ids = useRef<Set<string>>(new Set());
|
||||
/** CAD-14: Frische Prop-Referenz gegen Stale-Closure (Dispatcher/onChanged sind einmalig registriert). */
|
||||
const onElementCreatedRef = useRef(onElementCreated);
|
||||
onElementCreatedRef.current = onElementCreated;
|
||||
/** CAD-14: Frische Prop-Referenz für Selection-Änderungen aus V2-Werkzeugen. */
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
/** CAD-14 Phase 4c: Frische Cursor-Meldung (Dispatcher-nativ, kein Legacy-Callback). */
|
||||
const onCursorMovedRef = useRef(onCursorMoved);
|
||||
onCursorMovedRef.current = onCursorMoved;
|
||||
/** CAD-14 Phase 4c: Snap/Ortho-Zustände stale-sicher für den Dispatcher. */
|
||||
const snapEnabledRef = useRef(snapEnabled);
|
||||
snapEnabledRef.current = snapEnabled;
|
||||
const orthoRef = useRef(orthoEnabled);
|
||||
orthoRef.current = orthoEnabled;
|
||||
const spatialIndexRef = useRef<SpatialIndex | null>(null);
|
||||
const layerManagerRef = useRef<LayerManager | null>(null);
|
||||
const selectionEngineRef = useRef<SelectionEngine | null>(null);
|
||||
@@ -55,9 +72,6 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
const renderEngine = new RenderEngine(canvas, zoomPan, spatialIndex, layerManager);
|
||||
const snapEngine = new SnapEngine();
|
||||
const selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager);
|
||||
const interaction = new InteractionEngine(
|
||||
canvas, zoomPan, renderEngine, snapEngine, selectionEngine, spatialIndex, layerManager,
|
||||
);
|
||||
|
||||
// ── Task A2/A4.14: V2-Pfad ist seit Verifikation STANDARD — Opt-out via
|
||||
// VITE_TOOL_DISPATCHER=legacy. Siehe kernel/featureFlags.ts.
|
||||
@@ -82,21 +96,32 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
pixi = new PixiRenderer();
|
||||
pixiRendererRef.current = pixi;
|
||||
pixi.init(pixiCanvas);
|
||||
// CAD-13-fix: Pixi rendert jetzt die Elemente — Legacy-Canvas
|
||||
// stellt nur noch Grid/Hintergrund/Lineal dar (sonst doppelt).
|
||||
renderEngine.skipElementRendering = true;
|
||||
pixi.waitForReady().then(() => {
|
||||
// Task C4: Debug-Hook für Playwright-E2E-Assertions
|
||||
(window as unknown as { __v2Debug: unknown }).__v2Debug = {
|
||||
renderer: pixi,
|
||||
info: () => pixiRendererRef.current?.getDebugInfo() ?? null,
|
||||
docCount: () => v2Doc.getAllElements().length,
|
||||
announcedCount: () => announcedV2Ids.current.size,
|
||||
};
|
||||
}).catch((err) => {
|
||||
logger.warn('Pixi init failed — V2-Overlay deaktiviert:', err);
|
||||
pixi = null;
|
||||
pixiRendererRef.current = null;
|
||||
// CAD-13-fix Fallback: Ohne Pixi muss Legacy wieder Elemente zeichnen
|
||||
renderEngine.skipElementRendering = false;
|
||||
renderEngine.render();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Pixi init exception — V2-Overlay deaktiviert:', err);
|
||||
pixi = null;
|
||||
pixiRendererRef.current = null;
|
||||
// CAD-13-fix Fallback: Ohne Pixi muss Legacy wieder Elemente zeichnen
|
||||
renderEngine.skipElementRendering = false;
|
||||
renderEngine.render();
|
||||
}
|
||||
}
|
||||
/** Letztes bekanntes Elementobjekt je ID (Referenzdiff statt JSON). */
|
||||
@@ -135,7 +160,9 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
for (const el of v2Doc.getAllElements()) {
|
||||
if (!announcedV2Ids.current.has(el.id)) {
|
||||
announcedV2Ids.current.add(el.id);
|
||||
onElementCreated(el);
|
||||
// CAD-14: ueber Ref — die eingefrorene Prop haette eine alte
|
||||
// drawingId-Closure (still kein DB-Save nach Spaet-Mount).
|
||||
onElementCreatedRef.current(el);
|
||||
}
|
||||
}
|
||||
syncPixi(); // Task C2: V2-Elemente in den PixiJS-Overlay-Renderer spiegeln
|
||||
@@ -143,18 +170,63 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
|
||||
dispatcherRef.current = new InteractionDispatcher(canvas, {
|
||||
zoomPan,
|
||||
// CAD-14 Phase 4c: Snap aus der bestehenden SnapEngine (grid-snaps),
|
||||
// Ortho via Ref (stale-sicher).
|
||||
snap: (x: number, y: number) => {
|
||||
if (!snapEnabledRef.current) return { x, y };
|
||||
const result = snapEngine.snap(x, y);
|
||||
if (result.point) {
|
||||
renderEngine.setSnapPoints(result.preview);
|
||||
renderEngine.setActiveSnapPoint(result.point);
|
||||
return { x: result.point.x, y: result.point.y };
|
||||
}
|
||||
renderEngine.setSnapPoints([]);
|
||||
renderEngine.setActiveSnapPoint(null);
|
||||
return { x, y };
|
||||
},
|
||||
onCursorMoved: (wx: number, wy: number) => onCursorMovedRef.current?.(wx, wy),
|
||||
// CAD-14 Phase 4c: Ortho-Clamp gegen den letzten gesnappten
|
||||
// Down-Punkt (Legacy-Konvention) — Referenz verwaltet der Dispatcher.
|
||||
ortho: (x: number, y: number, last: { x: number; y: number } | null) => {
|
||||
if (!orthoRef.current || !last) return { x, y };
|
||||
const dx = Math.abs(x - last.x);
|
||||
const dy = Math.abs(y - last.y);
|
||||
return dx >= dy ? { x, y: last.y } : { x: last.x, y };
|
||||
},
|
||||
requestRender: () => renderEngine.render(),
|
||||
setStatus: (msg) => {
|
||||
/* StatusBar-Bridge folgt in A4 */
|
||||
},
|
||||
setPreview: (el) => renderEngine.setPreviewElement(el as CADElement | null),
|
||||
getDoc: () => v2Doc,
|
||||
// CAD-14 Phase 4a: Werkzeug-Optionen aus dem zentralen Store
|
||||
// (Laenge/Breite/chordCount/Winkel... erreichen die V2-Werkzeuge).
|
||||
getOptions: (toolId: string) => getToolOptions(toolId),
|
||||
getSelectionBridge: () => ({
|
||||
getIds: () => Array.from(selectionEngine.getSelectedIds()),
|
||||
setIds: (ids) => selectionEngine.selectByIds(ids, false),
|
||||
hitTest: (wx, wy, tol) => renderEngine.hitTest(wx, wy, tol),
|
||||
// CAD-14: App-Kanal fuer V2-Werkzeuge (selectTool) — via Ref gegen
|
||||
// Stale-Closure, derselbe Kanal wie die Legacy-Engine nutzt.
|
||||
onChange: (ids) => onSelectionChangeRef.current?.(ids),
|
||||
}),
|
||||
});
|
||||
|
||||
// ── 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());
|
||||
@@ -184,31 +256,24 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
pixi!.setSelection({ selectedIds: ids, elements: selected });
|
||||
};
|
||||
}
|
||||
// CAD-14 Phase 4a: Defaults aus den optionsSchemas aller V2-Werkzeuge
|
||||
// in den toolOptionsStore fuellen (Quelle fuer OptionsPanel + Dispatcher).
|
||||
for (const tool of pluginRegistry.getToolsV2()) {
|
||||
if (tool.optionsSchema?.length) {
|
||||
setToolOptionsDefaults(tool.manifest.id, defaultsFromSchema(tool.optionsSchema as never));
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('InteractionDispatcher v2 aktiviert (VITE_TOOL_DISPATCHER=v2)');
|
||||
}
|
||||
|
||||
zoomPanRef.current = zoomPan;
|
||||
renderEngineRef.current = renderEngine;
|
||||
interactionRef.current = interaction;
|
||||
spatialIndexRef.current = spatialIndex;
|
||||
layerManagerRef.current = layerManager;
|
||||
selectionEngineRef.current = selectionEngine;
|
||||
|
||||
interaction.setCallbacks({
|
||||
onElementCreated,
|
||||
onElementsDeleted,
|
||||
onElementsModified,
|
||||
onCursorMoved,
|
||||
onToolStateChanged,
|
||||
onTextEdit,
|
||||
onCommandTrigger,
|
||||
onSelectionChange,
|
||||
});
|
||||
|
||||
interaction.attach();
|
||||
|
||||
return () => {
|
||||
interaction.detach();
|
||||
dispatcherRef.current?.destroy();
|
||||
dispatcherRef.current = null;
|
||||
pixiRendererRef.current?.destroy(); // Task C2: WebGL-Kontext freigeben
|
||||
@@ -216,7 +281,6 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
setActiveDocument(null);
|
||||
zoomPanRef.current = null;
|
||||
renderEngineRef.current = null;
|
||||
interactionRef.current = null;
|
||||
spatialIndexRef.current = null;
|
||||
layerManagerRef.current = null;
|
||||
selectionEngineRef.current = null;
|
||||
@@ -224,23 +288,6 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Update callbacks when they change (avoids stale closures)
|
||||
useEffect(() => {
|
||||
const interaction = interactionRef.current;
|
||||
if (!interaction) return;
|
||||
interaction.setCallbacks({
|
||||
onElementCreated,
|
||||
onElementsDeleted,
|
||||
onElementsModified,
|
||||
onCursorMoved,
|
||||
onToolStateChanged,
|
||||
onTextEdit,
|
||||
onCommandTrigger,
|
||||
onSelectionChange,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, onTextEdit, onCommandTrigger, onSelectionChange]);
|
||||
|
||||
// Resize canvas to fill container
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -269,18 +316,63 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync elements to interaction engine + spatial index + render
|
||||
// Sync elements to spatial index + render (CAD-14: keine Legacy-Engine)
|
||||
useEffect(() => {
|
||||
const interaction = interactionRef.current;
|
||||
const spatialIndex = spatialIndexRef.current;
|
||||
const renderEngine = renderEngineRef.current;
|
||||
if (!interaction || !spatialIndex || !renderEngine) return;
|
||||
interaction.setElements(elements);
|
||||
if (!spatialIndex || !renderEngine) return;
|
||||
spatialIndex.clear();
|
||||
spatialIndex.bulkInsert(elements);
|
||||
renderEngine.setOverlayElements(elements);
|
||||
renderEngine.render();
|
||||
|
||||
// CAD-14 Audit-Fix: App-Löschungen in das V2-Dokument zurueckspiegeln —
|
||||
// sonst bleiben in App geloeschte Elemente als Geister in v2Doc/Pixi
|
||||
// haengen und kamen beim naechsten onChanged zurueck.
|
||||
const activeDoc = getActiveDocument();
|
||||
if (activeDoc) {
|
||||
const appIds = new Set(elements.map((e) => e.id));
|
||||
const ghostIds = activeDoc.getAllElements().filter((e) => !appIds.has(e.id)).map((e) => e.id);
|
||||
if (ghostIds.length > 0) {
|
||||
activeDoc.deleteElements(ghostIds);
|
||||
}
|
||||
}
|
||||
|
||||
// CAD-13-fix: Bestands-Elemente (aus DB geladen) müssen in Pixi gespiegelt
|
||||
// werden — nur NEUE V2-Elemente laufen über v2Doc.onChanged in syncPixi.
|
||||
// Ohne diesen Mirror blieben Alt-Projekte leer (Legacy skippt ja jetzt).
|
||||
const pixi = pixiRendererRef.current;
|
||||
if (pixi && renderEngine.skipElementRendering) {
|
||||
void pixi.waitForReady().then(() => {
|
||||
const mirror = pixiMirrorRef.current;
|
||||
const currentIds = new Set<string>();
|
||||
const layerFallback = {
|
||||
id: 'layer-0', name: 'Standard', visible: true, locked: false,
|
||||
color: '#ffffff', lineType: 'solid', transparency: 0,
|
||||
sortOrder: 0, parentId: null,
|
||||
} as never;
|
||||
for (const el of elements) {
|
||||
currentIds.add(el.id);
|
||||
// CAD-14: V2-stämmige Elemente zeichnet syncPixi (v2Doc.onChanged) —
|
||||
// hier NUR noch Aktualisierung des Spiegels, kein doppeltes drawElement.
|
||||
if (mirror.get(el.id) !== el) {
|
||||
mirror.set(el.id, el);
|
||||
const layer = layers.find((l) => l.id === el.layerId) ?? layerFallback;
|
||||
pixi.drawElement(el, layer, {
|
||||
color: '#ffffff', lineType: 'solid', transparency: 0, visible: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const id of Array.from(mirror.keys())) {
|
||||
if (!currentIds.has(id)) {
|
||||
mirror.delete(id);
|
||||
pixi.removeElement(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [elements]);
|
||||
}, [elements, layers]);
|
||||
|
||||
// Sync layers to LayerManager + render
|
||||
useEffect(() => {
|
||||
@@ -305,21 +397,12 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [blocks]);
|
||||
|
||||
// Sync groups to RenderEngine and InteractionEngine
|
||||
// Sync groups to RenderEngine (CAD-14: keine Legacy-Engine mehr)
|
||||
useEffect(() => {
|
||||
const renderEngine = renderEngineRef.current;
|
||||
const interaction = interactionRef.current;
|
||||
if (!renderEngine) return;
|
||||
renderEngine.setGroups(groups ?? []);
|
||||
renderEngine.render();
|
||||
// InteractionEngine uses GroupManager from App — we pass it via a simple adapter
|
||||
if (interaction && groups) {
|
||||
const gm = new GroupManager();
|
||||
gm.fromJSON(groups);
|
||||
interaction.setGroupManager(gm);
|
||||
} else if (interaction) {
|
||||
interaction.setGroupManager(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [groups]);
|
||||
|
||||
@@ -353,35 +436,36 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeLayerId, layers]);
|
||||
|
||||
// Sync grid/snap/ortho toggles
|
||||
// Sync grid/snap/ortho toggles (CAD-14: Dispatcher liest snap/ortho via Refs)
|
||||
useEffect(() => {
|
||||
const renderEngine = renderEngineRef.current;
|
||||
const interaction = interactionRef.current;
|
||||
if (!renderEngine || !interaction) return;
|
||||
if (!renderEngine) return;
|
||||
renderEngine.setOptions({ showGrid: gridEnabled });
|
||||
renderEngine.setOptions({ showSnapPoints: snapEnabled });
|
||||
renderEngine.setOptions({ showOrtho: orthoEnabled });
|
||||
renderEngine.setOptions({ gridSize, unit, scaleFactor, showGridLabels: true });
|
||||
renderEngine.setOptions({ canvasBgColor, gridColor, rulerEnabled });
|
||||
interaction.setSnapEnabled(snapEnabled);
|
||||
interaction.setOrthoEnabled(orthoEnabled);
|
||||
interaction.setPolarEnabled(polarEnabled);
|
||||
interaction.setUnits(unit, scaleFactor);
|
||||
interaction.setGridSize(gridSize);
|
||||
|
||||
// ── Task CAD-6: Hintergrund-Grundriss (bgConfig) an RenderEngine geben ──
|
||||
// Vorher wurde bgConfig als Prop uebergeben aber NIE genutzt — der
|
||||
// importierte Grundriss wurde nicht gezeichnet.
|
||||
renderEngine.setOptions({
|
||||
backgroundSrc: bgConfig?.visible ? (bgConfig.src || undefined) : undefined,
|
||||
backgroundScale: bgConfig?.scale ?? 1,
|
||||
backgroundOffsetX: bgConfig?.offsetX ?? 0,
|
||||
backgroundOffsetY: bgConfig?.offsetY ?? 0,
|
||||
backgroundRotation: ((bgConfig?.rotation ?? 0) * Math.PI) / 180,
|
||||
backgroundOpacity: bgConfig?.opacity ?? 0.5,
|
||||
});
|
||||
renderEngine.render();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled, unit, gridSize, scaleFactor, canvasBgColor, gridColor, rulerEnabled]);
|
||||
}, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled, unit, gridSize, scaleFactor, canvasBgColor, gridColor, rulerEnabled, bgConfig]);
|
||||
|
||||
// Sync active tool
|
||||
// Sync active tool (CAD-14: ausschliesslich Dispatcher)
|
||||
useEffect(() => {
|
||||
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,
|
||||
);
|
||||
// CAD-14 Phase 4c: Legacy-Engine entfernt — der Dispatcher ist die
|
||||
// EINZIGE Interaktions-Engine fuer alle Werkzeuge.
|
||||
dispatcherRef.current?.setActive(activeTool);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTool]);
|
||||
|
||||
@@ -405,14 +489,6 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
// Sync selected template to interaction engine
|
||||
useEffect(() => {
|
||||
const interaction = interactionRef.current;
|
||||
if (!interaction) return;
|
||||
interaction.setSelectedTemplate(selectedTemplate ?? null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTemplate]);
|
||||
|
||||
// Compute screen positions for remote cursors
|
||||
const [cursorScreenPositions, setCursorScreenPositions] = useState<Array<{ cursor: UserCursor; sx: number; sy: number }>>([]);
|
||||
useEffect(() => {
|
||||
@@ -472,9 +548,10 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
};
|
||||
|
||||
const handleZoomFit = () => {
|
||||
const interaction = interactionRef.current;
|
||||
if (interaction) {
|
||||
interaction.zoomFit();
|
||||
// CAD-14 Phase 4c: zoomFit direkt ueber ZoomPan (V2-Dokument-Elemente)
|
||||
const zoomPan = zoomPanRef.current;
|
||||
if (zoomPan) {
|
||||
zoomPan.zoomFit(getActiveDocument()?.getAllElements() ?? elements);
|
||||
}
|
||||
onZoomFit();
|
||||
};
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import type { LeftSidebarProps } from '../types/ui.types';
|
||||
import { SEATING_TEMPLATES } from '../services/seatingService';
|
||||
import { pluginRegistry } from '../plugins';
|
||||
|
||||
interface ToolDef {
|
||||
tool: string;
|
||||
@@ -138,6 +139,35 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ activeTool, onToolChange, sel
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* CAD-14 Phase 4b: Dynamische Sektion fuer Registry-Werkzeuge ohne
|
||||
statischen Button (truss, boxcorner, circle-truss, measure, ...).
|
||||
Ein Ort fuer ALLE Werkzeuge — keine Doppelung mehr. */}
|
||||
{(() => {
|
||||
const staticIds = new Set(sections.flatMap((s) => s.tools.map((t) => t.tool)));
|
||||
const v2Tools = pluginRegistry.getToolsV2().filter((t) => !staticIds.has(t.manifest.id));
|
||||
if (v2Tools.length === 0) return null;
|
||||
return (
|
||||
<div className="tool-section" key="v2-extra">
|
||||
{renderSectionLabel('Werkzeuge (Plugins)', 'v2-extra')}
|
||||
<div className="tool-grid" style={collapsedSections['v2-extra'] ? { display: 'none' } : undefined}>
|
||||
{v2Tools.map((tool) => (
|
||||
<button
|
||||
key={tool.manifest.id}
|
||||
className={`tool-btn${activeTool === tool.manifest.id ? ' active' : ''}`}
|
||||
data-v2tool={tool.manifest.id}
|
||||
data-tool={tool.manifest.id}
|
||||
title={tool.manifest.description ?? tool.manifest.label}
|
||||
onClick={() => onToolChange(tool.manifest.id)}
|
||||
>
|
||||
<span className="tool-btn-label" style={{ fontSize: 14 }}>{tool.manifest.icon}</span>
|
||||
<span className="tool-btn-label">{tool.manifest.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(onGroup || onUngroup) && (
|
||||
<div className="tool-section" key="grouping">
|
||||
{renderSectionLabel("Gruppierung", "grouping")}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Task F4 – UI für LibraryProviderExtensions.
|
||||
*
|
||||
* Lädt alle übergebenen Provider (builtin/user/global) und rendert je Ordner
|
||||
* eine Sektion mit Thumbnail-Blöcken. Jeder Block ist als DragSource an den
|
||||
* bestehenden 'text/global-block-data'-Kanal angebunden (DragDrop → Canvas).
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { LibraryProviderExtension, LibBlock } from '../plugins/types';
|
||||
|
||||
interface Props {
|
||||
providers: LibraryProviderExtension[];
|
||||
}
|
||||
|
||||
interface Section { key: string; label: string; blocks: LibBlock[]; }
|
||||
|
||||
const LibraryProviderPanel: React.FC<Props> = ({ providers }) => {
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const out: Section[] = [];
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const folders = await provider.listFolders();
|
||||
for (const folder of folders) {
|
||||
try {
|
||||
const blocks = await provider.listBlocks(folder.id);
|
||||
if (blocks.length > 0) {
|
||||
out.push({ key: `${provider.id}/${folder.id}`, label: folder.label, blocks });
|
||||
}
|
||||
} catch {
|
||||
// Ordner bei Fehler überspringen
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Provider bei Fehler überspringen
|
||||
}
|
||||
}
|
||||
if (!cancelled) setSections(out);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [providers]);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, block: LibBlock) => {
|
||||
const payload = typeof block.payload === 'string' ? block.payload : JSON.stringify(block.payload ?? []);
|
||||
e.dataTransfer.setData('text/global-block-data', payload);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="lib-provider-panel">
|
||||
{sections.map((section) => (
|
||||
<div className="lprov-section" key={section.key}>
|
||||
<div className="lprov-title">{section.label}</div>
|
||||
<div className="lprov-grid">
|
||||
{section.blocks.map((block) => (
|
||||
<button
|
||||
key={block.id}
|
||||
type="button"
|
||||
className="lprov-block"
|
||||
aria-label={block.name}
|
||||
title={block.name}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, block)}
|
||||
>
|
||||
{block.thumbnail
|
||||
? <span className="tree-thumb" dangerouslySetInnerHTML={{ __html: block.thumbnail }} />
|
||||
: <span className="lib-grid-placeholder">📦</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryProviderPanel;
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { 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
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import React from 'react';
|
||||
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> },
|
||||
{ id: 'insert', label: 'Einfügen', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg> },
|
||||
{ id: 'format', label: 'Format', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg> },
|
||||
{ id: 'view', label: 'Ansicht', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg> },
|
||||
{ id: 'canvas', label: 'Zeichenfläche', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 3v18"/></svg> },
|
||||
{ id: 'tools', label: 'Extras', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> },
|
||||
{ id: 'ki', label: 'KI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg> },
|
||||
];
|
||||
|
||||
@@ -39,93 +33,58 @@ 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().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' && (
|
||||
<>
|
||||
<div className="ribbon-group compact-mobile" data-ribbon-group="start">
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Neues Projekt (Strg+N)" onClick={() => onAction('new')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
|
||||
<span>Neu</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Öffnen (Strg+O)" onClick={() => onAction('open')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<span>Öffnen</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Speichern (Strg+S)" onClick={() => onAction('save')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
<span>Speichern</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Datei importieren (DXF, SVG, JSON)" onClick={() => onAction('import')}>
|
||||
<svg viewBox="0 0 24 24" 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 className="ribbon-btn" title="Import (DXF, SVG, JSON)" onClick={() => onAction('import')}>
|
||||
<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>
|
||||
<span>Import</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Export als (DXF, SVG, PDF, PNG, JSON)" onClick={() => onAction('export')}>
|
||||
<svg viewBox="0 0 24 24" 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 className="ribbon-btn" title="Export (DXF, SVG, PDF, PNG, JSON)" onClick={() => onAction('export')}>
|
||||
<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>
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Datei</div>
|
||||
</div>
|
||||
|
||||
<div className="ribbon-divider"></div>
|
||||
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Rückgängig (Strg+Z)" onClick={() => onAction('undo')}>
|
||||
<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>
|
||||
<span>Undo</span>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" 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>
|
||||
<span>Rückgängig</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Wiederherstellen (Strg+Y)" onClick={() => onAction('redo')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 15-6.7L21 13"/></svg>
|
||||
<span>Redo</span>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 15-6.7L21 13"/></svg>
|
||||
<span>Wiederherstellen</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Kopieren (Strg+C)" onClick={() => onAction('copy')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
<span>Kopieren</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Einfügen (Strg+V)" onClick={() => onAction('paste')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg>
|
||||
<span>Einfügen</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Gruppieren (G)" onClick={() => onAction('group')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><path d="M10 10l4 4"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
|
||||
<span>Gruppieren</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Degruppieren (Strg+G)" onClick={() => onAction('ungroup')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1" strokeDasharray="2 2"/><rect x="14" y="14" width="7" height="7" rx="1" strokeDasharray="2 2"/><path d="M10 10l4 4" strokeDasharray="2 2"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||||
<span>Degruppieren</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -134,200 +93,69 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== INSERT TAB ===== */}
|
||||
{activeTab === 'insert' && (
|
||||
<>
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Linie zeichnen" onClick={() => onAction('insert-line')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="19" x2="19" y2="5"/></svg>
|
||||
<span>Linie</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Rechteck zeichnen" onClick={() => onAction('insert-rect')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="1"/></svg>
|
||||
<span>Rechteck</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Kreis zeichnen" onClick={() => onAction('insert-circle')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/></svg>
|
||||
<span>Kreis</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Text einfügen" onClick={() => onAction('insert-text')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>
|
||||
<span>Text</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Freihand zeichnen" onClick={() => onAction('insert-freehand')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 17s5-5 9-5 9 5 9 5"/><path d="M3 12s5-5 9-5 9 5 9 5"/></svg>
|
||||
<span>Freihand</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Formen</div>
|
||||
</div>
|
||||
|
||||
<div className="ribbon-divider"></div>
|
||||
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Hintergrund importieren" onClick={() => onAction('background-import')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||||
<span>Hintergrund</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Bild einfügen" onClick={() => onAction('insert-image')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||||
<span>Bild</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Objekt</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== FORMAT TAB ===== */}
|
||||
{activeTab === 'format' && (
|
||||
<>
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Linienstärke" onClick={() => onAction('format-stroke-width')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="4" y1="12" x2="20" y2="12"/></svg>
|
||||
<span>Linienstärke</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Linienfarbe" onClick={() => onAction('format-stroke-color')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><line x1="3" y1="12" x2="21" y2="12"/></svg>
|
||||
<span>Linienfarbe</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Füllfarbe" onClick={() => onAction('format-fill-color')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 3l18 18"/></svg>
|
||||
<span>Füllfarbe</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Linientyp" onClick={() => onAction('format-stroke-style')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="16" x2="20" y2="16" stroke-dasharray="4 4"/></svg>
|
||||
<span>Linientyp</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Stil</div>
|
||||
</div>
|
||||
|
||||
<div className="ribbon-divider"></div>
|
||||
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Nach links ausrichten" onClick={() => onAction('format-align-left')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="4" y1="4" x2="4" y2="20"/><rect x="6" y="6" width="12" height="4"/><rect x="6" y="14" width="8" height="4"/></svg>
|
||||
<span>Links</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Zentrieren" onClick={() => onAction('format-align-center')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="4" x2="12" y2="20"/><rect x="6" y="6" width="12" height="4"/><rect x="8" y="14" width="8" height="4"/></svg>
|
||||
<span>Zentriert</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Nach rechts ausrichten" onClick={() => onAction('format-align-right')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="20" y1="4" x2="20" y2="20"/><rect x="6" y="6" width="12" height="4"/><rect x="10" y="14" width="8" height="4"/></svg>
|
||||
<span>Rechts</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Ausrichtung</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== VIEW TAB ===== */}
|
||||
{/* ===== ANSICHT TAB ===== */}
|
||||
{activeTab === 'view' && (
|
||||
<>
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Zoom anpassen (F)" onClick={() => onAction('zoom-fit')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
|
||||
<span>Zoom Fit</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="100% (1:1)" onClick={() => onAction('zoom-100')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
|
||||
<span>100%</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Grid ein/aus (F7)" onClick={() => onAction('grid')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="0"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
<span>Grid</span>
|
||||
<button className="ribbon-btn" title="Raster ein/aus (F7)" onClick={() => onAction('grid')}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/></svg>
|
||||
<span>Raster</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Layer-Manager" onClick={() => onAction('layer')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
|
||||
<span>Layer</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Ansicht</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== CANVAS TAB ===== */}
|
||||
{activeTab === 'canvas' && (
|
||||
<>
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<div className="ribbon-color-picker" title="Hintergrundfarbe der Zeichenfläche">
|
||||
<div className="ribbon-color-swatch" style={{ background: canvasBgColor }}>
|
||||
<input
|
||||
type="color"
|
||||
value={canvasBgColor}
|
||||
onChange={(e) => onCanvasBgColorChange?.(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="ribbon-color-label">Hintergrund</span>
|
||||
</div>
|
||||
<div className="ribbon-color-picker" title="Gitterfarbe">
|
||||
<div className="ribbon-color-swatch" style={{ background: gridColor }}>
|
||||
<input
|
||||
type="color"
|
||||
value={gridColor}
|
||||
onChange={(e) => onGridColorChange?.(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="ribbon-color-label">Gitter</span>
|
||||
</div>
|
||||
<button className="ribbon-btn" title="Hintergrund-Grundriss importieren" onClick={() => onAction('background-import')}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
|
||||
<span>Hintergrund</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Bild einfügen" onClick={() => onAction('insert-image')}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
<span>Bild</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Farben</div>
|
||||
<div className="ribbon-group-label">Hintergrund</div>
|
||||
</div>
|
||||
|
||||
<div className="ribbon-divider"></div>
|
||||
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button
|
||||
className={`ribbon-btn${rulerEnabled ? ' active' : ''}`}
|
||||
title="Lineal ein/aus"
|
||||
aria-pressed={rulerEnabled}
|
||||
onClick={() => onToggleRuler?.()}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 8h3"/><path d="M3 13h3"/><path d="M3 18h3"/><path d="M8 3v3"/><path d="M13 3v3"/><path d="M18 3v3"/></svg>
|
||||
<label className="ribbon-color-label">Canvas</label>
|
||||
<input type="color" value={canvasBgColor} onChange={(e) => onCanvasBgColorChange?.(e.target.value)} aria-label="Canvas-Hintergrundfarbe" />
|
||||
<label className="ribbon-color-label">Raster</label>
|
||||
<input type="color" value={gridColor} onChange={(e) => onGridColorChange?.(e.target.value)} aria-label="Rasterfarbe" />
|
||||
<button className="ribbon-btn" title="Lineal ein/aus" onClick={() => onToggleRuler?.()}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.3 8.7l-7.6-7.6a1 1 0 0 0-1.4 0l-9 9a1 1 0 0 0 0 1.4l7.6 7.6a1 1 0 0 0 1.4 0l9-9a1 1 0 0 0 0-1.4z"/></svg>
|
||||
<span>Lineal</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Raster ein/aus (F7)" onClick={() => onAction('grid')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
<span>Raster</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Anzeige</div>
|
||||
<div className="ribbon-group-label">Darstellung</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== TOOLS TAB ===== */}
|
||||
{activeTab === 'tools' && (
|
||||
<>
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" title="Messwerkzeug" onClick={() => onAction('measure')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.3 8.7 8.7 21.3a1 1 0 0 1-1.4 0L2.7 16.7a1 1 0 0 1 0-1.4L15.3 2.7a1 1 0 0 1 1.4 0l4.6 4.6a1 1 0 0 1 0 1.4z"/><path d="m7.5 10.5 2 2"/><path d="m10.5 7.5 2 2"/><path d="m13.5 4.5 2 2"/><path d="m4.5 13.5 2 2"/></svg>
|
||||
<span>Messen</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Plugins" onClick={() => onAction('plugins')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v6m0 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/><path d="M12 8a6 6 0 0 0-6 6c0 3 2 4 2 6h8c0-2 2-3 2-6a6 6 0 0 0-6-6z"/></svg>
|
||||
<span>Plugins</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Historie (Strg+H)" onClick={() => onAction('history')}>
|
||||
<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>
|
||||
<button className="ribbon-btn" title="Historie" onClick={() => onAction('history')}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" 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>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span>Gäste</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Stückliste Traversen" onClick={() => onAction('bom')}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/></svg>
|
||||
<span>Stückliste</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Extras</div>
|
||||
</div>
|
||||
@@ -339,18 +167,9 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
||||
<>
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-group-btns">
|
||||
<button className="ribbon-btn" style={{ color: 'var(--color-ki)' }} title="KI Copilot (Ctrl+K)" onClick={() => onAction('ki')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>
|
||||
<span>KI Copilot</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" style={{ color: 'var(--color-ki)' }} title="KI Zeichnen" onClick={() => onAction('ki-draw')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
<span>KI Zeichnen</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" style={{ color: 'var(--color-ki)' }} title="KI Analysieren" onClick={() => onAction('ki-analyze')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/><path d="M3 5c0 1.66 4 3 9 3s9-1.34 9-3-4-3-9-3-9 1.34-9 3"/></svg>
|
||||
<span>KI Analysieren</span>
|
||||
</button>
|
||||
<span style={{ fontSize: '12px', color: 'var(--color-ki, #8a8aff)', padding: '4px 8px' }}>
|
||||
KI Copilot — Befehle über die Befehlszeile unten (Strg+K)
|
||||
</span>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Künstliche Intelligenz</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import type { RightSidebarProps, RightPanel } from '../types/ui.types';
|
||||
import type { LibraryProviderExtension } from '../plugins/types';
|
||||
import PropertiesPanel from './PropertiesPanel';
|
||||
import ToolOptionsPanel from './ToolOptionsPanel';
|
||||
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;
|
||||
@@ -18,6 +24,7 @@ const tabs: Array<{ id: RightPanel; label: string; svg: React.ReactNode; badge?:
|
||||
|
||||
const RightSidebar: React.FC<RightSidebarProps> = ({
|
||||
activePanel, onPanelChange, selectedElement, layers, blocks,
|
||||
activeToolId,
|
||||
className,
|
||||
activeLayerId, onSelectLayer, onAddLayer, onToggleLayer,
|
||||
onDeleteLayer, onRenameLayer, onDuplicateLayer, onToggleLock,
|
||||
@@ -39,6 +46,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();
|
||||
@@ -94,6 +118,7 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
|
||||
</div>
|
||||
<div className="rightbar-content">
|
||||
<div className={`rightbar-panel${activePanel === 'tool' ? ' active' : ''}`} data-panel-content="tool">
|
||||
<ToolOptionsPanel activeToolId={activeToolId} />
|
||||
{activePanel === 'tool' && selectedElement && onUpdateElement && (
|
||||
<PropertiesPanel selectedElement={selectedElement} layers={layers} unit={unit} scaleFactor={scaleFactor} onDelete={selectedElement && onElementsDeleted ? () => onElementsDeleted([selectedElement.id]) : undefined} onUpdateProperty={(key, value) => {
|
||||
if (!selectedElement || !onUpdateElement) return;
|
||||
@@ -138,6 +163,7 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<LibraryProviderPanel providers={libraryProviders} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* CAD-14 Phase 4a - Werkzeug-Optionen-Panel.
|
||||
*
|
||||
* Rendert das optionsSchema des aktiven V2-Werkzeugs und bindet
|
||||
* die Werte an den toolOptionsService (Quelle fuer den Dispatcher).
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { pluginRegistry } from '../plugins';
|
||||
import { getToolOption, setToolOption, subscribeToolOptions } from '../services/toolOptionsService';
|
||||
|
||||
interface ToolOptionsPanelProps {
|
||||
activeToolId: string | null | undefined;
|
||||
}
|
||||
|
||||
const ToolOptionsPanel: React.FC<ToolOptionsPanelProps> = ({ activeToolId }) => {
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
// Store-Abonnement: Panel bei Optionsaenderung neu rendern
|
||||
React.useEffect(() => {
|
||||
return subscribeToolOptions(() => forceRender((n) => n + 1));
|
||||
}, []);
|
||||
|
||||
const tool = useMemo(() => {
|
||||
if (!activeToolId) return null;
|
||||
return pluginRegistry.getToolV2(activeToolId) ?? null;
|
||||
}, [activeToolId]);
|
||||
|
||||
const schema = tool?.optionsSchema ?? [];
|
||||
if (!tool || schema.length === 0) return null;
|
||||
|
||||
const renderField = (field: { key: string; label: string; type: string; min?: number; max?: number; options?: Array<{ value: string; label: string }> }) => {
|
||||
const value = getToolOption(tool.manifest.id, field.key);
|
||||
const onChange = (v: unknown) => setToolOption(tool.manifest.id, field.key, v);
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<label key={field.key} className="toolopt-row">
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={typeof value === 'number' ? value : Number(value) || 0}
|
||||
min={field.min}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (field.type === 'color') {
|
||||
return (
|
||||
<label key={field.key} className="toolopt-row">
|
||||
<span>{field.label}</span>
|
||||
<input type="color" value={String(value ?? '#9aa0a6')} onChange={(e) => onChange(e.target.value)} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (field.type === 'select' && field.options) {
|
||||
return (
|
||||
<label key={field.key} className="toolopt-row">
|
||||
<span>{field.label}</span>
|
||||
<select value={String(value ?? '')} onChange={(e) => onChange(e.target.value)}>
|
||||
{field.options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<label key={field.key} className="toolopt-row toolopt-check">
|
||||
<input type="checkbox" checked={value === true} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tool-options-panel">
|
||||
<div className="toolopt-title">Werkzeug-Optionen: {tool.manifest.label}</div>
|
||||
{schema.map((f) => renderField(f as never))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolOptionsPanel;
|
||||
@@ -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' ? 'Einfach' : '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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,6 +17,14 @@ import type { ZoomPanController } from '../canvas/ZoomPanController';
|
||||
|
||||
export interface DispatcherDeps {
|
||||
zoomPan: ZoomPanController;
|
||||
/** CAD-14 Phase 4c: Snap-Funktion (Weltkoordinaten) fuer Tool-Events. */
|
||||
snap?(x: number, y: number): { x: number; y: number };
|
||||
/** CAD-14 Phase 4c: Cursor-Meldung (auch ohne aktives Tool). */
|
||||
onCursorMoved?(worldX: number, worldY: number): void;
|
||||
/** CAD-14 Phase 4c: Render-Anstoess nach Pan/Wheel (CanvasArea ruft renderEngine.render). */
|
||||
requestRender?(): void;
|
||||
/** CAD-14 Phase 4c: Ortho-Anwendung gegen den letzten gesnappten Down-Punkt. */
|
||||
ortho?(x: number, y: number, last: { x: number; y: number } | null): { x: number; y: number };
|
||||
/** Liefert Optionsstände je ToolId (typisch: toolStore.optionValues). */
|
||||
getOptions?(toolId: string): Record<string, unknown>;
|
||||
/** Statuszeile (typisch: StatusBar-Bridge). */
|
||||
@@ -81,6 +89,9 @@ export class InteractionDispatcher {
|
||||
window.removeEventListener('pointermove', this.onMove);
|
||||
window.removeEventListener('pointerup', this.onUp);
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
this.canvas.removeEventListener('wheel', this.onWheelNative as EventListener);
|
||||
window.removeEventListener('pointermove', this.onPanMove);
|
||||
window.removeEventListener('pointerup', this.onPanEnd);
|
||||
this.bound = false;
|
||||
}
|
||||
|
||||
@@ -92,9 +103,46 @@ export class InteractionDispatcher {
|
||||
window.addEventListener('pointermove', this.onMove);
|
||||
window.addEventListener('pointerup', this.onUp);
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
// CAD-14 Phase 4c: natives Pan + Wheel (ersetzt Legacy-Engine)
|
||||
this.canvas.addEventListener('wheel', this.onWheelNative as EventListener, { passive: false });
|
||||
window.addEventListener('pointermove', this.onPanMove);
|
||||
window.addEventListener('pointerup', this.onPanEnd);
|
||||
this.bound = true;
|
||||
}
|
||||
|
||||
// ── CAD-14 Phase 4c: natives Pan (Mittel-/Rechtsklick-Drag) ──
|
||||
private panActive = false;
|
||||
private lastPanScreen = { x: 0, y: 0 };
|
||||
/** CAD-14 Phase 4c: Letzter gesnappter Down-Punkt (Ortho-Referenz). */
|
||||
private lastSnappedDown: { x: number; y: number } | null = null;
|
||||
|
||||
private onPanMove = (e: PointerEvent): void => {
|
||||
if (!this.panActive) return;
|
||||
const dx = e.clientX - this.lastPanScreen.x;
|
||||
const dy = e.clientY - this.lastPanScreen.y;
|
||||
this.lastPanScreen = { x: e.clientX, y: e.clientY };
|
||||
this.deps.zoomPan.pan(dx, dy);
|
||||
this.requestRender();
|
||||
};
|
||||
|
||||
private onPanEnd = (): void => {
|
||||
this.panActive = false;
|
||||
};
|
||||
|
||||
private onWheelNative = (e: WheelEvent): void => {
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
this.deps.zoomPan.zoomAt(cx, cy, factor);
|
||||
this.requestRender();
|
||||
};
|
||||
|
||||
private requestRender(): void {
|
||||
this.deps.requestRender?.();
|
||||
}
|
||||
|
||||
private buildContext(): import('../plugins/types').FullToolContext {
|
||||
const toolId = this.active?.manifest.id ?? '';
|
||||
return {
|
||||
@@ -112,26 +160,31 @@ export class InteractionDispatcher {
|
||||
}
|
||||
|
||||
private onDown = (e: PointerEvent): void => {
|
||||
if (!this.active) return;
|
||||
// Rechtsklick = Abbruch wie ESC
|
||||
if (e.button === 2) {
|
||||
this.cancelActive();
|
||||
// CAD-14 Phase 4c: natives Pan bei Mittel-/Rechtsklick (auch ohne Tool)
|
||||
if (e.button === 1 || e.button === 2) {
|
||||
this.panActive = true;
|
||||
this.lastPanScreen = { x: e.clientX, y: e.clientY };
|
||||
return;
|
||||
}
|
||||
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
|
||||
if (!this.active) return;
|
||||
const tpe = this.applySnapToEvent(toToolPointerEvent(e, this.canvas, this.deps.zoomPan));
|
||||
this.lastSnappedDown = { x: tpe.world.x, y: tpe.world.y };
|
||||
this.session.begin();
|
||||
this.active.handlers.down?.(tpe, this.buildContext());
|
||||
};
|
||||
|
||||
private onMove = (e: PointerEvent): void => {
|
||||
const tpeRaw = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
|
||||
// CAD-14 Phase 4c: Cursor-Meldung auch ohne aktives Tool (Statusleiste)
|
||||
this.deps.onCursorMoved?.(tpeRaw.world.x, tpeRaw.world.y);
|
||||
if (!this.active || !this.session.isActive) return;
|
||||
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
|
||||
const tpe = this.applySnapToEvent(tpeRaw);
|
||||
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);
|
||||
const tpe = this.applySnapToEvent(toToolPointerEvent(e, this.canvas, this.deps.zoomPan));
|
||||
try {
|
||||
this.active.handlers.up?.(tpe, this.buildContext());
|
||||
} finally {
|
||||
@@ -149,6 +202,20 @@ export class InteractionDispatcher {
|
||||
if (consumed) e.preventDefault();
|
||||
};
|
||||
|
||||
/** CAD-14 Phase 4c: wendet Snap aus Deps auf ein Tool-Pointer-Event an. */
|
||||
private applySnapToEvent(tpe: import('../plugins/types').ToolPointerEvent): import('../plugins/types').ToolPointerEvent {
|
||||
if (!this.deps.snap && !this.deps.ortho) return tpe;
|
||||
let p = { x: tpe.world.x, y: tpe.world.y };
|
||||
if (this.deps.snap) {
|
||||
const snapped = this.deps.snap(p.x, p.y);
|
||||
p = { x: snapped.x, y: snapped.y };
|
||||
}
|
||||
if (this.deps.ortho) {
|
||||
p = this.deps.ortho(p.x, p.y, this.lastSnappedDown);
|
||||
}
|
||||
return { ...tpe, world: p };
|
||||
}
|
||||
|
||||
private cancelActive(): void {
|
||||
if (!this.active) return;
|
||||
this.active.handlers.cancel?.(this.buildContext());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
import * as Y from 'yjs';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
import type { CADElement, CADLayer } from '../../types/cad.types';
|
||||
import type { CADElement, CADLayer, LayoutDefinition, BlockDefinition } from '../../types/cad.types';
|
||||
|
||||
export class CADDocument {
|
||||
private readonly ydoc: YjsDocument;
|
||||
@@ -22,10 +22,10 @@ export class CADDocument {
|
||||
constructor(ydoc: YjsDocument) {
|
||||
this.ydoc = ydoc;
|
||||
// Trackt alle Operationen auf Elements + Layers → gemeinsame Undo-Historie.
|
||||
// Blocks/Groups/Configs bewusst NICHT tracken: Werkzeuge ändern primär Elemente/Layer.
|
||||
// Blocks/Groups/Configs bewusst NICHT tracken; Layouts (G1) dazu seit Task G1.
|
||||
// captureTimeout 0: KEIN automatisches Mergen zeitnaher Transaktionen —
|
||||
// jede explizite transact() bleibt EIGENER Undo-Schritt (deterministisch für Tools).
|
||||
this.undoManager = new Y.UndoManager([this.ydoc.data.elements, this.ydoc.data.layers], {
|
||||
this.undoManager = new Y.UndoManager([this.ydoc.data.elements, this.ydoc.data.layers, this.ydoc.data.layouts], {
|
||||
captureTimeout: 0,
|
||||
});
|
||||
}
|
||||
@@ -52,6 +52,86 @@ export class CADDocument {
|
||||
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). */
|
||||
|
||||
@@ -2,6 +2,14 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { registerBuiltinPlugins, pluginRegistry } from './plugins';
|
||||
|
||||
// CAD-14 Audit-Fix: Registry VOR dem React-Render initialisieren, damit
|
||||
// der erste CanvasArea-Mount (Child-Effekte laufen vor Parent-Effekten)
|
||||
// gefuellte Tools vorfindet — sonst '[InteractionDispatcher] kein
|
||||
// aktives V2-Tool select' beim App-Start.
|
||||
registerBuiltinPlugins();
|
||||
pluginRegistry.initDefaults();
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
* (2-Punkt Pfeil+Label).
|
||||
*/
|
||||
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
|
||||
import {
|
||||
DEFAULT_DIM_STYLE, getDimStyle, computeDimValue, formatDimValue,
|
||||
} from '../../../services/dimStyleService';
|
||||
import type { CADElement } from '../../../types/cad.types';
|
||||
import type { Pt } from '../../../tools/modification/geometry';
|
||||
|
||||
@@ -127,15 +130,272 @@ export const dimensionTool: ToolExtensionV2 = make2PointAnnotate('dimension', 'B
|
||||
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' : ''})`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task CAD-1: Assoziative Bemassung — 5 Typen mit DimStyles.
|
||||
// linear (H/V), aligned, angular (3 Klicks), radius, diameter.
|
||||
// Alle berechnen den Masswert aus der Geometrie (assoziativ).
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
let dimStart: Pt | null = null;
|
||||
let dimVertex: Pt | null = null; // angular: Scheitel
|
||||
|
||||
function dimStyleOf(ctx: FullToolContext) {
|
||||
const name = (ctx.options.dimStyle as string | undefined) ?? 'ISO-25';
|
||||
return getDimStyle(name);
|
||||
}
|
||||
|
||||
function commitDim(el: CADElement, ctx: FullToolContext, noun: string): void {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) return;
|
||||
c.doc.transact(() => c.doc!.addElement(el));
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus(`${noun} erstellt: ${String(el.properties.value)}`);
|
||||
}
|
||||
|
||||
export const dimLinearTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'dim-linear', label: 'Linear-Bemassung', icon: '\u2194',
|
||||
ribbonTab: 'format', tags: ['basic'],
|
||||
description: 'Lineare Bemassung: 2 Punkte, horizontal/vertikal (Option)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'dimStyle', label: 'Stil', type: 'select',
|
||||
options: [{ value: 'ISO-25', label: 'ISO-25' }, { value: 'ISO-50', label: 'ISO-50' },
|
||||
{ value: 'Architectural', label: 'Architektur' }, { value: 'Engineering', label: 'Engineering' }] },
|
||||
{ key: 'dimSubtype', label: 'Ausrichtung', type: 'select',
|
||||
options: [{ value: 'horizontal', label: 'Horizontal' }, { value: 'vertical', label: 'Vertikal' }] },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) return;
|
||||
if (!dimStart) { dimStart = e.world; ctx.setStatus('Bemassung: zweiten Punkt wählen'); return; }
|
||||
const subtype = (c.options.dimSubtype as string | undefined) ?? 'horizontal';
|
||||
const style = dimStyleOf(c);
|
||||
const raw = computeDimValue('linear', dimStart, e.world, undefined, subtype);
|
||||
const el: CADElement = {
|
||||
id: uid('dim'), type: 'dimension', layerId: (c.options.layerId as string | undefined) ?? 'layer-0',
|
||||
x: (dimStart.x + e.world.x) / 2, y: (dimStart.y + e.world.y) / 2,
|
||||
width: Math.abs(e.world.x - dimStart.x), height: Math.abs(e.world.y - dimStart.y),
|
||||
properties: {
|
||||
x1: dimStart.x, y1: dimStart.y, x2: e.world.x, y2: e.world.y,
|
||||
dimType: 'linear', dimSubtype: subtype,
|
||||
value: formatDimValue(raw, style), dimStyle: style.name,
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
dimStart = null;
|
||||
commitDim(el, ctx, 'Linearbemassung');
|
||||
},
|
||||
move(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!dimStart) return;
|
||||
const subtype = (c.options.dimSubtype as string | undefined) ?? 'horizontal';
|
||||
const style = dimStyleOf(c);
|
||||
const raw = computeDimValue('linear', dimStart, e.world, undefined, subtype);
|
||||
ctx.setPreview?.({
|
||||
id: '__preview_dim__', type: 'dimension', layerId: 'layer-0',
|
||||
x: (dimStart.x + e.world.x) / 2, y: (dimStart.y + e.world.y) / 2,
|
||||
width: Math.abs(e.world.x - dimStart.x), height: Math.abs(e.world.y - dimStart.y),
|
||||
properties: {
|
||||
x1: dimStart.x, y1: dimStart.y, x2: e.world.x, y2: e.world.y,
|
||||
dimType: 'linear', dimSubtype: subtype,
|
||||
value: formatDimValue(raw, style), dimStyle: style.name,
|
||||
},
|
||||
} as unknown as CADElement);
|
||||
},
|
||||
cancel(ctx) { dimStart = null; ctx.setPreview?.(null); ctx.setStatus('Bemassung abgebrochen'); },
|
||||
},
|
||||
};
|
||||
|
||||
export const dimAlignedTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'dim-aligned', label: 'Ausgerichtet', icon: '\u2197',
|
||||
ribbonTab: 'format', tags: ['basic'],
|
||||
description: 'Bemassung entlang der Punkte-Verbindung',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'dimStyle', label: 'Stil', type: 'select',
|
||||
options: [{ value: 'ISO-25', label: 'ISO-25' }, { value: 'ISO-50', label: 'ISO-50' }] },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) return;
|
||||
if (!dimStart) { dimStart = e.world; ctx.setStatus('Ausgerichtet: zweiten Punkt wählen'); return; }
|
||||
const style = dimStyleOf(c);
|
||||
const raw = computeDimValue('aligned', dimStart, e.world);
|
||||
const el: CADElement = {
|
||||
id: uid('dim'), type: 'dimension', layerId: (c.options.layerId as string | undefined) ?? 'layer-0',
|
||||
x: (dimStart.x + e.world.x) / 2, y: (dimStart.y + e.world.y) / 2,
|
||||
width: Math.abs(e.world.x - dimStart.x), height: Math.abs(e.world.y - dimStart.y),
|
||||
properties: {
|
||||
x1: dimStart.x, y1: dimStart.y, x2: e.world.x, y2: e.world.y,
|
||||
dimType: 'aligned', value: formatDimValue(raw, style), dimStyle: style.name,
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
dimStart = null;
|
||||
commitDim(el, ctx, 'Ausgerichtete Bemassung');
|
||||
},
|
||||
cancel(ctx) { dimStart = null; ctx.setPreview?.(null); ctx.setStatus('Abgebrochen'); },
|
||||
},
|
||||
};
|
||||
|
||||
export const dimAngularTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'dim-angular', label: 'Winkel', icon: '\u2220',
|
||||
ribbonTab: 'format', tags: ['pro'],
|
||||
description: 'Winkelbemassung: Scheitel, Schenkel 1, Schenkel 2',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'dimStyle', label: 'Stil', type: 'select',
|
||||
options: [{ value: 'ISO-25', label: 'ISO-25' }] },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) return;
|
||||
if (!dimVertex) { dimVertex = e.world; ctx.setStatus('Winkel: Schenkel 1 klicken'); return; }
|
||||
if (!dimStart) { dimStart = e.world; ctx.setStatus('Winkel: Schenkel 2 klicken'); return; }
|
||||
const style = dimStyleOf(c);
|
||||
const leg1 = { x: dimStart.x - dimVertex.x, y: dimStart.y - dimVertex.y };
|
||||
const leg2 = { x: e.world.x - dimVertex.x, y: e.world.y - dimVertex.y };
|
||||
const raw = computeDimValue('angular', leg1, leg2);
|
||||
const el: CADElement = {
|
||||
id: uid('dim'), type: 'dimension', layerId: (c.options.layerId as string | undefined) ?? 'layer-0',
|
||||
x: dimVertex.x, y: dimVertex.y, width: 0, height: 0,
|
||||
properties: {
|
||||
dimType: 'angular', value: formatDimValue(raw, style, 'angular'),
|
||||
vertexX: dimVertex.x, vertexY: dimVertex.y,
|
||||
leg1X: dimStart.x, leg1Y: dimStart.y, leg2X: e.world.x, leg2Y: e.world.y,
|
||||
dimStyle: style.name,
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
dimVertex = null; dimStart = null;
|
||||
commitDim(el, ctx, 'Winkelbemassung');
|
||||
},
|
||||
cancel(ctx) { dimVertex = null; dimStart = null; ctx.setPreview?.(null); ctx.setStatus('Abgebrochen'); },
|
||||
},
|
||||
};
|
||||
|
||||
function makeRadialDim(mode: 'radius' | 'diameter'): ToolExtensionV2 {
|
||||
return {
|
||||
manifest: {
|
||||
id: mode === 'radius' ? 'dim-radius' : 'dim-diameter',
|
||||
label: mode === 'radius' ? 'Radius' : 'Durchmesser',
|
||||
icon: mode === 'radius' ? '\u2199' : '\u2300',
|
||||
ribbonTab: 'format', tags: ['basic'],
|
||||
description: mode === 'radius' ? 'Radius bemassen (Kreis anklicken)' : 'Durchmesser bemassen (Kreis anklicken)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'dimStyle', label: 'Stil', type: 'select',
|
||||
options: [{ value: 'ISO-25', label: 'ISO-25' }] },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc || !c.selection) return;
|
||||
const hit = c.selection.hitTest(e.world.x, e.world.y, 8);
|
||||
if (!hit || hit.type !== 'circle') {
|
||||
ctx.setStatus(`${mode === 'radius' ? 'Radius' : 'Durchmesser'}: Kreis anklicken`);
|
||||
return;
|
||||
}
|
||||
const style = dimStyleOf(c);
|
||||
const raw = computeDimValue(mode, undefined, undefined, hit);
|
||||
const el: CADElement = {
|
||||
id: uid('dim'), type: 'dimension', layerId: hit.layerId,
|
||||
x: hit.x, y: hit.y, width: hit.width, height: hit.height,
|
||||
properties: {
|
||||
dimType: mode, value: formatDimValue(raw, style, mode),
|
||||
centerX: hit.x, centerY: hit.y,
|
||||
arrowX: hit.x + ((hit.properties as Record<string, unknown>).radius as number ?? hit.width / 2),
|
||||
arrowY: hit.y,
|
||||
dimStyle: style.name,
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
commitDim(el, ctx, mode === 'radius' ? 'Radiusbemassung' : 'Durchmesserbemassung');
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const dimRadiusTool: ToolExtensionV2 = makeRadialDim('radius');
|
||||
export const dimDiameterTool: ToolExtensionV2 = makeRadialDim('diameter');
|
||||
|
||||
export const coreAnnotatePlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'core-annotate',
|
||||
name: 'Beschriftung (Kern)',
|
||||
version: '1.0.0',
|
||||
version: '1.1.0',
|
||||
author: 'web-cad team',
|
||||
description: 'Text, Bemaßung, Leader (V2).',
|
||||
category: 'tools',
|
||||
enabledByDefault: true,
|
||||
},
|
||||
tools: [textTool, dimensionTool, leaderTool],
|
||||
tools: [textTool, dimensionTool, leaderTool, mtextTool, dimLinearTool, dimAlignedTool, dimAngularTool, dimRadiusTool, dimDiameterTool],
|
||||
};
|
||||
|
||||
@@ -414,15 +414,707 @@ function buildRevCloud(pts: Pt[], ctx: FullToolContext): 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');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task CAD-5: MLINE (Mehrfachlinie) — zwei parallele Linien
|
||||
// (Wand/Rohr) mit gap-Offset ±gap/2 entlang der Normalen.
|
||||
// Optionale Endkappen verbinden die beiden Linien an Start/Ende.
|
||||
// Klick-Sequenz wie polyline; ENTER committet ab 2 Punkten;
|
||||
// ALLE Teile in EINER Transaktion = 1 Undo-Schritt.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
let mlinePoints: Pt[] = [];
|
||||
|
||||
/**
|
||||
* Normalen-Offset-Punkte für eine Punktliste ±d (CAD-5, rein).
|
||||
* An inneren Punkten wird die Richtung aus dem Mittel der
|
||||
* angrenzenden Segment-Normalen bestimmt (Miter wie bei Wänden).
|
||||
*/
|
||||
function offsetPolyline(pts: Pt[], d: number): Pt[] {
|
||||
if (pts.length < 2) return [...pts];
|
||||
const out: Pt[] = [];
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
let nx = 0, ny = 0;
|
||||
if (i > 0) {
|
||||
const dx = pts[i].x - pts[i - 1].x;
|
||||
const dy = pts[i].y - pts[i - 1].y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
nx += -dy / len; ny += dx / len;
|
||||
}
|
||||
if (i < pts.length - 1) {
|
||||
const dx = pts[i + 1].x - pts[i].x;
|
||||
const dy = pts[i + 1].y - pts[i].y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
nx += -dy / len; ny += dx / len;
|
||||
}
|
||||
const nLen = Math.hypot(nx, ny) || 1;
|
||||
out.push({ x: pts[i].x + (nx / nLen) * d, y: pts[i].y + (ny / nLen) * d });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const mlineTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'mline',
|
||||
label: 'Mehrfachlinie',
|
||||
icon: '\u2551',
|
||||
ribbonTab: 'canvas',
|
||||
tags: ['basic'],
|
||||
description: 'Doppel-Linie (Wand): Punkte klicken, ENTER beendet (CAD-5)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'gap', label: 'Gesamtstaerke', type: 'number', min: 1 },
|
||||
{ key: 'cap', label: 'Endkappen', type: 'checkbox' },
|
||||
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
mlinePoints.push(e.world);
|
||||
ctx.setStatus(`Mehrfachlinie: ${mlinePoints.length} Punkte — ENTER beendet`);
|
||||
},
|
||||
move(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (mlinePoints.length === 0) return;
|
||||
const gap = (c.options.gap as number | undefined) ?? 20;
|
||||
const pts = [...mlinePoints, e.world];
|
||||
const a = offsetPolyline(pts, gap / 2);
|
||||
const b = offsetPolyline(pts, -gap / 2);
|
||||
ctx.setPreview?.({
|
||||
id: '__preview_mline__',
|
||||
type: 'polyline',
|
||||
layerId: 'layer-0',
|
||||
x: e.world.x, y: e.world.y, width: 0, height: 0,
|
||||
properties: { points: a, stroke: '#e0e0f0', strokeWidth: 1, mline: true },
|
||||
} as unknown as CADElement);
|
||||
},
|
||||
up() { /* Commit nur via ENTER */ },
|
||||
key(e, ctx) {
|
||||
if (e.key !== 'Enter' && e.key !== 'ENTER') return false;
|
||||
const c = ctx as FullToolContext;
|
||||
if (mlinePoints.length < 2) {
|
||||
ctx.setStatus(`Mehrfachlinie benötigt mindestens 2 Punkte (aktuell ${mlinePoints.length})`);
|
||||
return true;
|
||||
}
|
||||
if (!c.doc) return false;
|
||||
const gap = Math.max(1, (c.options.gap as number | undefined) ?? 20);
|
||||
const cap = (c.options.cap as boolean | undefined) ?? false;
|
||||
const stroke = (c.options.strokeColor as string | undefined) ?? '#e0e0f0';
|
||||
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
|
||||
const a = offsetPolyline(mlinePoints, gap / 2);
|
||||
const b = offsetPolyline(mlinePoints, -gap / 2);
|
||||
const mkLine = (pts: Pt[]): CADElement => ({
|
||||
id: uid('mline'),
|
||||
type: 'polyline',
|
||||
layerId,
|
||||
x: pts.reduce((s, p) => s + p.x, 0) / pts.length,
|
||||
y: pts.reduce((s, p) => s + p.y, 0) / pts.length,
|
||||
width: Math.max(...pts.map((p) => p.x)) - Math.min(...pts.map((p) => p.x)),
|
||||
height: Math.max(...pts.map((p) => p.y)) - Math.min(...pts.map((p) => p.y)),
|
||||
properties: { points: pts, stroke, strokeWidth: 1, mline: true, gap },
|
||||
} as unknown as CADElement);
|
||||
const els: CADElement[] = [mkLine(a), mkLine(b)];
|
||||
if (cap) {
|
||||
const capAt = (i: number): CADElement => ({
|
||||
id: uid('mlinecap'),
|
||||
type: 'line',
|
||||
layerId,
|
||||
x: (a[i].x + b[i].x) / 2,
|
||||
y: (a[i].y + b[i].y) / 2,
|
||||
width: Math.abs(b[i].x - a[i].x),
|
||||
height: Math.abs(b[i].y - a[i].y),
|
||||
properties: {
|
||||
x1: a[i].x, y1: a[i].y, x2: b[i].x, y2: b[i].y,
|
||||
stroke, strokeWidth: 1, mlineCap: true, mline: true,
|
||||
},
|
||||
} as unknown as CADElement);
|
||||
els.push(capAt(0), capAt(a.length - 1));
|
||||
}
|
||||
c.doc.transact(() => {
|
||||
for (const el of els) c.doc!.addElement(el);
|
||||
});
|
||||
const count = mlinePoints.length;
|
||||
mlinePoints = [];
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus(`Mehrfachlinie erstellt (${count} Punkte${cap ? ', mit Endkappen' : ''})`);
|
||||
return true;
|
||||
},
|
||||
cancel(ctx) {
|
||||
mlinePoints = [];
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Mehrfachlinie abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let boxStart: Pt | null = null;
|
||||
|
||||
export const selectTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'select',
|
||||
label: 'Auswahl',
|
||||
icon: '\u2196',
|
||||
ribbonTab: 'canvas',
|
||||
tags: ['basic'],
|
||||
description: 'Auswahl: Klick wählt, Drag = Box-Select, Shift additiv (CAD-14: direkt auf V2-Dokument)',
|
||||
},
|
||||
optionsSchema: [],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
const sel = c.selection;
|
||||
const hit = sel?.hitTest(e.world.x, e.world.y, 5) ?? null;
|
||||
if (hit) {
|
||||
if (e.shift) {
|
||||
// additiv: bestehende IDs + neue ID
|
||||
const current = sel?.getIds() ?? [];
|
||||
const next = current.includes(hit.id) ? current.filter((id) => id !== hit.id) : [...current, hit.id];
|
||||
sel?.setIds(next);
|
||||
} else {
|
||||
sel?.setIds([hit.id]);
|
||||
}
|
||||
sel?.onChange?.(sel?.getIds());
|
||||
c.setStatus(`Ausgewählt: ${hit.id}`);
|
||||
boxStart = null;
|
||||
} else {
|
||||
if (!e.shift) sel?.setIds([]);
|
||||
sel?.onChange?.(sel?.getIds() ?? []);
|
||||
boxStart = e.world;
|
||||
c.setStatus('Box-Select: aufziehen');
|
||||
}
|
||||
},
|
||||
move(e, ctx) {
|
||||
if (!boxStart) return;
|
||||
const c = ctx as FullToolContext;
|
||||
// Live-Vorschau der Box als gestricheltes Rechteck
|
||||
ctx.setPreview?.({
|
||||
id: '__boxselect__', type: 'rect', layerId: '',
|
||||
x: (boxStart.x + e.world.x) / 2,
|
||||
y: (boxStart.y + e.world.y) / 2,
|
||||
width: Math.abs(e.world.x - boxStart.x),
|
||||
height: Math.abs(e.world.y - boxStart.y),
|
||||
properties: { stroke: '#00aaff', strokeWidth: 1, visible: true },
|
||||
} as unknown as CADElement);
|
||||
},
|
||||
up(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!boxStart) return;
|
||||
const start = boxStart;
|
||||
boxStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
// War es ein Drag (Box) oder nur ein Klick?
|
||||
const dx = Math.abs(e.world.x - start.x);
|
||||
const dy = Math.abs(e.world.y - start.y);
|
||||
if (dx < 3 && dy < 3) return; // Klick bereits in down behandelt
|
||||
const minX = Math.min(start.x, e.world.x);
|
||||
const maxX = Math.max(start.x, e.world.x);
|
||||
const minY = Math.min(start.y, e.world.y);
|
||||
const maxY = Math.max(start.y, e.world.y);
|
||||
const all = c.doc?.getAllElements() ?? [];
|
||||
const hits = all.filter((el) => el.x >= minX && el.x <= maxX && el.y >= minY && el.y <= maxY).map((el) => el.id);
|
||||
const sel = c.selection;
|
||||
if (e.shift) {
|
||||
const current = sel?.getIds() ?? [];
|
||||
const merged = Array.from(new Set([...current, ...hits]));
|
||||
sel?.setIds(merged);
|
||||
} else {
|
||||
sel?.setIds(hits);
|
||||
}
|
||||
sel?.onChange?.(sel?.getIds() ?? []);
|
||||
c.setStatus(`Box-Select: ${hits.length} Element(e)`);
|
||||
},
|
||||
cancel(ctx) {
|
||||
boxStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Auswahl abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const coreDrawingPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'core-drawing',
|
||||
name: 'Zeichnen (Kern)',
|
||||
version: '1.3.0',
|
||||
version: '1.6.0',
|
||||
author: 'web-cad team',
|
||||
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.',
|
||||
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud, Auswahl.',
|
||||
category: 'tools',
|
||||
enabledByDefault: true,
|
||||
},
|
||||
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool],
|
||||
tools: [selectTool, lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool, mlineTool],
|
||||
};
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* Distanzformatierung via bestehendem formatDistance(unit, scaleFactor).
|
||||
*/
|
||||
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
|
||||
import { formatDistance } from '../../../utils/format';
|
||||
import { formatDistance, formatArea } from '../../../utils/format';
|
||||
import { polygonArea } from '../../../tools/modification/geometry';
|
||||
|
||||
type MeasurePhase = 0 | 1; // 0=wartet auf Start, 1=wartet auf Ende
|
||||
let measureStart: import('../../../tools/modification/geometry').Pt | null = null;
|
||||
@@ -46,6 +47,70 @@ export const measureTool: ToolExtensionV2 = {
|
||||
};
|
||||
|
||||
/** 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',
|
||||
@@ -56,5 +121,5 @@ export const coreMeasurePlugin: PluginV2 = {
|
||||
category: 'tools',
|
||||
enabledByDefault: true,
|
||||
},
|
||||
tools: [measureTool],
|
||||
tools: [measureTool, measureAreaTool],
|
||||
};
|
||||
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
extendElement,
|
||||
filletElements,
|
||||
} from '../../../tools/modification/geometry';
|
||||
import { openBlockForEdit, saveBlockEdit, discardBlockEdit } from '../../../services/blockEditorService';
|
||||
import { BLOCK_EDIT_MARKER } from '../../../services/blockEditorService';
|
||||
export { BLOCK_EDIT_MARKER };
|
||||
import type { Pt } from '../../../tools/modification/geometry';
|
||||
|
||||
const uid = (prefix: string): string =>
|
||||
@@ -82,6 +85,174 @@ function hitHandle(
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task D9: GripEditing — Vertex-/Center-Grips (reine Funktion).
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GripPoint {
|
||||
kind: 'vertex' | 'center' | 'corner';
|
||||
point: Pt;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Element-spezifische Griff-Punkte (Task D9):
|
||||
* line = beide Endpunkte, polyline/polygon = alle Vertices,
|
||||
* circle/arc = Zentrum, rect/default = 4 BBox-Ecken (C3fin-Skalierung).
|
||||
*/
|
||||
export function getGripPoints(el: CADElement): GripPoint[] {
|
||||
const props = el.properties as Record<string, unknown>;
|
||||
const type = String(el.type);
|
||||
if (
|
||||
type === 'line' &&
|
||||
props.x1 !== undefined && props.y1 !== undefined &&
|
||||
props.x2 !== undefined && props.y2 !== undefined
|
||||
) {
|
||||
return [
|
||||
{ kind: 'vertex', point: { x: props.x1 as number, y: props.y1 as number }, index: 0 },
|
||||
{ kind: 'vertex', point: { x: props.x2 as number, y: props.y2 as number }, index: 1 },
|
||||
];
|
||||
}
|
||||
if (type === 'polyline' || type === 'polygon') {
|
||||
const pts = (props.points as Pt[] | undefined) ?? [];
|
||||
return pts.map((p, i) => ({ kind: 'vertex' as const, point: { x: p.x, y: p.y }, index: i }));
|
||||
}
|
||||
if (type === 'circle' || type === 'arc') {
|
||||
return [{ kind: 'center', point: { x: el.x, y: el.y }, index: 0 }];
|
||||
}
|
||||
// rect und alle anderen: BBox-Ecken (Corner-Drag/Skalierung, C3fin)
|
||||
const minX = el.x - el.width / 2;
|
||||
const minY = el.y - el.height / 2;
|
||||
const maxX = el.x + el.width / 2;
|
||||
const maxY = el.y + el.height / 2;
|
||||
return [
|
||||
{ kind: 'corner', point: { x: minX, y: minY }, index: 0 },
|
||||
{ kind: 'corner', point: { x: maxX, y: minY }, index: 1 },
|
||||
{ kind: 'corner', point: { x: maxX, y: maxY }, index: 2 },
|
||||
{ kind: 'corner', point: { x: minX, y: maxY }, index: 3 },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet eine Vertex-/Center-Drag-Position an (Task D9) und liefert
|
||||
* das aktualisierte Element (reine Funktion — für Preview UND Commit).
|
||||
*/
|
||||
function applyVertexDrag(
|
||||
el: CADElement,
|
||||
drag: { el: CADElement; gripKind: 'vertex' | 'center'; index: number },
|
||||
wx: number,
|
||||
wy: number,
|
||||
): CADElement {
|
||||
const props = { ...(el.properties as Record<string, unknown>) };
|
||||
const type = String(el.type);
|
||||
if (drag.gripKind === 'center') {
|
||||
return { ...el, x: wx, y: wy, properties: props as CADElement['properties'] } as CADElement;
|
||||
}
|
||||
if (type === 'line') {
|
||||
if (drag.index === 0) { props.x1 = wx; props.y1 = wy; }
|
||||
else { props.x2 = wx; props.y2 = wy; }
|
||||
// BBox aktualisieren
|
||||
const x1 = props.x1 as number, y1 = props.y1 as number, x2 = props.x2 as number, y2 = props.y2 as number;
|
||||
return {
|
||||
...el,
|
||||
x: (x1 + x2) / 2,
|
||||
y: (y1 + y2) / 2,
|
||||
width: Math.abs(x2 - x1),
|
||||
height: Math.abs(y2 - y1),
|
||||
properties: props as CADElement['properties'],
|
||||
} as CADElement;
|
||||
}
|
||||
if (type === 'polyline' || type === 'polygon') {
|
||||
const pts = [...((props.points as Pt[] | undefined) ?? [])];
|
||||
if (drag.index < pts.length) pts[drag.index] = { x: wx, y: wy };
|
||||
props.points = pts;
|
||||
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
|
||||
const minX = Math.min(...xs), minY = Math.min(...ys);
|
||||
const maxX = Math.max(...xs), maxY = Math.max(...ys);
|
||||
return {
|
||||
...el,
|
||||
x: minX + (maxX - minX) / 2,
|
||||
y: minY + (maxY - minY) / 2,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
properties: props as CADElement['properties'],
|
||||
} as CADElement;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task CAD-3: Fenster-/Kreuz-Selektion (Box-Select).
|
||||
// Links-nach-rechts ziehen = 'window' (Element muss VOLLSTAENDIG
|
||||
// in der Box liegen), rechts-nach-links = 'crossing' (Beruehrung
|
||||
// reicht). Leerer down startet Box-Drag statt Selektion zu leeren.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Richtung bestimmt den Modus: to.x >= from.x → window, sonst crossing. */
|
||||
export function boxModeFor(from: Pt, to: Pt): 'window' | 'crossing' {
|
||||
return to.x >= from.x ? 'window' : 'crossing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Selektiert alle Elemente, die die Box erfuellen.
|
||||
* window: BBox des Elements VOLLSTAENDIG in der Box.
|
||||
* crossing: BBox des Elements ueberschneidet/beruehrt die Box.
|
||||
*/
|
||||
export function boxSelectElements(
|
||||
elements: CADElement[],
|
||||
box: { x1: number; y1: number; x2: number; y2: number },
|
||||
mode: 'window' | 'crossing',
|
||||
): string[] {
|
||||
const minX = Math.min(box.x1, box.x2);
|
||||
const maxX = Math.max(box.x1, box.x2);
|
||||
const minY = Math.min(box.y1, box.y2);
|
||||
const maxY = Math.max(box.y1, box.y2);
|
||||
const hits: string[] = [];
|
||||
for (const el of elements) {
|
||||
const b = elementBBox(el);
|
||||
if (!b) continue;
|
||||
if (mode === 'window') {
|
||||
if (b.minX >= minX && b.maxX <= maxX && b.minY >= minY && b.maxY <= maxY) {
|
||||
hits.push(el.id);
|
||||
}
|
||||
} else {
|
||||
// crossing: Ueberschneidung (Beruehrung inklusive)
|
||||
if (b.minX <= maxX && b.maxX >= minX && b.minY <= maxY && b.maxY >= minY) {
|
||||
hits.push(el.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** BBox eines Elements (line aus Koordinaten, sonst x/y±w/2). */
|
||||
function elementBBox(el: CADElement): { minX: number; minY: number; maxX: number; maxY: number } | null {
|
||||
const props = el.properties as Record<string, unknown>;
|
||||
if (
|
||||
props.x1 !== undefined && props.y1 !== undefined &&
|
||||
props.x2 !== undefined && props.y2 !== undefined
|
||||
) {
|
||||
const x1 = props.x1 as number, y1 = props.y1 as number;
|
||||
const x2 = props.x2 as number, y2 = props.y2 as number;
|
||||
return { minX: Math.min(x1, x2), minY: Math.min(y1, y2), maxX: Math.max(x1, x2), maxY: Math.max(y1, y2) };
|
||||
}
|
||||
const w = el.width ?? 0, h = el.height ?? 0;
|
||||
return { minX: el.x - w / 2, minY: el.y - h / 2, maxX: el.x + w / 2, maxY: el.y + h / 2 };
|
||||
}
|
||||
|
||||
/** Box-Drag-Sitzung (Task CAD-3). */
|
||||
let boxDrag: { start: Pt; shift: boolean } | null = null;
|
||||
|
||||
/** Mindestgroesse (Welt-Einheiten) ab der ein Zug als Box gilt. */
|
||||
const BOX_MIN_SIZE = 3;
|
||||
|
||||
/** Vertex-/Center-Drag-Sitzung (Task D9). */
|
||||
let vertexDrag: {
|
||||
el: CADElement;
|
||||
gripKind: 'vertex' | 'center';
|
||||
index: number;
|
||||
} | null = null;
|
||||
|
||||
export const selectTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'select',
|
||||
@@ -97,10 +268,35 @@ export const selectTool: ToolExtensionV2 = {
|
||||
const bridge = (ctx as FullToolContext).selection;
|
||||
const c = ctx as FullToolContext;
|
||||
if (!bridge) return;
|
||||
// CAD-3: verlorene Box-Sitzungen abräumen (defensive State-Hygiene,
|
||||
// schützt auch vor verlorenen up-Events); wird unten bei Leerraum neu gesetzt.
|
||||
boxDrag = null;
|
||||
const additive = e.shift;
|
||||
const subtractive = e.ctrl;
|
||||
const hit = bridge.hitTest(e.world.x, e.world.y, 5);
|
||||
|
||||
// ── Task D9: Vertex-/Center-Grip-Prüfung VOR BBox-Corners ──
|
||||
// (spezifischere Grips gewinnen gegenüber Corner-Skalierung)
|
||||
if (!additive && !subtractive && c.doc) {
|
||||
const GRIP_TOL = 8;
|
||||
for (const id of bridge.getIds()) {
|
||||
const el = c.doc.getElement(id);
|
||||
if (!el) continue;
|
||||
for (const g of getGripPoints(el)) {
|
||||
if (g.kind === 'corner') continue; // Corner unten (C3fin)
|
||||
if (
|
||||
Math.abs(e.world.x - g.point.x) <= GRIP_TOL &&
|
||||
Math.abs(e.world.y - g.point.y) <= GRIP_TOL
|
||||
) {
|
||||
vertexDrag = { el, gripKind: g.kind, index: g.index };
|
||||
const what = g.kind === 'center' ? 'Zentrum' : `Punkt ${g.index + 1}`;
|
||||
ctx.setStatus(`${what} gegriffen — ziehen zum Verschieben`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handle-Drag-Start (Task C3fin interaktiv) ──
|
||||
// Wenn bereits Elemente selektiert sind und der Klick auf eine Ecke
|
||||
// eines davon trifft → Handle-Drag starten statt neu zu wählen.
|
||||
@@ -118,7 +314,10 @@ export const selectTool: ToolExtensionV2 = {
|
||||
}
|
||||
|
||||
if (!hit) {
|
||||
if (!additive && !subtractive) bridge.setIds([]);
|
||||
// ── Task CAD-3: Leerraum-Klick startet Box-Select-Drag ──
|
||||
// (statt sofort zu leeren; das Leeren passiert nur bei Klick OHNE Zug)
|
||||
boxDrag = { start: e.world, shift: e.shift };
|
||||
ctx.setStatus('Box-Auswahl: ziehen (rechts=fenster, links=kreuzend)');
|
||||
return;
|
||||
}
|
||||
let ids = new Set(bridge.getIds());
|
||||
@@ -133,6 +332,40 @@ export const selectTool: ToolExtensionV2 = {
|
||||
},
|
||||
|
||||
move(e, ctx) {
|
||||
// ── Task CAD-3: Box-Select-Preview ──
|
||||
if (boxDrag) {
|
||||
const c = ctx as FullToolContext;
|
||||
const x = Math.min(boxDrag.start.x, e.world.x);
|
||||
const y = Math.min(boxDrag.start.y, e.world.y);
|
||||
const w = Math.abs(e.world.x - boxDrag.start.x);
|
||||
const h = Math.abs(e.world.y - boxDrag.start.y);
|
||||
const mode = boxModeFor(boxDrag.start, e.world);
|
||||
c.setPreview?.({
|
||||
id: '__preview_boxselect__',
|
||||
type: 'rect',
|
||||
layerId: 'layer-0',
|
||||
x: x + w / 2,
|
||||
y: y + h / 2,
|
||||
width: w,
|
||||
height: h,
|
||||
properties: {
|
||||
stroke: mode === 'window' ? '#4a90d9' : '#22c55e',
|
||||
strokeWidth: 1,
|
||||
fill: mode === 'window' ? 'rgba(74,144,217,0.08)' : 'rgba(34,197,94,0.08)',
|
||||
boxSelectMode: mode,
|
||||
},
|
||||
} as unknown as CADElement);
|
||||
return;
|
||||
}
|
||||
// ── Task D9: Vertex-Drag-Preview (nur der Punkt aktualisiert) ──
|
||||
if (vertexDrag) {
|
||||
const c = ctx as FullToolContext;
|
||||
const el = c.doc?.getElement(vertexDrag.el.id);
|
||||
if (!el) return;
|
||||
const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y);
|
||||
c.setPreview?.(updated);
|
||||
return;
|
||||
}
|
||||
// Live-Skalierung während Handle-Drag:
|
||||
if (!handleDrag) return;
|
||||
const c = ctx as FullToolContext;
|
||||
@@ -174,6 +407,60 @@ export const selectTool: ToolExtensionV2 = {
|
||||
},
|
||||
|
||||
up(e, ctx) {
|
||||
// ── Task CAD-3: Box-Select-Abschluss ──
|
||||
if (boxDrag) {
|
||||
const c = ctx as FullToolContext;
|
||||
const start = boxDrag.start;
|
||||
const shift = boxDrag.shift || e.shift;
|
||||
boxDrag = null;
|
||||
ctx.setPreview?.(null);
|
||||
const w = Math.abs(e.world.x - start.x);
|
||||
const h = Math.abs(e.world.y - start.y);
|
||||
// Winzige Box = Klick ohne Zug → Legacy: Selektion leeren
|
||||
if (w < BOX_MIN_SIZE && h < BOX_MIN_SIZE) {
|
||||
if (!shift) (c.selection ?? { setIds: () => {} }).setIds([]);
|
||||
ctx.setStatus('Selektion geleert');
|
||||
return;
|
||||
}
|
||||
const mode = boxModeFor(start, e.world);
|
||||
const all = c.doc ? c.doc.getAllElements() : [];
|
||||
const hits = boxSelectElements(all, {
|
||||
x1: start.x, y1: start.y, x2: e.world.x, y2: e.world.y,
|
||||
}, mode);
|
||||
const bridge = (ctx as FullToolContext).selection;
|
||||
if (!bridge) return;
|
||||
if (shift) {
|
||||
const merged = new Set([...bridge.getIds(), ...hits]);
|
||||
bridge.setIds(Array.from(merged));
|
||||
} else {
|
||||
bridge.setIds(hits);
|
||||
}
|
||||
const count = shift
|
||||
? new Set([...(bridge.getIds())]).size
|
||||
: hits.length;
|
||||
ctx.setStatus(`Box-Auswahl (${mode === 'window' ? 'Fenster' : 'Kreuzend'}): ${count} Element(e)`);
|
||||
return;
|
||||
}
|
||||
// ── Task D9: Vertex-Commit (nur der Punkt, 1 Undo-Schritt) ──
|
||||
if (vertexDrag) {
|
||||
const c = ctx as FullToolContext;
|
||||
const el = c.doc?.getElement(vertexDrag.el.id);
|
||||
if (el && c.doc) {
|
||||
const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y);
|
||||
c.doc.transact(() => {
|
||||
c.doc!.updateElement(el.id, {
|
||||
x: updated.x,
|
||||
y: updated.y,
|
||||
properties: updated.properties as CADElement['properties'],
|
||||
});
|
||||
});
|
||||
const what = vertexDrag.gripKind === 'center' ? 'Zentrum' : `Punkt ${vertexDrag.index + 1}`;
|
||||
ctx.setStatus(`${what} verschoben`);
|
||||
}
|
||||
vertexDrag = null;
|
||||
ctx.setPreview?.(null);
|
||||
return;
|
||||
}
|
||||
if (!handleDrag) return;
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) { handleDrag = null; return; }
|
||||
@@ -224,6 +511,8 @@ export const selectTool: ToolExtensionV2 = {
|
||||
|
||||
cancel(ctx) {
|
||||
handleDrag = null;
|
||||
vertexDrag = null;
|
||||
boxDrag = null;
|
||||
ctx.setPreview?.(null);
|
||||
},
|
||||
},
|
||||
@@ -239,8 +528,15 @@ export const hatchTool: ToolExtensionV2 = {
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'hatchPattern', label: 'Muster', type: 'select', options: [
|
||||
{ value: 'lines', label: 'Linien' },
|
||||
{ value: 'cross', label: 'Kreuz' },
|
||||
{ value: 'ANSI31', label: 'ANSI31 (45°)' },
|
||||
{ value: 'ANSI32', label: 'ANSI32 (Eisen)' },
|
||||
{ value: 'ANSI37', label: 'ANSI37 (Beton)' },
|
||||
{ value: 'NET', label: 'Netz' },
|
||||
{ value: 'GRASS', label: 'Gras' },
|
||||
{ value: 'DOTS', label: 'Punkte' },
|
||||
{ value: 'SOLID', label: 'Voll' },
|
||||
{ value: 'lines', label: 'Linien (alt)' },
|
||||
{ value: 'cross', label: 'Kreuz (alt)' },
|
||||
] },
|
||||
{ key: 'hatchSpacing', label: 'Abstand', type: 'number', min: 2, max: 40 },
|
||||
],
|
||||
@@ -922,6 +1218,110 @@ function makeArrayTool(mode: 'array-rect' | 'array-polar'): ToolExtensionV2 {
|
||||
export const arrayRectTool: ToolExtensionV2 = makeArrayTool('array-rect');
|
||||
export const arrayPolarTool: ToolExtensionV2 = makeArrayTool('array-polar');
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task D10-Rest: array-path — Elemente entlang einer Polyline
|
||||
// (Pfad) verteilen. Klick1: Quelle (Auto-HitSelect), Klick2: auf
|
||||
// die Pfad-Polyline klicken. count Kopien gleichmäßig über die
|
||||
// Gesamtlänge (s = i/(count-1)·total), ALLES in 1 Transaktion.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
let arrayPathBase: Pt | null = null;
|
||||
|
||||
/** Punkt auf einer Polyline bei kumulierter Länge s (0..total). */
|
||||
function pointAlongPolyline(pts: Pt[], s: number): Pt {
|
||||
if (pts.length === 0) return { x: 0, y: 0 };
|
||||
if (pts.length === 1 || s <= 0) return { ...pts[0] };
|
||||
let remaining = s;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const dx = pts[i + 1].x - pts[i].x;
|
||||
const dy = pts[i + 1].y - pts[i].y;
|
||||
const segLen = Math.hypot(dx, dy);
|
||||
if (segLen <= 0) continue;
|
||||
if (remaining <= segLen) {
|
||||
const t = remaining / segLen;
|
||||
return { x: pts[i].x + dx * t, y: pts[i].y + dy * t };
|
||||
}
|
||||
remaining -= segLen;
|
||||
}
|
||||
return { ...pts[pts.length - 1] };
|
||||
}
|
||||
|
||||
export const arrayPathTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'array-path',
|
||||
label: 'Array Pfad',
|
||||
icon: '∿',
|
||||
ribbonTab: 'canvas',
|
||||
tags: ['pro'],
|
||||
description: 'Array entlang Pfad: Auswahl treffen, dann auf Pfad-Polyline klicken (Task D10)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'count', label: 'Anzahl', type: 'number', min: 2, max: 100 },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc || !c.selection) return;
|
||||
if (!arrayPathBase) {
|
||||
if (c.selection.getIds().length === 0) {
|
||||
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
|
||||
if (!hit) {
|
||||
ctx.setStatus('Array Pfad: zuerst Element(e) auswählen');
|
||||
return;
|
||||
}
|
||||
c.selection.setIds([hit.id]);
|
||||
}
|
||||
arrayPathBase = e.world;
|
||||
ctx.setStatus(`Array Pfad: auf Pfad-Polyline klicken (${c.selection.getIds().length} Element(e))`);
|
||||
return;
|
||||
}
|
||||
// Zweiter Klick: muss eine Polyline als Pfad treffen
|
||||
const pathHit = c.selection.hitTest(e.world.x, e.world.y, 8);
|
||||
if (!pathHit || (pathHit.type !== 'polyline' && pathHit.type !== 'polygon')) {
|
||||
ctx.setStatus('Array Pfad: zweiter Klick muss eine Polyline als Pfad treffen');
|
||||
arrayPathBase = null;
|
||||
return;
|
||||
}
|
||||
const pathPts = (pathHit.properties as Record<string, unknown>).points as Pt[] | undefined;
|
||||
if (!pathPts || pathPts.length < 2) {
|
||||
ctx.setStatus('Array Pfad: Pfad-Polyline hat zu wenige Punkte');
|
||||
arrayPathBase = null;
|
||||
return;
|
||||
}
|
||||
const count = Math.max(2, (c.options.count as number | undefined) ?? 5);
|
||||
const ids = c.selection.getIds().filter((id) => id !== pathHit.id);
|
||||
// Gesamtlänge des Pfads
|
||||
let total = 0;
|
||||
for (let i = 0; i < pathPts.length - 1; i++) {
|
||||
total += Math.hypot(pathPts[i + 1].x - pathPts[i].x, pathPts[i + 1].y - pathPts[i].y);
|
||||
}
|
||||
c.doc.transact(() => {
|
||||
for (const id of ids) {
|
||||
const el = c.doc!.getElement(id);
|
||||
if (!el) continue;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const s = total * (i / Math.max(1, count - 1));
|
||||
const pos = pointAlongPolyline(pathPts, s);
|
||||
c.doc!.addElement({
|
||||
...el,
|
||||
id: uid('arrp'),
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
} as CADElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
arrayPathBase = null;
|
||||
ctx.setStatus(`Array Pfad: ${ids.length * count} Kopien entlang Pfad verteilt`);
|
||||
},
|
||||
cancel(ctx) {
|
||||
arrayPathBase = null;
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Array Pfad abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Chamfer (Task D-Ext): schneidet zwei sich schneidende Linien an einer
|
||||
// festen Distanz vom Schnittpunkt und verbindet die Enden mit einer
|
||||
@@ -1318,6 +1718,203 @@ export const offsetTool: ToolExtensionV2 = {
|
||||
};
|
||||
|
||||
/** V2-Plugin: Kern-Bearbeitungswerkzeuge. */
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Task CAD-4: DIVIDE/MEASURE — Punkte entlang eines Pfads
|
||||
// (Polyline) verteilen. DIVIDE: n gleiche Segmente → n+1 Punkte
|
||||
// inkl. Enden. MEASURE: fester Abstand inkl. Start, Rest verworfen.
|
||||
// Punkte = Konstruktionsmarker auf defpoints (1 Transaktion).
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Punkte entlang eines Polylinien-Pfads (Task CAD-4, rein).
|
||||
* segments > 0: DIVIDE — n+1 gleichmäßige Punkte inkl. Enden.
|
||||
* fixedSpacing > 0: MEASURE — Punkte bei 0, spacing, 2*spacing, …
|
||||
* solange s ≤ Gesamtlänge; Rest am Ende wird verworfen.
|
||||
*/
|
||||
export function pointsAlongPath(pts: Pt[], segments: number, fixedSpacing?: number): Pt[] {
|
||||
if (!Array.isArray(pts) || pts.length < 2) return [];
|
||||
let total = 0;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
total += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
|
||||
}
|
||||
if (total <= 0) return [...pts];
|
||||
const out: Pt[] = [];
|
||||
if (segments > 0) {
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
out.push(pointAlongPolyline(pts, (total * i) / segments));
|
||||
}
|
||||
} else if (fixedSpacing && fixedSpacing > 0) {
|
||||
for (let s = 0; s <= total + 1e-9; s += fixedSpacing) {
|
||||
out.push(pointAlongPolyline(pts, s));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** defpoints-Layer sicherstellen (CAD-4, lokal im core-modify). */
|
||||
function ensureDefpoints(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,
|
||||
});
|
||||
}
|
||||
|
||||
/** Pfad-Treffer validieren: muss polyline/polygon mit ≥2 Punkten sein. */
|
||||
function requirePathHit(
|
||||
c: FullToolContext,
|
||||
wx: number,
|
||||
wy: number,
|
||||
): { pts: Pt[]; path: CADElement } | null {
|
||||
if (!c.selection) return null;
|
||||
const hit = c.selection.hitTest(wx, wy, 8);
|
||||
if (!hit || (hit.type !== 'polyline' && hit.type !== 'polygon')) return null;
|
||||
const pts = (hit.properties as Record<string, unknown>).points as Pt[] | undefined;
|
||||
if (!pts || pts.length < 2) return null;
|
||||
return { pts, path: hit };
|
||||
}
|
||||
|
||||
/** Konstruktions-Marker-Punkt auf defpoints platzieren. */
|
||||
function makePathMarker(p: Pt): CADElement {
|
||||
return {
|
||||
id: uid('pathmark'),
|
||||
type: 'circle',
|
||||
layerId: 'defpoints',
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
width: 3,
|
||||
height: 3,
|
||||
properties: { radius: 1.5, point: true, construction: true, stroke: '#8a8f98', strokeWidth: 1 },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
export const divideTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'divide',
|
||||
label: 'Teilen',
|
||||
icon: '÷',
|
||||
ribbonTab: 'canvas',
|
||||
tags: ['pro'],
|
||||
description: 'Pfad in n gleiche Segmente teilen (DIVIDE, Task CAD-4)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'count', label: 'Anzahl Segmente', type: 'number', min: 1, max: 100 },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) return;
|
||||
const found = requirePathHit(c, e.world.x, e.world.y);
|
||||
if (!found) {
|
||||
ctx.setStatus('Teilen: Polyline als Pfad anklicken');
|
||||
return;
|
||||
}
|
||||
const segments = Math.max(1, (c.options.count as number | undefined) ?? 4);
|
||||
ensureDefpoints(c);
|
||||
const marks = pointsAlongPath(found.pts, segments);
|
||||
c.doc.transact(() => {
|
||||
for (const p of marks) c.doc!.addElement(makePathMarker(p));
|
||||
});
|
||||
ctx.setStatus(`Geteilt: ${marks.length} Punkte auf ${segments} Segmente`);
|
||||
},
|
||||
cancel(ctx) {
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Teilen abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const measureTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'measure',
|
||||
label: 'Abmessen (Punkte)',
|
||||
icon: '⋮',
|
||||
ribbonTab: 'canvas',
|
||||
tags: ['pro'],
|
||||
description: 'Punkte im festen Abstand auf Pfad (MEASURE, Task CAD-4)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'spacing', label: 'Abstand', type: 'number', min: 1 },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) return;
|
||||
const found = requirePathHit(c, e.world.x, e.world.y);
|
||||
if (!found) {
|
||||
ctx.setStatus('Abmessen: Polyline als Pfad anklicken');
|
||||
return;
|
||||
}
|
||||
const spacing = Math.max(1, (c.options.spacing as number | undefined) ?? 20);
|
||||
ensureDefpoints(c);
|
||||
const marks = pointsAlongPath(found.pts, 0, spacing);
|
||||
c.doc.transact(() => {
|
||||
for (const p of marks) c.doc!.addElement(makePathMarker(p));
|
||||
});
|
||||
ctx.setStatus(`Abgemessen: ${marks.length} Punkte alle ${spacing} Einheiten`);
|
||||
},
|
||||
cancel(ctx) {
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Abmessen abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task CAD-5: Block-Editor — Blockdefinition in-place bearbeiten.
|
||||
// Klick 1 auf Block-Instanz oeffnet (markierte Kopien erscheinen),
|
||||
// Kopien normal bearbeiten, Klick 2 speichert zurueck in die
|
||||
// Definition, cancel verwirft. (Service: blockEditorService)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const blockEditTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'block-edit',
|
||||
label: 'Block bearbeiten',
|
||||
icon: '\u2699',
|
||||
ribbonTab: 'canvas',
|
||||
tags: ['pro'],
|
||||
description: 'Block-Instanz anklicken um die Definition zu bearbeiten (CAD-5)',
|
||||
},
|
||||
optionsSchema: [],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc || !c.selection) return;
|
||||
// Offene Sitzung? → speichern
|
||||
if ((c.doc.getAllElements() as CADElement[]).some(
|
||||
(el) => (el.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER],
|
||||
)) {
|
||||
const saved = saveBlockEdit(c.doc as never);
|
||||
ctx.setStatus(saved ? `Block "${saved}" gespeichert` : 'Speichern fehlgeschlagen');
|
||||
return;
|
||||
}
|
||||
const hit = c.selection.hitTest(e.world.x, e.world.y, 8);
|
||||
if (!hit || hit.type !== 'block_instance') {
|
||||
ctx.setStatus('Block bearbeiten: Block-Instanz anklicken');
|
||||
return;
|
||||
}
|
||||
const blockId = (hit.properties as Record<string, unknown>).blockId as string | undefined;
|
||||
if (!blockId) {
|
||||
ctx.setStatus('Block bearbeiten: Instanz ohne Block-Referenz');
|
||||
return;
|
||||
}
|
||||
const opened = openBlockForEdit(c.doc as never, blockId);
|
||||
ctx.setStatus(opened ? `Block "${blockId}" geöffnet — Elemente bearbeiten, erneut klicken speichert` : 'Block konnte nicht geöffnet werden');
|
||||
},
|
||||
cancel(ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (c.doc) discardBlockEdit(c.doc as never);
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Block-Bearbeitung verworfen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const coreModifyPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'core-modify',
|
||||
@@ -1328,5 +1925,5 @@ export const coreModifyPlugin: PluginV2 = {
|
||||
category: 'tools',
|
||||
enabledByDefault: true,
|
||||
},
|
||||
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool],
|
||||
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool, arrayPathTool, divideTool, measureTool, blockEditTool],
|
||||
};
|
||||
|
||||
@@ -280,15 +280,551 @@ export const arcRowTool: ToolExtensionV2 = {
|
||||
};
|
||||
|
||||
/** 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)`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task CAD-7: Magnetisches Traversen-Tool (Truss).
|
||||
// Rechteckiger Balken (Laengen-Option 100/200/290cm), Drag
|
||||
// definiert die Richtung. MAGNETISCH: Nahe dem Ende einer
|
||||
// bestehenden Traverse dockt die neue exakt an (Position UND
|
||||
// Rotation uebernommen) - Traversen-Reihen bauen sich selbst.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Dock-Ergebnis des magnetischen Andockens. */
|
||||
export interface TrussDock {
|
||||
center: Pt;
|
||||
rotationDeg: number;
|
||||
dockedTo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpunkte einer Traverse in Weltkoordinaten (CAD-7, rein).
|
||||
* Rotation in Grad um das Zentrum (el.x/el.y).
|
||||
*/
|
||||
export function trussEndpoints(el: CADElement): [Pt, Pt] {
|
||||
const length = (el.properties.length as number | undefined) ?? el.width ?? 0;
|
||||
const rot = (((el.properties.rotation as number | undefined) ?? 0) * Math.PI) / 180;
|
||||
const half = length / 2;
|
||||
const dx = Math.cos(rot) * half;
|
||||
const dy = Math.sin(rot) * half;
|
||||
return [
|
||||
{ x: el.x - dx, y: el.y - dy },
|
||||
{ x: el.x + dx, y: el.y + dy },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Magnetisches Andocken (CAD-7, rein): Sucht unter allen Traversen
|
||||
* den Endpunkt in Dock-Toleranz und liefert die Geometrie der
|
||||
* neuen Traverse (Zentrum + Rotation), die exakt dort andockt.
|
||||
* dockedTo = ID der Anker-Traverse; null = kein Dock in Reichweite.
|
||||
*/
|
||||
export function snapTrussToDock(
|
||||
existing: CADElement[],
|
||||
cursor: Pt,
|
||||
newLength: number,
|
||||
tolerance: number,
|
||||
): TrussDock | null {
|
||||
let best: { dock: TrussDock; dist: number } | null = null;
|
||||
for (const el of existing) {
|
||||
const isTruss = String(el.type) === 'truss' && el.properties.truss === true;
|
||||
const isCorner = String(el.type) === 'boxcorner' && el.properties.boxcorner === true;
|
||||
if (!isTruss && !isCorner) continue;
|
||||
const rotDeg = (el.properties.rotation as number | undefined) ?? 0;
|
||||
const ends = isTruss ? trussEndpoints(el) : boxcornerEndpoints(el);
|
||||
for (let endIdx = 0; endIdx < ends.length; endIdx++) {
|
||||
const end = ends[endIdx];
|
||||
const dist = Math.hypot(cursor.x - end.x, cursor.y - end.y);
|
||||
if (dist > tolerance) continue;
|
||||
if (best && dist >= best.dist) continue;
|
||||
// Weg-Richtung VOM Dock-Ende (arm-spezifisch):
|
||||
// Truss-Start (idx 0) = Gegenrichtung (rot+180), Truss-Ende (idx 1) = rot,
|
||||
// Boxcorner-Arm1 (idx 0) = rot, Boxcorner-Arm2 (idx 1) = rot+90.
|
||||
let dirDeg: number;
|
||||
if (isTruss) {
|
||||
dirDeg = endIdx === 0 ? (rotDeg + 180) % 360 : rotDeg;
|
||||
} else {
|
||||
dirDeg = endIdx === 0 ? rotDeg : (rotDeg + 90) % 360;
|
||||
}
|
||||
const dirRad = (dirDeg * Math.PI) / 180;
|
||||
const half = newLength / 2;
|
||||
const centerX = end.x + Math.cos(dirRad) * half;
|
||||
const centerY = end.y + Math.sin(dirRad) * half;
|
||||
best = {
|
||||
dist,
|
||||
dock: {
|
||||
center: { x: centerX, y: centerY },
|
||||
rotationDeg: dirDeg,
|
||||
dockedTo: el.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return best ? best.dock : null;
|
||||
}
|
||||
|
||||
/** Traversen-Drag-Sitzung (CAD-7). */
|
||||
let trussDragStart: Pt | null = null;
|
||||
|
||||
function makeTrussElement(
|
||||
center: Pt,
|
||||
length: number,
|
||||
rotationDeg: number,
|
||||
layerId: string,
|
||||
trussWidth = 29,
|
||||
chordCount = 2,
|
||||
): CADElement {
|
||||
const rad = (rotationDeg * Math.PI) / 180;
|
||||
const w = Math.abs(length * Math.cos(rad)) + Math.abs(trussWidth * Math.sin(rad));
|
||||
const h = Math.abs(length * Math.sin(rad)) + Math.abs(trussWidth * Math.cos(rad));
|
||||
return {
|
||||
id: `truss_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`,
|
||||
type: 'truss',
|
||||
layerId,
|
||||
x: center.x,
|
||||
y: center.y,
|
||||
width: Math.max(w, trussWidth),
|
||||
height: Math.max(h, trussWidth),
|
||||
properties: {
|
||||
length, rotation: rotationDeg, trussWidth, chordCount, truss: true,
|
||||
stroke: '#9aa0a6', strokeWidth: 2, fill: '#6b7280',
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
const DOCK_TOLERANCE = 25;
|
||||
|
||||
export const trussTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'truss',
|
||||
label: 'Traverse (magnetisch)',
|
||||
icon: '\u2550',
|
||||
ribbonTab: 'insert',
|
||||
tags: ['basic'],
|
||||
description: 'Traverse platzieren: Drag = Richtung, dockt magnetisch an bestehenden Traversen an (CAD-7)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'length', label: 'Länge (cm)', type: 'number', min: 10, max: 1000 },
|
||||
{ key: 'trussWidth', label: 'Breite (cm)', type: 'number', min: 10, max: 60 },
|
||||
{ key: 'chordCount', label: 'Gurtrohre', type: 'number', min: 1, max: 4 },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
trussDragStart = e.world;
|
||||
ctx.setStatus('Traverse: Richtung ziehen (dockt automatisch an Traversen an)');
|
||||
},
|
||||
move(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!trussDragStart) return;
|
||||
const length = Number((c.options.length as number | string | undefined) ?? 200) || 200;
|
||||
const trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
|
||||
const chordCount = Math.max(1, Math.min(4, (c.options.chordCount as number | undefined) ?? 2));
|
||||
// Magnet-Vorschau: dockt der Cursor an ein Traversen-/Ecken-Ende?
|
||||
const existing = c.doc ? (c.doc.getAllElements() as CADElement[]).filter(
|
||||
(el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner',
|
||||
) : [];
|
||||
const dock = snapTrussToDock(existing, trussDragStart, length, DOCK_TOLERANCE);
|
||||
if (dock) {
|
||||
ctx.setPreview?.(makeTrussElement(dock.center, length, dock.rotationDeg, 'layer-0', trussWidth, chordCount));
|
||||
return;
|
||||
}
|
||||
// Frei: Richtung aus Drag
|
||||
const ang = (Math.atan2(e.world.y - trussDragStart.y, e.world.x - trussDragStart.x) * 180) / Math.PI;
|
||||
const cx = (trussDragStart.x + e.world.x) / 2;
|
||||
const cy = (trussDragStart.y + e.world.y) / 2;
|
||||
ctx.setPreview?.(makeTrussElement({ x: cx, y: cy }, length, ang, 'layer-0', trussWidth, chordCount));
|
||||
},
|
||||
up(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
const start = trussDragStart;
|
||||
trussDragStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
if (!start || !c.doc) return;
|
||||
const length = Number((c.options.length as string | undefined) ?? 200) || 200;
|
||||
const trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
|
||||
const chordCount = Math.max(1, Math.min(4, (c.options.chordCount as number | undefined) ?? 2));
|
||||
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
|
||||
// MAGNETISCH: Dock-Pruefung am Drag-Startpunkt (Traversen UND Ecken)
|
||||
const existing = (c.doc.getAllElements() as CADElement[]).filter(
|
||||
(el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner',
|
||||
);
|
||||
const dock = snapTrussToDock(existing, start, length, DOCK_TOLERANCE);
|
||||
let el: CADElement;
|
||||
if (dock) {
|
||||
el = makeTrussElement(dock.center, length, dock.rotationDeg, layerId, trussWidth, chordCount);
|
||||
ctx.setStatus(`Traverse angedockt an ${dock.dockedTo} (${length}cm, Breite ${trussWidth}cm)`);
|
||||
} else {
|
||||
const ang = (Math.atan2(e.world.y - start.y, e.world.x - start.x) * 180) / Math.PI;
|
||||
const cx = (start.x + e.world.x) / 2;
|
||||
const cy = (start.y + e.world.y) / 2;
|
||||
el = makeTrussElement({ x: cx, y: cy }, length, ang, layerId, trussWidth, chordCount);
|
||||
ctx.setStatus(`Traverse platziert (${length}cm, Breite ${trussWidth}cm)`);
|
||||
}
|
||||
c.doc.transact(() => { c.doc!.addElement(el); });
|
||||
},
|
||||
cancel(ctx) {
|
||||
trussDragStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Traverse abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task CAD-8: Boxcorner (Eck-Traverse) — L-foermiges Element
|
||||
// mit zwei Armen (rot und rot+90). Magnetisch: dockt an
|
||||
// Traversen-Enden und andere Ecken; Traversen docken an die
|
||||
// Armenden. DREHBAR via properties.rotation (PropertiesPanel).
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Armenden einer Boxcorner: Endpunkte beider Arme vom Eckpunkt. */
|
||||
export function boxcornerEndpoints(el: CADElement): [Pt, Pt] {
|
||||
const armLength = (el.properties.armLength as number | undefined) ?? 50;
|
||||
const rotDeg = (el.properties.rotation as number | undefined) ?? 0;
|
||||
const angle = (el.properties.angle as number | undefined) ?? 90;
|
||||
const rot = (rotDeg * Math.PI) / 180;
|
||||
const rot2 = ((rotDeg + angle) * Math.PI) / 180;
|
||||
return [
|
||||
{ x: el.x + Math.cos(rot) * armLength, y: el.y + Math.sin(rot) * armLength },
|
||||
{ x: el.x + Math.cos(rot2) * armLength, y: el.y + Math.sin(rot2) * armLength },
|
||||
];
|
||||
}
|
||||
|
||||
export interface CornerDock {
|
||||
vertex: Pt;
|
||||
rotationDeg: number;
|
||||
dockedTo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magnetisches Andocken einer Ecke (rein): dockt an Traversen-
|
||||
* Enden und Ecken-Armenden; am Traversen-Start-Ende wird die
|
||||
* Gegenrichtung uebernommen (rot+180), damit Reihen nach aussen
|
||||
* wachsen.
|
||||
*/
|
||||
export function cornerDockToDock(
|
||||
existing: CADElement[],
|
||||
cursor: Pt,
|
||||
tolerance: number,
|
||||
): CornerDock | null {
|
||||
let best: { dock: CornerDock; dist: number } | null = null;
|
||||
for (const el of existing) {
|
||||
const isTruss = String(el.type) === 'truss' && el.properties.truss === true;
|
||||
const isCorner = String(el.type) === 'boxcorner' && el.properties.boxcorner === true;
|
||||
if (!isTruss && !isCorner) continue;
|
||||
const rotDeg = (el.properties.rotation as number | undefined) ?? 0;
|
||||
const ends = isTruss ? trussEndpoints(el) : boxcornerEndpoints(el);
|
||||
for (let endIdx = 0; endIdx < ends.length; endIdx++) {
|
||||
const end = ends[endIdx];
|
||||
const dist = Math.hypot(cursor.x - end.x, cursor.y - end.y);
|
||||
if (dist > tolerance) continue;
|
||||
if (best && dist >= best.dist) continue;
|
||||
const dir = isTruss && endIdx === 0 ? -1 : 1;
|
||||
const newRot = isTruss && endIdx === 0 ? (rotDeg + 180) % 360 : rotDeg;
|
||||
void dir;
|
||||
best = {
|
||||
dist,
|
||||
dock: { vertex: { x: end.x, y: end.y }, rotationDeg: newRot, dockedTo: el.id },
|
||||
};
|
||||
}
|
||||
}
|
||||
return best ? best.dock : null;
|
||||
}
|
||||
|
||||
function makeBoxcornerElement(
|
||||
vertex: Pt,
|
||||
armLength: number,
|
||||
rotationDeg: number,
|
||||
angleDeg: number,
|
||||
trussWidth: number,
|
||||
layerId: string,
|
||||
): CADElement {
|
||||
return {
|
||||
id: `boxcorner_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`,
|
||||
type: 'boxcorner',
|
||||
layerId,
|
||||
x: vertex.x,
|
||||
y: vertex.y,
|
||||
width: armLength + trussWidth,
|
||||
height: armLength + trussWidth,
|
||||
properties: {
|
||||
armLength, trussWidth, rotation: rotationDeg, angle: angleDeg, boxcorner: true,
|
||||
stroke: '#9aa0a6', strokeWidth: 2, fill: '#6b7280',
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
let cornerDragStart: Pt | null = null;
|
||||
|
||||
export const boxCornerTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'boxcorner',
|
||||
label: 'Eck-Traverse (magnetisch)',
|
||||
icon: '╭',
|
||||
ribbonTab: 'insert',
|
||||
tags: ['basic'],
|
||||
description: 'Eck-Traverse (Boxcorner): Drag = Richtung, dockt magnetisch an (CAD-8)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'armLength', label: 'Armlänge (cm)', type: 'number', min: 20, max: 150 },
|
||||
{ key: 'angle', label: 'Winkel (°)', type: 'number', min: 15, max: 165 },
|
||||
{ key: 'trussWidth', label: 'Breite (cm)', type: 'number', min: 10, max: 60 },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
cornerDragStart = e.world;
|
||||
ctx.setStatus('Eck-Traverse: Richtung ziehen (dockt automatisch an)');
|
||||
},
|
||||
move(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!cornerDragStart) return;
|
||||
const armLength = (c.options.armLength as number | undefined) ?? 50;
|
||||
const cornerAngle = (c.options.angle as number | undefined) ?? 90;
|
||||
const trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
|
||||
const existing = c.doc ? (c.doc.getAllElements() as CADElement[]).filter(
|
||||
(el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner',
|
||||
) : [];
|
||||
const dock = cornerDockToDock(existing, cornerDragStart, DOCK_TOLERANCE);
|
||||
if (dock) {
|
||||
ctx.setPreview?.(makeBoxcornerElement(dock.vertex, armLength, dock.rotationDeg, cornerAngle, trussWidth, 'layer-0'));
|
||||
return;
|
||||
}
|
||||
const ang = (Math.atan2(e.world.y - cornerDragStart.y, e.world.x - cornerDragStart.x) * 180) / Math.PI;
|
||||
ctx.setPreview?.(makeBoxcornerElement(cornerDragStart, armLength, ang, cornerAngle, trussWidth, 'layer-0'));
|
||||
},
|
||||
up(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
const start = cornerDragStart;
|
||||
cornerDragStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
if (!start || !c.doc) return;
|
||||
const armLength = (c.options.armLength as number | undefined) ?? 50;
|
||||
const cornerAngle = (c.options.angle as number | undefined) ?? 90;
|
||||
const trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
|
||||
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
|
||||
const existing = (c.doc.getAllElements() as CADElement[]).filter(
|
||||
(el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner',
|
||||
);
|
||||
const dock = cornerDockToDock(existing, start, DOCK_TOLERANCE);
|
||||
let el: CADElement;
|
||||
if (dock) {
|
||||
el = makeBoxcornerElement(dock.vertex, armLength, dock.rotationDeg, cornerAngle, trussWidth, layerId);
|
||||
ctx.setStatus(`Eck-Traverse angedockt an ${dock.dockedTo}`);
|
||||
} else {
|
||||
const ang = (Math.atan2(e.world.y - start.y, e.world.x - start.x) * 180) / Math.PI;
|
||||
el = makeBoxcornerElement(start, armLength, ang, cornerAngle, trussWidth, layerId);
|
||||
ctx.setStatus(`Eck-Traverse platziert (${armLength}cm Arme, Breite ${trussWidth}cm)`);
|
||||
}
|
||||
c.doc.transact(() => { c.doc!.addElement(el); });
|
||||
},
|
||||
cancel(ctx) {
|
||||
cornerDragStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Eck-Traverse abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Task CAD-11: Kreis-Traverse
|
||||
function makeCircleTrussElement(
|
||||
center: Pt,
|
||||
diameter: number,
|
||||
trussWidth: number,
|
||||
chordCount: number,
|
||||
layerId: string,
|
||||
): CADElement {
|
||||
return {
|
||||
id: `circletruss_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`,
|
||||
type: 'circletruss',
|
||||
layerId,
|
||||
x: center.x,
|
||||
y: center.y,
|
||||
width: diameter,
|
||||
height: diameter,
|
||||
properties: {
|
||||
diameter, trussWidth, chordCount, circletruss: true,
|
||||
stroke: '#9aa0a6', strokeWidth: 2, fill: 'none',
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
export const circleTrussTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'circle-truss',
|
||||
label: 'Kreis-Traverse',
|
||||
icon: '○',
|
||||
ribbonTab: 'insert',
|
||||
tags: ['basic'],
|
||||
description: 'Kreis-Traverse platzieren (CAD-11)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'diameter', label: 'Durchmesser (cm)', type: 'number', min: 50, max: 1500 },
|
||||
{ key: 'trussWidth', label: 'Breite (cm)', type: 'number', min: 10, max: 60 },
|
||||
{ key: 'chordCount', label: 'Gurtrohre', type: 'number', min: 1, max: 4 },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) return;
|
||||
const diameter = Math.max(50, (c.options.diameter as number | undefined) ?? 200);
|
||||
const trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
|
||||
const chordCount = Math.max(1, Math.min(4, (c.options.chordCount as number | undefined) ?? 2));
|
||||
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
|
||||
const el = makeCircleTrussElement(e.world, diameter, trussWidth, chordCount, layerId);
|
||||
c.doc.transact(() => { c.doc!.addElement(el); });
|
||||
ctx.setStatus('Kreis-Traverse platziert');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const eventToolsV2Plugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'event-tools',
|
||||
name: 'Event-Bestuhlung',
|
||||
version: '2.1.0',
|
||||
version: '2.2.0',
|
||||
author: 'web-cad team',
|
||||
description: 'Stühle, Reihen (Drag), Blöcke (Drag), Tische, Bühne (V2).',
|
||||
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],
|
||||
tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool, trussTool, boxCornerTool, circleTrussTool],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
{
|
||||
"folderId": "cat-arch",
|
||||
"label": "Architektur-Katalog",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "arch-1",
|
||||
"name": "Tür 900",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a1a",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": 0,
|
||||
"y1": 0,
|
||||
"x2": 900,
|
||||
"y2": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a1b",
|
||||
"type": "arc",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1800,
|
||||
"height": 1800,
|
||||
"properties": {
|
||||
"radius": 900,
|
||||
"startAngle": 0,
|
||||
"endAngle": 90
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-2",
|
||||
"name": "Doppel-Tür 1800",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a2a",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": -900,
|
||||
"y1": 0,
|
||||
"x2": -450,
|
||||
"y2": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a2b",
|
||||
"type": "arc",
|
||||
"layerId": "layer-0",
|
||||
"x": -900,
|
||||
"y": 0,
|
||||
"width": 900,
|
||||
"height": 900,
|
||||
"properties": {
|
||||
"radius": 450,
|
||||
"startAngle": 0,
|
||||
"endAngle": 90
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a2c",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": 450,
|
||||
"y1": 0,
|
||||
"x2": 900,
|
||||
"y2": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a2d",
|
||||
"type": "arc",
|
||||
"layerId": "layer-0",
|
||||
"x": 900,
|
||||
"y": 0,
|
||||
"width": 900,
|
||||
"height": 900,
|
||||
"properties": {
|
||||
"radius": 450,
|
||||
"startAngle": 90,
|
||||
"endAngle": 180
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-3",
|
||||
"name": "Tür 1200",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a3a",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": 0,
|
||||
"y1": 0,
|
||||
"x2": 1200,
|
||||
"y2": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a3b",
|
||||
"type": "arc",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 2400,
|
||||
"height": 2400,
|
||||
"properties": {
|
||||
"radius": 1200,
|
||||
"startAngle": 0,
|
||||
"endAngle": 90
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-4",
|
||||
"name": "Fenster 1200",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a4a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": -30,
|
||||
"width": 1200,
|
||||
"height": 20,
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": "a4b",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 30,
|
||||
"width": 1200,
|
||||
"height": 20,
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-5",
|
||||
"name": "Fenster 2400",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a5a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": -30,
|
||||
"width": 2400,
|
||||
"height": 20,
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": "a5b",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 30,
|
||||
"width": 2400,
|
||||
"height": 20,
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-6",
|
||||
"name": "Wandstück 2m",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a6a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 2000,
|
||||
"height": 240,
|
||||
"properties": {
|
||||
"fill": "#8a8f98"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-7",
|
||||
"name": "Wandstück vertikal 2m",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a7a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 240,
|
||||
"height": 2000,
|
||||
"properties": {
|
||||
"fill": "#8a8f98"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-8",
|
||||
"name": "Treppe (8 Stufen)",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a8s0",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": -350,
|
||||
"y1": 0,
|
||||
"x2": -350,
|
||||
"y2": 1200
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a8s1",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": -250,
|
||||
"y1": 0,
|
||||
"x2": -250,
|
||||
"y2": 1200
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a8s2",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": -150,
|
||||
"y1": 0,
|
||||
"x2": -150,
|
||||
"y2": 1200
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a8s3",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": -50,
|
||||
"y1": 0,
|
||||
"x2": -50,
|
||||
"y2": 1200
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a8s4",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": 50,
|
||||
"y1": 0,
|
||||
"x2": 50,
|
||||
"y2": 1200
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a8s5",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": 150,
|
||||
"y1": 0,
|
||||
"x2": 150,
|
||||
"y2": 1200
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a8s6",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": 250,
|
||||
"y1": 0,
|
||||
"x2": 250,
|
||||
"y2": 1200
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a8s7",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": 350,
|
||||
"y1": 0,
|
||||
"x2": 350,
|
||||
"y2": 1200
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a8la",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": -450,
|
||||
"y1": 0,
|
||||
"x2": -450,
|
||||
"y2": 1200
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a8lb",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": 450,
|
||||
"y1": 0,
|
||||
"x2": 450,
|
||||
"y2": 1200
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-9",
|
||||
"name": "Säule Ø50",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a9a",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 500,
|
||||
"height": 500,
|
||||
"properties": {
|
||||
"radius": 250,
|
||||
"fill": "#9aa0a6"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-10",
|
||||
"name": "Schreibtisch",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a10a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1800,
|
||||
"height": 800,
|
||||
"properties": {
|
||||
"fill": "#6b7280"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-11",
|
||||
"name": "Bürostuhl",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a11a",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 60,
|
||||
"height": 60,
|
||||
"properties": {
|
||||
"fill": "#5a7d9a",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-12",
|
||||
"name": "Sofa 3-Sitzer",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a12a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 2200,
|
||||
"height": 900,
|
||||
"properties": {
|
||||
"fill": "#5a7d9a"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-13",
|
||||
"name": "Einzelbett",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a13a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 900,
|
||||
"height": 2000,
|
||||
"properties": {
|
||||
"fill": "#8fa3bf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a13b",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": -750,
|
||||
"width": 700,
|
||||
"height": 300,
|
||||
"properties": {
|
||||
"fill": "#d5dbdb"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-14",
|
||||
"name": "Waschbecken",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a14a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 600,
|
||||
"height": 450,
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": "a14b",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 340,
|
||||
"height": 340,
|
||||
"properties": {
|
||||
"radius": 170,
|
||||
"fill": "#7f9db9"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch-15",
|
||||
"name": "Küchenzeile 2.4m",
|
||||
"elements": [
|
||||
{
|
||||
"id": "a15a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 2400,
|
||||
"height": 600,
|
||||
"properties": {
|
||||
"fill": "#3c4043"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a15p0",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": -1800,
|
||||
"y": 0,
|
||||
"width": 180,
|
||||
"height": 180,
|
||||
"properties": {
|
||||
"radius": 90,
|
||||
"fill": "#202124"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a15p1",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": -1320,
|
||||
"y": 0,
|
||||
"width": 180,
|
||||
"height": 180,
|
||||
"properties": {
|
||||
"radius": 90,
|
||||
"fill": "#202124"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a15p2",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": -840,
|
||||
"y": 0,
|
||||
"width": 180,
|
||||
"height": 180,
|
||||
"properties": {
|
||||
"radius": 90,
|
||||
"fill": "#202124"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a15p3",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": -360,
|
||||
"y": 0,
|
||||
"width": 180,
|
||||
"height": 180,
|
||||
"properties": {
|
||||
"radius": 90,
|
||||
"fill": "#202124"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
{
|
||||
"folderId": "cat-event",
|
||||
"label": "Event-Katalog",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "evt-1",
|
||||
"name": "Stuhl",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e1",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-2",
|
||||
"name": "Tisch rund 180cm",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e2a",
|
||||
"type": "table",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1800,
|
||||
"height": 1800,
|
||||
"properties": {
|
||||
"fill": "#8b7355"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-3",
|
||||
"name": "Tisch rund 120cm",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e3a",
|
||||
"type": "table",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1200,
|
||||
"height": 1200,
|
||||
"properties": {
|
||||
"fill": "#8b7355"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-4",
|
||||
"name": "Stehtisch 80cm",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e4a",
|
||||
"type": "table",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 800,
|
||||
"height": 800,
|
||||
"properties": {
|
||||
"fill": "#a08f6a"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-5",
|
||||
"name": "Bühne 3x2m",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e5a",
|
||||
"type": "stage",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 3000,
|
||||
"height": 2000,
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-6",
|
||||
"name": "Bühne 4x3m",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e6a",
|
||||
"type": "stage",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-7",
|
||||
"name": "Podest 1x1m",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e7a",
|
||||
"type": "stage",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1000,
|
||||
"height": 1000,
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-8",
|
||||
"name": "Sitzreihe (10 Stühle)",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e2",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": -405,
|
||||
"y": 0,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e3",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": -315,
|
||||
"y": 0,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e4",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": -225,
|
||||
"y": 0,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e5",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": -135,
|
||||
"y": 0,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e6",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": -45,
|
||||
"y": 0,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e7",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 45,
|
||||
"y": 0,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e8",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 135,
|
||||
"y": 0,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e9",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 225,
|
||||
"y": 0,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e10",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 315,
|
||||
"y": 0,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e11",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 405,
|
||||
"y": 0,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-9",
|
||||
"name": "Bankett-Rundtisch (8 Stühle)",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e9a",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1800,
|
||||
"height": 1800,
|
||||
"properties": {
|
||||
"radius": 900,
|
||||
"fill": "#8b7355"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e12",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 1200,
|
||||
"y": 0,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e13",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 848,
|
||||
"y": 848,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e14",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 1200,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e15",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": -848,
|
||||
"y": 848,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e16",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": -1200,
|
||||
"y": 0,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e17",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": -848,
|
||||
"y": -848,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e18",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": -1200,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e19",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 848,
|
||||
"y": -848,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-10",
|
||||
"name": "Tischgruppe (4 Stühle)",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e10a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1600,
|
||||
"height": 800,
|
||||
"properties": {
|
||||
"fill": "#8b7355"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e20",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": -400,
|
||||
"y": -580,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e21",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 400,
|
||||
"y": -580,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e22",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": -400,
|
||||
"y": 580,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e23",
|
||||
"type": "chair",
|
||||
"layerId": "layer-0",
|
||||
"x": 400,
|
||||
"y": 580,
|
||||
"width": 45,
|
||||
"height": 45,
|
||||
"properties": {
|
||||
"fill": "#4a90d9",
|
||||
"outlineColor": "#2a5a99"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-11",
|
||||
"name": "Flipchart",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e11a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 700,
|
||||
"height": 500,
|
||||
"properties": {
|
||||
"fill": "#546b7a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e11b",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": 0,
|
||||
"y1": 250,
|
||||
"x2": 0,
|
||||
"y2": 500
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-12",
|
||||
"name": "Pinnwand 120cm",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e12a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1200,
|
||||
"height": 150,
|
||||
"properties": {
|
||||
"fill": "#7a6550"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-13",
|
||||
"name": "Kaffeetafel 2m",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e13a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 2000,
|
||||
"height": 600,
|
||||
"properties": {
|
||||
"fill": "#6b5340"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-14",
|
||||
"name": "Garderobe 1m",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e14a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1000,
|
||||
"height": 300,
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": "e14h0",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": -300,
|
||||
"y": 0,
|
||||
"width": 180,
|
||||
"height": 180,
|
||||
"properties": {
|
||||
"radius": 90
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e14h1",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": -150,
|
||||
"y": 0,
|
||||
"width": 180,
|
||||
"height": 180,
|
||||
"properties": {
|
||||
"radius": 90
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e14h2",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 180,
|
||||
"height": 180,
|
||||
"properties": {
|
||||
"radius": 90
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e14h3",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 150,
|
||||
"y": 0,
|
||||
"width": 180,
|
||||
"height": 180,
|
||||
"properties": {
|
||||
"radius": 90
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e14h4",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 300,
|
||||
"y": 0,
|
||||
"width": 180,
|
||||
"height": 180,
|
||||
"properties": {
|
||||
"radius": 90
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "evt-15",
|
||||
"name": "Teppichläufer 2m",
|
||||
"elements": [
|
||||
{
|
||||
"id": "e15a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 2000,
|
||||
"height": 200,
|
||||
"properties": {
|
||||
"fill": "#7a5230"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
{
|
||||
"folderId": "cat-land",
|
||||
"label": "Landschafts-Katalog",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "land-1",
|
||||
"name": "Baum (groß)",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l1a",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": -200,
|
||||
"width": 2200,
|
||||
"height": 2200,
|
||||
"properties": {
|
||||
"radius": 1100,
|
||||
"fill": "#6aa84f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l1b",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 600,
|
||||
"width": 200,
|
||||
"height": 800,
|
||||
"properties": {
|
||||
"fill": "#8b5a2b"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-2",
|
||||
"name": "Baum (mittel)",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l2a",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": -150,
|
||||
"width": 1500,
|
||||
"height": 1500,
|
||||
"properties": {
|
||||
"radius": 750,
|
||||
"fill": "#6aa84f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l2b",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 450,
|
||||
"width": 150,
|
||||
"height": 600,
|
||||
"properties": {
|
||||
"fill": "#8b5a2b"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-3",
|
||||
"name": "Strauch",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l3a",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1000,
|
||||
"height": 1000,
|
||||
"properties": {
|
||||
"radius": 500,
|
||||
"fill": "#85b75f"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-4",
|
||||
"name": "Hecke horizontal 4m",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l4a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 4000,
|
||||
"height": 500,
|
||||
"properties": {
|
||||
"fill": "#4c7a3f"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-5",
|
||||
"name": "Hecke vertikal 4m",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l5a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 500,
|
||||
"height": 4000,
|
||||
"properties": {
|
||||
"fill": "#4c7a3f"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-6",
|
||||
"name": "Blumenbeet",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l6a",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1600,
|
||||
"height": 1600,
|
||||
"properties": {
|
||||
"radius": 800,
|
||||
"fill": "#c76fb6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l6b",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 600,
|
||||
"height": 600,
|
||||
"properties": {
|
||||
"radius": 300,
|
||||
"fill": "#e6a23c"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-7",
|
||||
"name": "Teich",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l7a",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 3000,
|
||||
"height": 3000,
|
||||
"properties": {
|
||||
"radius": 1500,
|
||||
"fill": "#4a90d9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l7b",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 100,
|
||||
"width": 2700,
|
||||
"height": 2700,
|
||||
"properties": {
|
||||
"radius": 1350,
|
||||
"fill": "#5fa8e0"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-8",
|
||||
"name": "Rasenfläche 5x3m",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l8a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 5000,
|
||||
"height": 3000,
|
||||
"properties": {
|
||||
"fill": "#7cb342"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-9",
|
||||
"name": "Bodenplatte",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l9a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1200,
|
||||
"height": 1200,
|
||||
"properties": {
|
||||
"fill": "#b8a888"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-10",
|
||||
"name": "Weg (Gerade)",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l10a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 600,
|
||||
"height": 4000,
|
||||
"properties": {
|
||||
"fill": "#b8a888"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-11",
|
||||
"name": "Sonnenliege",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l11a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 600,
|
||||
"height": 2000,
|
||||
"properties": {
|
||||
"fill": "#d2b48c"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-12",
|
||||
"name": "Feuerschale",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l12a",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1200,
|
||||
"height": 1200,
|
||||
"properties": {
|
||||
"radius": 600,
|
||||
"fill": "#5a5a5a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l12b",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 600,
|
||||
"height": 600,
|
||||
"properties": {
|
||||
"radius": 300,
|
||||
"fill": "#cc4125"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-13",
|
||||
"name": "Zaun 3m",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l13a",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": -1500,
|
||||
"y1": 0,
|
||||
"x2": -1500,
|
||||
"y2": 900
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l13b",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": 1500,
|
||||
"y1": 0,
|
||||
"x2": 1500,
|
||||
"y2": 900
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l13c",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": -1500,
|
||||
"y1": 450,
|
||||
"x2": 1500,
|
||||
"y2": 450
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-14",
|
||||
"name": "Laterne",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l14a",
|
||||
"type": "circle",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": -1000,
|
||||
"width": 400,
|
||||
"height": 400,
|
||||
"properties": {
|
||||
"radius": 200,
|
||||
"fill": "#f5d76e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l14b",
|
||||
"type": "line",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"properties": {
|
||||
"x1": 0,
|
||||
"y1": -800,
|
||||
"x2": 0,
|
||||
"y2": 1000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l14c",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 1100,
|
||||
"width": 300,
|
||||
"height": 200,
|
||||
"properties": {
|
||||
"fill": "#5a5a5a"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "land-15",
|
||||
"name": "Gartenbank",
|
||||
"elements": [
|
||||
{
|
||||
"id": "l15a",
|
||||
"type": "rect",
|
||||
"layerId": "layer-0",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1500,
|
||||
"height": 400,
|
||||
"properties": {
|
||||
"fill": "#8b5a2b"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Task F4 – Builtin-Bibliotheks-Plugin.
|
||||
*
|
||||
* Registriert den builtin-Katalog-Provider über die Plugin-API v2. Die Provider
|
||||
* für user-library (zeichnungsscoped) und global-library (API/Token) sind als
|
||||
* Fabriken (makeUserLibraryProvider / makeGlobalLibraryProvider) umgesetzt und
|
||||
* werden vom UI je Token/Zeichnung instanziert.
|
||||
*/
|
||||
import type { PluginV2 } from '../../types';
|
||||
import { builtinCatalogProvider } from './providers';
|
||||
|
||||
export const builtinLibraryPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'builtin-library',
|
||||
name: 'Block-Bibliothek',
|
||||
version: '1.0.0',
|
||||
author: 'WebCAD Project',
|
||||
description: 'Bibliothek: builtin-Kataloge (Event/Architektur/Landschaft) als LibraryProvider (Task F4).',
|
||||
category: 'library',
|
||||
enabledByDefault: true,
|
||||
permissions: ['storage'],
|
||||
},
|
||||
libraryProviders: [builtinCatalogProvider],
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Task F4 – Bibliotheksquellen als LibraryProviderExtension.
|
||||
*
|
||||
* Drei Quellen:
|
||||
* - builtinCatalogProvider: statische JSON-Startersets (event/architecture/landscape)
|
||||
* - makeUserLibraryProvider: zeichnungsscoped Blockdefinitionen (Drawing-Blöcke)
|
||||
* - makeGlobalLibraryProvider: persistente globale Blockbibliothek (global-blocks API)
|
||||
*
|
||||
* Payload-Konvention: String-Payload = JSON-Array von CADElementen — identisch zum
|
||||
* 'text/global-block-data' Drop-Kanal von BlockLibraryTree (F2/F3), damit der
|
||||
* Canvas-Drop-Pfad (CanvasArea) ohne Änderung funktioniert.
|
||||
*/
|
||||
import catalogEvent from './catalogs/event.json';
|
||||
import catalogArch from './catalogs/architecture.json';
|
||||
import catalogLand from './catalogs/landscape.json';
|
||||
import { generateBlockSvg } from '../../../utils/blockThumbnail';
|
||||
import type { CADElement } from '../../../types/cad.types';
|
||||
import type { LibraryProviderExtension, LibBlock, LibFolder } from '../../types';
|
||||
import { getGlobalFolders, getGlobalBlocks, type GlobalBlock } from '../../../services/api';
|
||||
|
||||
interface CatalogBlock { id: string; name: string; elements: CADElement[]; }
|
||||
interface CatalogFile { folderId: string; label: string; blocks: CatalogBlock[]; }
|
||||
|
||||
const CATALOGS: CatalogFile[] = [catalogEvent, catalogArch, catalogLand].map(
|
||||
(c) => c as unknown as CatalogFile,
|
||||
);
|
||||
|
||||
const thumbCache = new Map<string, string>();
|
||||
|
||||
function toLibBlock(folderId: string, block: CatalogBlock): LibBlock {
|
||||
let thumbnail = thumbCache.get(block.id);
|
||||
if (!thumbnail) {
|
||||
thumbnail = generateBlockSvg(block.elements);
|
||||
thumbCache.set(block.id, thumbnail);
|
||||
}
|
||||
return {
|
||||
id: block.id,
|
||||
folderId,
|
||||
name: block.name,
|
||||
thumbnail,
|
||||
payload: JSON.stringify(block.elements),
|
||||
};
|
||||
}
|
||||
|
||||
/** Statischer builtin-Katalog: Event / Architektur / Landschaft Startersets. */
|
||||
export const builtinCatalogProvider: LibraryProviderExtension = {
|
||||
id: 'builtin-catalog',
|
||||
label: 'Builtin-Kataloge',
|
||||
async listFolders(): Promise<LibFolder[]> {
|
||||
return CATALOGS.map((c) => ({ id: c.folderId, label: c.label, parentId: null }));
|
||||
},
|
||||
async listBlocks(folderId: string): Promise<LibBlock[]> {
|
||||
const cat = CATALOGS.find((c) => c.folderId === folderId);
|
||||
return cat ? cat.blocks.map((b) => toLibBlock(folderId, b)) : [];
|
||||
},
|
||||
async getBlock(id: string): Promise<LibBlock> {
|
||||
for (const cat of CATALOGS) {
|
||||
const found = cat.blocks.find((b) => b.id === id);
|
||||
if (found) return toLibBlock(cat.folderId, found);
|
||||
}
|
||||
throw new Error(`Block not found: ${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export interface UserBlockLike {
|
||||
id: string;
|
||||
name: string;
|
||||
elements?: unknown;
|
||||
elements_json?: string;
|
||||
thumbnail?: string | null;
|
||||
drawing_id?: string;
|
||||
description?: string | null;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
function extractElements(block: UserBlockLike): CADElement[] {
|
||||
try {
|
||||
if (Array.isArray(block.elements)) return block.elements as CADElement[];
|
||||
if (typeof block.elements_json === 'string') {
|
||||
const parsed: unknown = JSON.parse(block.elements_json);
|
||||
if (Array.isArray(parsed)) return parsed as CADElement[];
|
||||
const wrapped = parsed as { elements?: unknown } | null;
|
||||
if (wrapped && Array.isArray(wrapped.elements)) return wrapped.elements as CADElement[];
|
||||
}
|
||||
} catch {
|
||||
// invalider Inhalt → leerer Block wird beim Auflisten gefiltert
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Zeichnungsscoped Blöcke (Drawing-Blöcke) als Bibliothek anbinden. */
|
||||
export function makeUserLibraryProvider(userBlocks: readonly UserBlockLike[]): LibraryProviderExtension {
|
||||
const folderId = 'user-blocks';
|
||||
const toBlock = (block: UserBlockLike): LibBlock => {
|
||||
const elements = extractElements(block);
|
||||
return {
|
||||
id: `user:${block.id}`,
|
||||
folderId,
|
||||
name: block.name,
|
||||
thumbnail: elements.length ? generateBlockSvg(elements) : undefined,
|
||||
payload: elements.length ? JSON.stringify(elements) : undefined,
|
||||
};
|
||||
};
|
||||
return {
|
||||
id: 'user-library',
|
||||
label: 'Benutzer-Bibliothek',
|
||||
async listFolders(): Promise<LibFolder[]> {
|
||||
return [{ id: folderId, label: 'Zeichnungs-Blöcke', parentId: null }];
|
||||
},
|
||||
async listBlocks(id: string): Promise<LibBlock[]> {
|
||||
return id === folderId
|
||||
? userBlocks.filter((b) => extractElements(b).length > 0).map(toBlock)
|
||||
: [];
|
||||
},
|
||||
async getBlock(id: string): Promise<LibBlock> {
|
||||
const found = userBlocks.find((b) => `user:${b.id}` === id && extractElements(b).length > 0);
|
||||
if (found) return toBlock(found);
|
||||
throw new Error(`Block not found: ${id}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Persistente globale Blockbibliothek über die global-blocks API anbinden. */
|
||||
export function makeGlobalLibraryProvider(token: string): LibraryProviderExtension {
|
||||
const toBlock = (block: GlobalBlock): LibBlock => ({
|
||||
id: `global:${block.id}`,
|
||||
folderId: block.folder_id ?? 'global-root',
|
||||
name: block.name,
|
||||
thumbnail: block.svg_data ?? undefined,
|
||||
payload: block.block_data,
|
||||
});
|
||||
const loadAll = async (): Promise<GlobalBlock[]> => {
|
||||
try {
|
||||
return await getGlobalBlocks(token);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
return {
|
||||
id: 'global-library',
|
||||
label: 'Globale Bibliothek',
|
||||
async listFolders(): Promise<LibFolder[]> {
|
||||
try {
|
||||
const folders = await getGlobalFolders(token);
|
||||
return folders.map((f) => ({ id: f.id, label: f.name, parentId: f.parent_id }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
async listBlocks(folderId: string): Promise<LibBlock[]> {
|
||||
const blocks = await loadAll();
|
||||
return blocks.filter((b) => (b.folder_id ?? 'global-root') === folderId).map(toBlock);
|
||||
},
|
||||
async getBlock(id: string): Promise<LibBlock> {
|
||||
const blocks = await loadAll();
|
||||
const found = blocks.find((b) => `global:${b.id}` === id);
|
||||
if (found) return toBlock(found);
|
||||
throw new Error(`Block not found: ${id}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,8 @@ export { coreModifyPlugin } from './builtin/core-modify';
|
||||
export { coreAnnotatePlugin } from './builtin/core-annotate';
|
||||
export { 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';
|
||||
@@ -40,6 +42,7 @@ 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() {
|
||||
@@ -50,4 +53,5 @@ export function registerBuiltinPlugins() {
|
||||
pluginRegistry.registerV2(coreAnnotatePlugin);
|
||||
pluginRegistry.registerV2(coreMeasurePlugin);
|
||||
pluginRegistry.registerV2(eventToolsV2Plugin);
|
||||
pluginRegistry.registerV2(builtinLibraryPlugin);
|
||||
}
|
||||
|
||||
@@ -157,10 +157,12 @@ export interface ToolContext {
|
||||
export interface ToolSelectionBridge {
|
||||
/** Aktuell selektierte Element-IDs. */
|
||||
getIds(): string[];
|
||||
/** Setzt die Selektion vollständig. */
|
||||
setIds(ids: string[]): void;
|
||||
/** Setzt die Selektion vollständig (additive = zur bestehenden hinzufügen). */
|
||||
setIds(ids: string[], additive?: boolean): void;
|
||||
/** Element-Treffer an Weltposition (oder null). */
|
||||
hitTest(worldX: number, worldY: number, tolerancePx: number): CADElement | null;
|
||||
/** CAD-14: Wird bei jeder Selektionsänderung durch V2-Werkzeuge aufgerufen. */
|
||||
onChange?(ids: string[]): void;
|
||||
}
|
||||
|
||||
/** Erweiterter Kontext für Tools mit Dokument-/Selektionszugriff (A3). */
|
||||
@@ -208,7 +210,9 @@ export interface LibBlock {
|
||||
folderId: string;
|
||||
name: string;
|
||||
tags?: string[];
|
||||
thumbnail?: string; // dataURL
|
||||
thumbnail?: string; // svg/dataURL
|
||||
/** Payload für Canvas-Drop: i.d.R. JSON-String eines CADElement-Arrays (Task F4). */
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
/** Liefert Kataloge — Kern der Plugin-erweiterbaren Library (Phase F). */
|
||||
|
||||
@@ -156,8 +156,48 @@ export class PixiRenderer implements RenderAdapter {
|
||||
this.root.addChild(g);
|
||||
}
|
||||
|
||||
drawBackground(_img: HTMLImageElement | null, _cfg: BackgroundDrawConfig): void {
|
||||
// Sprite-/Texturintegration folgt in Task C3.
|
||||
private bgSprite: unknown | null = null;
|
||||
private bgSpriteSrc = '';
|
||||
|
||||
drawBackground(img: HTMLImageElement | null, cfg: BackgroundDrawConfig): void {
|
||||
// Task CAD-6: Hintergrund-Grundriss als Sprite unter den Elementen.
|
||||
if (!this.root || !img || !cfg.src) {
|
||||
// Ohne Bild: vorhandenes Sprite entfernen (Hintergrund aus)
|
||||
if (this.bgSprite) {
|
||||
(this.bgSprite as { destroy: () => void }).destroy();
|
||||
this.bgSprite = null;
|
||||
this.bgSpriteSrc = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Bild-Cache: Neuaufbau nur bei Quellwechsel
|
||||
if (this.bgSpriteSrc !== cfg.src) {
|
||||
if (this.bgSprite) (this.bgSprite as { destroy: () => void }).destroy();
|
||||
// Lazy-Pixi (Modul-Fehler-Isolation wie in init): Sprite dynamisch
|
||||
const PixiNS = (this as unknown as { pixiNS: unknown }).pixiNS as
|
||||
{ Sprite: new (src: string) => unknown } | undefined;
|
||||
if (!PixiNS) return;
|
||||
const sprite = new PixiNS.Sprite(cfg.src);
|
||||
(sprite as unknown as { alpha: number }).alpha = cfg.opacity ?? 0.5;
|
||||
this.bgSprite = sprite;
|
||||
this.bgSpriteSrc = cfg.src;
|
||||
this.root.addChildAt(sprite as never, 0); // ganz unten
|
||||
}
|
||||
// Transform aktualisieren (Legacy-Paritaet: Zentrum bei offset, scaled)
|
||||
const s = cfg.scale ?? 1;
|
||||
const sprite = this.bgSprite as unknown as {
|
||||
position: { set: (x: number, y: number) => void };
|
||||
width: number; height: number;
|
||||
anchor: { set: (x: number, y: number) => void };
|
||||
rotation: number; scale: { set: (x: number, y: number) => void };
|
||||
alpha: number;
|
||||
};
|
||||
sprite.anchor.set(0, 0);
|
||||
sprite.width = img.width * s;
|
||||
sprite.height = img.height * s;
|
||||
sprite.position.set(cfg.offsetX ?? 0, cfg.offsetY ?? 0);
|
||||
sprite.rotation = cfg.rotation ?? 0;
|
||||
sprite.alpha = cfg.opacity ?? 0.5;
|
||||
}
|
||||
|
||||
drawRuler(_cfg: RulerConfig): void {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
* - arc-Winkel in Grad, 0° = 3 Uhr, gegen den Uhrzeigersinn
|
||||
*/
|
||||
import type { CADElement } from '../../types/cad.types';
|
||||
import { getDimStyle } from '../../services/dimStyleService';
|
||||
import { generateHatchLines, computeBoundary, getHatchPattern } from '../../services/hatchPatternService';
|
||||
import type { Pt } from '../../tools/modification/geometry';
|
||||
|
||||
/** Zeichenprimitiv — kleinste darstellbare Einheit. */
|
||||
@@ -42,7 +44,86 @@ function fillOrNull(raw: unknown): string | null {
|
||||
* Gibt ein leeres Array zurück für unbekannte Typen bzw. Elemente,
|
||||
* die nichts Sichtbares beitragen (z.B. leerer Text).
|
||||
*/
|
||||
export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
||||
/**
|
||||
* Pfeilspitze als geschlossenes Polygon (Task CAD-1).
|
||||
* Spitze bei (x,y), Richtung ang (rad), Laenge size.
|
||||
*/
|
||||
function arrowPrimitives(x: number, y: number, ang: number, size: number, color: string): ShapePrimitive[] {
|
||||
const halfW = size * 0.35;
|
||||
const bx = x - size * Math.cos(ang);
|
||||
const by = y - size * Math.sin(ang);
|
||||
const px = -Math.sin(ang) * halfW;
|
||||
const py = Math.cos(ang) * halfW;
|
||||
return [{
|
||||
kind: 'polyline',
|
||||
points: [
|
||||
{ x, y },
|
||||
{ x: bx + px, y: by + py },
|
||||
{ x: bx - px, y: by - py },
|
||||
],
|
||||
close: true,
|
||||
stroke: color,
|
||||
strokeWidth: 1,
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Task CAD-2: Hatch-Linien fuer ein Element mit hatch-Properties.
|
||||
* Nutzt hatchPatternService.generateHatchLines mit Boundary aus der
|
||||
* Element-Kontur. Ohne Muster: leeres Array.
|
||||
*/
|
||||
function hatchPrimitives(el: CADElement, stroke: string): ShapePrimitive[] {
|
||||
const props = el.properties as Record<string, unknown>;
|
||||
if (props.hatch !== true) return [];
|
||||
const patternId = (props.hatchPattern as string | undefined) ?? 'ANSI31';
|
||||
if (!getHatchPattern(patternId)) return [];
|
||||
const spacing = (props.hatchSpacing as number | undefined) ?? 8;
|
||||
|
||||
// Boundary aus Kontur je Elementtyp
|
||||
let bbox: { minX: number; minY: number; maxX: number; maxY: number } | null = null;
|
||||
if (Array.isArray(props.points)) {
|
||||
bbox = computeBoundary(props.points as Array<{ x: number; y: number }>);
|
||||
} else if (el.type === 'circle' || el.type === 'arc') {
|
||||
const r = (props.radius as number | undefined) ?? el.width / 2;
|
||||
bbox = { minX: el.x - r, minY: el.y - r, maxX: el.x + r, maxY: el.y + r };
|
||||
} else {
|
||||
bbox = {
|
||||
minX: el.x - el.width / 2, minY: el.y - el.height / 2,
|
||||
maxX: el.x + el.width / 2, maxY: el.y + el.height / 2,
|
||||
};
|
||||
}
|
||||
if (!bbox) return [];
|
||||
|
||||
const segs = generateHatchLines(patternId, bbox, spacing);
|
||||
const out: ShapePrimitive[] = [];
|
||||
for (const s of segs) {
|
||||
if (s.fill) {
|
||||
const rectPrim: ShapePrimitive = {
|
||||
kind: 'rect',
|
||||
x: (bbox.minX + bbox.maxX) / 2,
|
||||
y: (bbox.minY + bbox.maxY) / 2,
|
||||
w: bbox.maxX - bbox.minX,
|
||||
h: bbox.maxY - bbox.minY,
|
||||
fill: s.fill,
|
||||
stroke,
|
||||
strokeWidth: 1,
|
||||
};
|
||||
out.push(rectPrim);
|
||||
} else {
|
||||
const linePrim: ShapePrimitive = {
|
||||
kind: 'line',
|
||||
from: s.from,
|
||||
to: s.to,
|
||||
stroke,
|
||||
strokeWidth: 0.5,
|
||||
};
|
||||
out.push(linePrim);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -66,7 +147,7 @@ export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
||||
|
||||
case 'rect': {
|
||||
// Legacy-BBox-Konvention: x/y ist das Zentrum des Rechtecks
|
||||
return [{
|
||||
const base: ShapePrimitive[] = [{
|
||||
kind: 'rect',
|
||||
x: el.x - el.width / 2,
|
||||
y: el.y - el.height / 2,
|
||||
@@ -76,11 +157,12 @@ export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
||||
stroke,
|
||||
strokeWidth: sw,
|
||||
}];
|
||||
return [...base, ...hatchPrimitives(el, stroke)];
|
||||
}
|
||||
|
||||
case 'circle': {
|
||||
const radius = (props.radius as number | undefined) ?? el.width / 2;
|
||||
return [{
|
||||
const circBase: ShapePrimitive[] = [{
|
||||
kind: 'circle',
|
||||
center: { x: el.x, y: el.y },
|
||||
radius,
|
||||
@@ -88,6 +170,7 @@ export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
||||
stroke,
|
||||
strokeWidth: sw,
|
||||
}];
|
||||
return [...circBase, ...hatchPrimitives(el, stroke)];
|
||||
}
|
||||
|
||||
case 'arc': {
|
||||
@@ -109,13 +192,14 @@ export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
||||
? (props.points as unknown[]).map(asPoint)
|
||||
: [];
|
||||
if (pts.length < 2) return [];
|
||||
return [{
|
||||
const polyBase: ShapePrimitive[] = [{
|
||||
kind: 'polyline',
|
||||
points: pts,
|
||||
close: el.type === 'polygon',
|
||||
stroke,
|
||||
strokeWidth: sw,
|
||||
}];
|
||||
return [...polyBase, ...hatchPrimitives(el, stroke)];
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
@@ -133,25 +217,75 @@ export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
||||
// ── 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+)
|
||||
// Task CAD-1: komplette Bemassung mit Extension lines, Masslinie,
|
||||
// Pfeilspitzen (geschlossene Polygone) und Wert-Text nach DimStyle.
|
||||
const dimType = (props.dimType as string | undefined) ?? 'linear';
|
||||
const style = getDimStyle(props.dimStyle as string | undefined);
|
||||
const dimColor = (props.dimColor as string | undefined) ?? (props.color as string | undefined) ?? '#aaaaaa';
|
||||
const label = String(props.value ?? props.text ?? '');
|
||||
|
||||
if (dimType === 'radius' || dimType === 'diameter') {
|
||||
const cx = (props.centerX as number | undefined) ?? el.x;
|
||||
const cy = (props.centerY as number | undefined) ?? el.y;
|
||||
const ax = (props.arrowX as number | undefined) ?? cx + el.width / 2;
|
||||
const ay = (props.arrowY as number | undefined) ?? cy;
|
||||
const out: ShapePrimitive[] = [
|
||||
{ kind: 'line', from: { x: cx, y: cy }, to: { x: ax, y: ay }, stroke: dimColor, strokeWidth: 1 },
|
||||
];
|
||||
out.push(...arrowPrimitives(ax, ay, Math.atan2(ay - cy, ax - cx), style.arrowSize, dimColor));
|
||||
if (label) {
|
||||
out.push({ kind: 'text', at: { x: (cx + ax) / 2, y: (cy + ay) / 2 - style.textHeight * 0.6 }, text: label, fontSize: style.textHeight * 4, color: dimColor });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (dimType === 'angular') {
|
||||
const vx = (props.vertexX as number | undefined) ?? el.x;
|
||||
const vy = (props.vertexY as number | undefined) ?? el.y;
|
||||
const l1x = (props.leg1X as number | undefined) ?? vx + 100;
|
||||
const l1y = (props.leg1Y as number | undefined) ?? vy;
|
||||
const l2x = (props.leg2X as number | undefined) ?? vx;
|
||||
const l2y = (props.leg2Y as number | undefined) ?? vy + 100;
|
||||
const out: ShapePrimitive[] = [
|
||||
{ kind: 'line', from: { x: vx, y: vy }, to: { x: l1x, y: l1y }, stroke: dimColor, strokeWidth: 1 },
|
||||
{ kind: 'line', from: { x: vx, y: vy }, to: { x: l2x, y: l2y }, stroke: dimColor, strokeWidth: 1 },
|
||||
];
|
||||
if (label) {
|
||||
out.push({ kind: 'text', at: { x: vx + 30, y: vy - 10 }, text: label, fontSize: style.textHeight * 4, color: dimColor });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// linear / aligned
|
||||
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 subtype = (props.dimSubtype as string | undefined) ?? 'horizontal';
|
||||
|
||||
// Masslinie: bei horizontal bei y = min(y1,y2) - offset; vertical bei x analog
|
||||
const off = 15;
|
||||
let mx1 = x1, my1 = y1, mx2 = x2, my2 = y2;
|
||||
if (subtype === 'horizontal') { my1 = Math.min(y1, y2) - off; my2 = my1; }
|
||||
else if (subtype === 'vertical') { mx1 = Math.min(x1, x2) - off; mx2 = mx1; }
|
||||
else { /* aligned: Masslinie = Punkte-Verbindung */ }
|
||||
|
||||
const out: ShapePrimitive[] = [
|
||||
{ kind: 'line', from: { x: x1, y: y1 }, to: { x: x2, y: y2 }, stroke: dimColor, strokeWidth: 1 },
|
||||
// Extension lines
|
||||
{ kind: 'line', from: { x: x1, y: y1 }, to: { x: x1, y: my1 }, stroke: '#888888', strokeWidth: 0.5 },
|
||||
{ kind: 'line', from: { x: x2, y: y2 }, to: { x: x2, y: my2 }, stroke: '#888888', strokeWidth: 0.5 },
|
||||
// Masslinie
|
||||
{ kind: 'line', from: { x: mx1, y: my1 }, to: { x: mx2, y: my2 }, stroke: dimColor, strokeWidth: 1 },
|
||||
];
|
||||
// Pfeilspitzen an beiden Enden der Masslinie
|
||||
const ang1 = Math.atan2(my2 - my1, mx2 - mx1);
|
||||
out.push(...arrowPrimitives(mx1, my1, ang1, style.arrowSize, dimColor));
|
||||
out.push(...arrowPrimitives(mx2, my2, ang1 + Math.PI, style.arrowSize, dimColor));
|
||||
if (label) {
|
||||
out.push({
|
||||
kind: 'text',
|
||||
at: { x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 12 },
|
||||
text: label,
|
||||
fontSize: 10,
|
||||
color: dimColor,
|
||||
at: { x: (mx1 + mx2) / 2, y: (my1 + my2) / 2 - style.textHeight * 0.6 },
|
||||
text: label, fontSize: style.textHeight * 4, color: dimColor,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -236,6 +370,207 @@ export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
||||
}];
|
||||
}
|
||||
|
||||
case 'curtain': {
|
||||
// Vorhang (Task E7): wellige Polyline entlang der Breite.
|
||||
const pts = Array.isArray(props.points)
|
||||
? (props.points as unknown[]).map(asPoint)
|
||||
: [];
|
||||
if (pts.length >= 2) {
|
||||
return [{ kind: 'polyline', points: pts, close: false, stroke, strokeWidth: sw }];
|
||||
}
|
||||
return [{
|
||||
kind: 'line',
|
||||
from: { x: el.x - el.width / 2, y: el.y },
|
||||
to: { x: el.x + el.width / 2, y: el.y },
|
||||
stroke,
|
||||
strokeWidth: sw,
|
||||
}];
|
||||
}
|
||||
|
||||
case 'spotlight': {
|
||||
// Scheinwerfer (Task E7): Kegel-Polygon in properties.color.
|
||||
const pts = Array.isArray(props.points)
|
||||
? (props.points as unknown[]).map(asPoint)
|
||||
: [];
|
||||
if (pts.length >= 3) {
|
||||
const col = (props.color as string | undefined) ?? '#ffcc00';
|
||||
return [{ kind: 'polyline', points: pts, close: true, stroke: col, strokeWidth: sw }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
case 'truss': {
|
||||
// Traverse (CAD-7/8/11): ROTIERTES Rechteck-Polygon + Gurtlinien
|
||||
// entlang der Richtung (Fachwerk-Profil), Breite = trussWidth,
|
||||
// chordCount = Anzahl Gurtrohre (CAD-11).
|
||||
const length = (props.length as number | undefined) ?? el.width;
|
||||
const tWidth = (props.trussWidth as number | undefined) ?? el.height ?? 29;
|
||||
const chordCount = Math.max(1, Math.min(4, (props.chordCount as number | undefined) ?? 2));
|
||||
const rotDeg = (props.rotation as number | undefined) ?? 0;
|
||||
const rad = (rotDeg * Math.PI) / 180;
|
||||
const ux = Math.cos(rad), uy = Math.sin(rad);
|
||||
const nx = -Math.sin(rad), ny = Math.cos(rad);
|
||||
const hl = length / 2, hw = tWidth / 2;
|
||||
const c = { x: el.x, y: el.y };
|
||||
const corner = (su: number, sv: number): { x: number; y: number } => ({
|
||||
x: c.x + ux * (hl * su) + nx * (hw * sv),
|
||||
y: c.y + uy * (hl * su) + ny * (hw * sv),
|
||||
});
|
||||
const pts = [corner(-1, -1), corner(1, -1), corner(1, 1), corner(-1, 1)];
|
||||
const strokeCol = (props.stroke as string | undefined) ?? '#9aa0a6';
|
||||
const out: ShapePrimitive[] = [
|
||||
{
|
||||
kind: 'polyline',
|
||||
points: pts,
|
||||
close: true,
|
||||
stroke: strokeCol,
|
||||
strokeWidth: 2,
|
||||
},
|
||||
];
|
||||
// Gurtrohre: chordCount Linien gleichmaessig ueber die Breite verteilt
|
||||
// chordCount=1: Mittellinie, chordCount=2: bei ±hw/2, chordCount=4: bei ±hw/2 und ±hw/6
|
||||
for (let ci = 0; ci < chordCount; ci++) {
|
||||
let sv: number;
|
||||
if (chordCount === 1) sv = 0;
|
||||
else if (chordCount === 2) sv = ci === 0 ? -0.5 : 0.5;
|
||||
else if (chordCount === 3) sv = ci === 0 ? -0.67 : ci === 1 ? 0 : 0.67;
|
||||
else sv = -0.75 + ci * 0.5; // chordCount=4: -0.75, -0.25, 0.25, 0.75
|
||||
const chordPt = (su: number): { x: number; y: number } => ({
|
||||
x: c.x + ux * (hl * su) + nx * (hw * sv),
|
||||
y: c.y + uy * (hl * su) + ny * (hw * sv),
|
||||
});
|
||||
const from = chordPt(-1);
|
||||
const to = chordPt(1);
|
||||
out.push({ kind: 'line', from, to, stroke: '#4b5563', strokeWidth: 1 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'boxcorner': {
|
||||
// Eck-Traverse (CAD-8/11): L-foermiges Polygon (6 Punkte) aus zwei
|
||||
// Armen (rot und rot+angle) um den Eckpunkt, Breite = trussWidth,
|
||||
// Winkel = angle (variabel, CAD-11, Default 90).
|
||||
const armLength = (props.armLength as number | undefined) ?? 50;
|
||||
const bWidth = (props.trussWidth as number | undefined) ?? 29;
|
||||
const rotDeg = (props.rotation as number | undefined) ?? 0;
|
||||
const angleDeg = (props.angle as number | undefined) ?? 90;
|
||||
const rad = (rotDeg * Math.PI) / 180;
|
||||
const rad2 = ((rotDeg + angleDeg) * Math.PI) / 180;
|
||||
const ux = Math.cos(rad), uy = Math.sin(rad);
|
||||
const nx = -Math.sin(rad), ny = Math.cos(rad);
|
||||
const vx = { x: el.x, y: el.y };
|
||||
const px = (a: number, b: number): { x: number; y: number } => ({
|
||||
x: vx.x + ux * a + nx * b,
|
||||
y: vx.y + uy * a + ny * b,
|
||||
});
|
||||
const ax2 = Math.cos(rad2), ay2 = Math.sin(rad2);
|
||||
const pxa = (a: number, b: number): { x: number; y: number } => ({
|
||||
x: vx.x + ax2 * a - ay2 * b,
|
||||
y: vx.y + ay2 * a + ax2 * b,
|
||||
});
|
||||
// L-Geometrie: Arm1 entlang +u (rot), Arm2 entlang rot+angle
|
||||
const pts = [
|
||||
px(0, 0),
|
||||
px(armLength, 0),
|
||||
px(armLength, bWidth),
|
||||
px(bWidth, bWidth),
|
||||
pxa(bWidth, 0),
|
||||
pxa(armLength, 0),
|
||||
pxa(0, 0),
|
||||
px(0, bWidth),
|
||||
];
|
||||
const strokeCol = (props.stroke as string | undefined) ?? '#9aa0a6';
|
||||
return [{
|
||||
kind: 'polyline',
|
||||
points: pts,
|
||||
close: true,
|
||||
stroke: strokeCol,
|
||||
strokeWidth: 2,
|
||||
}];
|
||||
}
|
||||
|
||||
case 'circletruss': {
|
||||
// Kreis-Traverse (CAD-11): Aussenkreis + Innenkreis +
|
||||
// chordCount Gurtrohr-Kreise gleichmaessig verteilt.
|
||||
const diameter = (props.diameter as number | undefined) ?? el.width;
|
||||
const tWidth = (props.trussWidth as number | undefined) ?? 29;
|
||||
const chordCount = Math.max(1, Math.min(4, (props.chordCount as number | undefined) ?? 2));
|
||||
const rOuter = diameter / 2;
|
||||
const rInner = rOuter - tWidth;
|
||||
const strokeCol = (props.stroke as string | undefined) ?? '#9aa0a6';
|
||||
const out: ShapePrimitive[] = [
|
||||
{ kind: 'circle', center: { x: el.x, y: el.y }, radius: rOuter, fill: null, stroke: strokeCol, strokeWidth: 2 },
|
||||
{ kind: 'circle', center: { x: el.x, y: el.y }, radius: rInner, fill: null, stroke: strokeCol, strokeWidth: 1 },
|
||||
];
|
||||
// Gurtrohr-Kreise: gleichmaessig zwischen Aussen- und Innenkreis
|
||||
const rMid = (rOuter + rInner) / 2;
|
||||
for (let ci = 0; ci < chordCount; ci++) {
|
||||
const ang = (2 * Math.PI * ci) / chordCount;
|
||||
out.push({
|
||||
kind: 'circle',
|
||||
center: { x: el.x + Math.cos(ang) * rMid, y: el.y + Math.sin(ang) * rMid },
|
||||
radius: tWidth * 0.15,
|
||||
fill: null,
|
||||
stroke: '#4b5563',
|
||||
strokeWidth: 1,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
@@ -230,7 +230,9 @@ export async function deleteElement(token: string, elementId: string): Promise<v
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete element');
|
||||
// CAD-14 Audit-Fix: 404 = bereits geloescht (idempotent, Race-sicher —
|
||||
// CRDT-Sync und REST-Kanal laufen parallel, zweiter Versuch ist kein Fehler).
|
||||
if (!res.ok && res.status !== 404) throw new Error('Failed to delete element');
|
||||
}
|
||||
|
||||
// ─── Layers ─────────────────────────────────────────────
|
||||
@@ -854,3 +856,28 @@ export async function importLibrary(token: string, pkg: { format: string; versio
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Task CAD-12a – Autosave-Service.
|
||||
*
|
||||
* Der Yjs-CRDT speichert bereits automatisch über WebSocket → LevelDB.
|
||||
* Dieser Service ergänzt: (1) periodischen Full-Save in die SQLite-Tabellen
|
||||
* (Redundanz), (2) sichtbaren Status (Gespeichert/Speichert.../Offline),
|
||||
* (3) Reconnect-Handling (CRDT synced nach).
|
||||
*/
|
||||
|
||||
export const AUTOSAVE_INTERVAL_MS = 30_000;
|
||||
|
||||
export interface AutosaveStatus {
|
||||
state: 'idle' | 'saved' | 'saving' | 'offline';
|
||||
lastSaved: Date | null;
|
||||
}
|
||||
|
||||
export interface AutosaveDeps {
|
||||
/** Ist der CRDT-WebSocket verbunden? */
|
||||
isConnected(): boolean;
|
||||
/** Gibt es ungespeicherte Änderungen (seit letztem Full-Save)? */
|
||||
hasUnsavedChanges(): boolean;
|
||||
/** Speichert den Zustand (Promise). */
|
||||
performFullSave(): Promise<void>;
|
||||
/** Status-Callback (UI-Update). */
|
||||
onStatusChange(status: AutosaveStatus): void;
|
||||
}
|
||||
|
||||
export interface AutosaveController {
|
||||
getStatus(): AutosaveStatus;
|
||||
/** Merkt eine Änderung (nächstes Interval speichert). */
|
||||
markChanges(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function createAutosaveController(deps: AutosaveDeps): AutosaveController {
|
||||
let state: AutosaveStatus['state'] = 'idle';
|
||||
let lastSaved: Date | null = null;
|
||||
let dirty = false;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
const setStatus = (s: AutosaveStatus['state']): void => {
|
||||
state = s;
|
||||
deps.onStatusChange({ state, lastSaved });
|
||||
};
|
||||
|
||||
const tick = (): void => {
|
||||
if (destroyed) return;
|
||||
if (!deps.isConnected()) { setStatus('offline'); return; }
|
||||
if (!dirty && !deps.hasUnsavedChanges()) { setStatus('saved'); return; }
|
||||
setStatus('saving');
|
||||
deps.performFullSave()
|
||||
.then(() => {
|
||||
lastSaved = new Date();
|
||||
dirty = false;
|
||||
setStatus('saved');
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus('offline');
|
||||
});
|
||||
};
|
||||
|
||||
// Init: Status sofort melden
|
||||
if (!deps.isConnected()) setStatus('offline');
|
||||
else setStatus('saved');
|
||||
|
||||
timer = setInterval(tick, AUTOSAVE_INTERVAL_MS);
|
||||
|
||||
return {
|
||||
getStatus: () => ({ state, lastSaved }),
|
||||
markChanges: () => { dirty = true; },
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Task CAD-5 – Block-Editor-Service.
|
||||
*
|
||||
* openBlockForEdit legt markierte KOPIEN der Block-Elemente ins
|
||||
* Dokument (Original unangetastet); saveBlockEdit schreibt die
|
||||
* bearbeiteten Kopien als neue Blockdefinition zurueck und
|
||||
* raeumt die Edit-Kopien auf; discardBlockEdit verwirft alles.
|
||||
* Marker: BLOCK_EDIT_MARKER (properties-Flag + __blockEdit-IDs).
|
||||
*/
|
||||
import type { CADElement, BlockDefinition } from '../types/cad.types';
|
||||
import type { CADDocument } from '../kernel/document/CADDocument';
|
||||
|
||||
/** Marker-Flag in properties der Edit-Kopien. */
|
||||
export const BLOCK_EDIT_MARKER = '__blockEdit';
|
||||
|
||||
/** Aktive Edit-Sitzung (Service-State). */
|
||||
let editSession: { blockId: string; copyIds: string[] } | null = null;
|
||||
|
||||
/**
|
||||
* Oeffnet einen Block zur Bearbeitung: Kopien aller Elemente mit
|
||||
* Edit-Marker werden ins Dokument gelegt (Original bleibt).
|
||||
* @returns true wenn geoeffnet, false bei unbekannter ID oder
|
||||
* bereits offener Sitzung.
|
||||
*/
|
||||
export function openBlockForEdit(doc: CADDocument, blockId: string): boolean {
|
||||
if (editSession) return false;
|
||||
const block = doc.getBlock(blockId);
|
||||
if (!block) return false;
|
||||
const copyIds: string[] = [];
|
||||
doc.transact(() => {
|
||||
for (const el of block.elements) {
|
||||
const copy: CADElement = {
|
||||
...el,
|
||||
id: `__blockEdit_${blockId}_${copyIds.length}`,
|
||||
properties: {
|
||||
...(el.properties as Record<string, unknown>),
|
||||
[BLOCK_EDIT_MARKER]: true,
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(copy);
|
||||
copyIds.push(copy.id);
|
||||
}
|
||||
});
|
||||
editSession = { blockId, copyIds };
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die Bearbeitung: die markierten Kopien werden als
|
||||
* neue Elementliste in die Blockdefinition geschrieben (Marker
|
||||
* entfernt); Edit-Kopien werden aus dem Dokument geloescht.
|
||||
* @returns blockId der gespeicherten Definition oder null.
|
||||
*/
|
||||
export function saveBlockEdit(doc: CADDocument): string | null {
|
||||
if (!editSession) return null;
|
||||
const { blockId, copyIds } = editSession;
|
||||
const elements: CADElement[] = [];
|
||||
for (const id of copyIds) {
|
||||
const el = doc.getElement(id);
|
||||
if (!el) continue;
|
||||
const { [BLOCK_EDIT_MARKER]: _marker, ...cleanProps } =
|
||||
el.properties as Record<string, unknown>;
|
||||
elements.push({ ...el, id: uid('blk'), properties: cleanProps } as unknown as CADElement);
|
||||
}
|
||||
const block = doc.getBlock(blockId);
|
||||
if (block) {
|
||||
doc.addBlock({ ...block, elements });
|
||||
}
|
||||
doc.deleteElements(copyIds);
|
||||
editSession = null;
|
||||
return blockId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwirft die Bearbeitung: Edit-Kopien werden geloescht, die
|
||||
* Blockdefinition bleibt unveraendert.
|
||||
* @returns true wenn verworfen, false ohne offene Sitzung.
|
||||
*/
|
||||
export function discardBlockEdit(doc: CADDocument): boolean {
|
||||
if (!editSession) return false;
|
||||
doc.deleteElements(editSession.copyIds);
|
||||
editSession = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Ist gerade eine Block-Bearbeitung offen? */
|
||||
export function isBlockEditOpen(): boolean {
|
||||
return editSession !== null;
|
||||
}
|
||||
|
||||
let uidCounter = 0;
|
||||
function uid(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}_${uidCounter++}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Task CAD-9 – Stueckliste (BOM) fuer Traversen-Systeme.
|
||||
*
|
||||
* buildBOM gruppiert Traversen + Eck-Traversen nach Typ + Mass
|
||||
* (Laenge x Breite), zaehlt Stueckzahlen und summiert die
|
||||
* Gesamt-Traversemeter (Boxcorner zaehlt BEIDE Arme).
|
||||
* exportBOMCsv: Semikolon-separierte CSV (Excel-DE-freundlich).
|
||||
*/
|
||||
import type { CADElement } from '../types/cad.types';
|
||||
|
||||
export interface BOMRow {
|
||||
pos: number;
|
||||
type: 'Traverse' | 'Eck-Traverse' | 'Kreis-Traverse';
|
||||
/** Laenge in cm (Traverse: length; Eck-Traverse: armLength; Kreis: Durchmesser). */
|
||||
length: number;
|
||||
/** Breite in cm (trussWidth). */
|
||||
width: number;
|
||||
count: number;
|
||||
/** Gesamtlaenge dieser Position in cm. */
|
||||
totalCm: number;
|
||||
/** Bezeichnung. */
|
||||
label: string;
|
||||
/** Gurtrohr-Anzahl (optional, CAD-11). */
|
||||
chordCount?: number;
|
||||
/** Eck-Winkel in Grad (optional, CAD-11). */
|
||||
angle?: number;
|
||||
}
|
||||
|
||||
export interface BOMResult {
|
||||
rows: BOMRow[];
|
||||
/** Gesamte Traversenlaenge in Metern (Ecken zaehlen beide Arme). */
|
||||
totalLengthM: number;
|
||||
}
|
||||
|
||||
interface GroupKey {
|
||||
type: 'Traverse' | 'Eck-Traverse' | 'Kreis-Traverse';
|
||||
length: number;
|
||||
width: number;
|
||||
chordCount?: number;
|
||||
angle?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Stueckliste aus allen Zeichnungselementen.
|
||||
* Nur truss- und boxcorner-Elemente fliessen ein; alles andere
|
||||
* wird still ignoriert.
|
||||
*/
|
||||
export function buildBOM(elements: CADElement[]): BOMResult {
|
||||
const groups = new Map<string, { key: GroupKey; count: number }>();
|
||||
|
||||
for (const el of elements) {
|
||||
if (String(el.type) === 'truss' && el.properties.truss === true) {
|
||||
const length = (el.properties.length as number | undefined) ?? el.width ?? 0;
|
||||
const width = (el.properties.trussWidth as number | undefined) ?? el.height ?? 29;
|
||||
const chordCount = (el.properties.chordCount as number | undefined) ?? 2;
|
||||
const key: GroupKey = { type: 'Traverse', length, width, chordCount };
|
||||
const id = `T|${length}|${width}|${chordCount}`;
|
||||
const g = groups.get(id) ?? { key, count: 0 };
|
||||
g.count++;
|
||||
groups.set(id, g);
|
||||
} else if (String(el.type) === 'boxcorner' && el.properties.boxcorner === true) {
|
||||
const length = (el.properties.armLength as number | undefined) ?? 50;
|
||||
const width = (el.properties.trussWidth as number | undefined) ?? 29;
|
||||
const angle = (el.properties.angle as number | undefined) ?? 90;
|
||||
const key: GroupKey = { type: 'Eck-Traverse', length, width, angle };
|
||||
const id = `C|${length}|${width}|${angle}`;
|
||||
const g = groups.get(id) ?? { key, count: 0 };
|
||||
g.count++;
|
||||
groups.set(id, g);
|
||||
} else if (String(el.type) === 'circletruss' && el.properties.circletruss === true) {
|
||||
const diameter = (el.properties.diameter as number | undefined) ?? el.width;
|
||||
const width = (el.properties.trussWidth as number | undefined) ?? 29;
|
||||
const chordCount = (el.properties.chordCount as number | undefined) ?? 2;
|
||||
const key: GroupKey = { type: 'Kreis-Traverse', length: diameter, width, chordCount };
|
||||
const id = `K|${diameter}|${width}|${chordCount}`;
|
||||
const g = groups.get(id) ?? { key, count: 0 };
|
||||
g.count++;
|
||||
groups.set(id, g);
|
||||
}
|
||||
}
|
||||
|
||||
// Sortierung: Traversen vor Ecken, innerhalb Typ nach Laenge absteigend
|
||||
const entries = Array.from(groups.values());
|
||||
entries.sort((a, b) => {
|
||||
if (a.key.type !== b.key.type) {
|
||||
return a.key.type === 'Traverse' ? -1 : 1;
|
||||
}
|
||||
return b.key.length - a.key.length;
|
||||
});
|
||||
|
||||
const rows: BOMRow[] = entries.map((g, i) => {
|
||||
// Ecken haben 2 Arme: Gesamtlaenge = count * length * 2
|
||||
// Kreis-Traversen: Umfang = Pi * Durchmesser
|
||||
const isCircle = g.key.type === 'Kreis-Traverse';
|
||||
const armFactor = g.key.type === 'Eck-Traverse' ? 2 : 1;
|
||||
const perPieceCm = isCircle ? Math.PI * g.key.length : g.key.length * armFactor;
|
||||
const totalCm = g.count * perPieceCm;
|
||||
const detailParts: string[] = [];
|
||||
if (g.key.chordCount !== undefined && g.key.chordCount > 0) detailParts.push(`${g.key.chordCount}-rohr`);
|
||||
if (g.key.angle !== undefined && g.key.angle !== 90) detailParts.push(`${g.key.angle}°`);
|
||||
const detailSuffix = detailParts.length > 0 ? ` (${detailParts.join(', ')})` : '';
|
||||
return {
|
||||
pos: i + 1,
|
||||
type: g.key.type,
|
||||
length: g.key.length,
|
||||
width: g.key.width,
|
||||
count: g.count,
|
||||
totalCm,
|
||||
label: `${g.key.type} ${isCircle ? '⌀' : ''}${g.key.length} × ${g.key.width}${detailSuffix}`,
|
||||
chordCount: g.key.chordCount,
|
||||
angle: g.key.angle,
|
||||
};
|
||||
});
|
||||
|
||||
const totalCmAll = rows.reduce((s, r) => s + r.totalCm, 0);
|
||||
return {
|
||||
rows,
|
||||
totalLengthM: totalCmAll / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportiert die Stueckliste als Semikolon-CSV (Excel-DE oeffnet
|
||||
* das direkt mehrspaltig). Spalten: Pos;Typ;Laenge_cm;Breite_cm;
|
||||
* Anzahl;Gesamt_cm;Bezeichnung + Summenzeile am Ende.
|
||||
*/
|
||||
export function exportBOMCsv(bom: BOMResult): string {
|
||||
const lines: string[] = ['Pos;Typ;Laenge_cm;Breite_cm;Anzahl;Gesamt_cm;Bezeichnung'];
|
||||
for (const r of bom.rows) {
|
||||
lines.push(
|
||||
`${r.pos};${r.type};${r.length};${r.width};${r.count};${r.totalCm};${r.label}`,
|
||||
);
|
||||
}
|
||||
if (bom.rows.length > 0) {
|
||||
lines.push(`;;;SUMME;;;${bom.totalLengthM.toFixed(2)} m`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Task CAD-1 – Bemassungsstile (DimStyles) + assoziative Wert-Berechnung.
|
||||
*
|
||||
* DimStyle: ISO-artige Stile (Text-/Pfeilhoehe, Einheit, Dezimalstellen,
|
||||
* Pfeilstil, Linien-Offsets). computeDimValue berechnet den Masswert
|
||||
* aus der Geometrie (assoziativ), formatDimValue erzeugt den Text.
|
||||
*/
|
||||
import type { CADElement } from '../types/cad.types';
|
||||
import type { Pt } from '../tools/modification/geometry';
|
||||
|
||||
export type DimTypeName = 'linear' | 'aligned' | 'angular' | 'radius' | 'diameter';
|
||||
|
||||
export interface DimStyle {
|
||||
name: string;
|
||||
/** Text-Hoehe in Welt-Einheiten. */
|
||||
textHeight: number;
|
||||
/** Pfeil-Laenge in Welt-Einheiten. */
|
||||
arrowSize: number;
|
||||
unit: 'mm' | 'cm' | 'm';
|
||||
decimals: number;
|
||||
arrowStyle: 'closed' | 'open' | 'dot' | 'tick';
|
||||
/** Offset Extension-Linie zu Messpunkt. */
|
||||
extOffset: number;
|
||||
/** Ueberstand der Masslinie ueber Pfeile. */
|
||||
overshoot: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_DIM_STYLE: DimStyle = {
|
||||
name: 'ISO-25',
|
||||
textHeight: 2.5,
|
||||
arrowSize: 2.5,
|
||||
unit: 'mm',
|
||||
decimals: 0,
|
||||
arrowStyle: 'closed',
|
||||
extOffset: 0.625,
|
||||
overshoot: 1.25,
|
||||
};
|
||||
|
||||
export const DIM_STYLES: DimStyle[] = [
|
||||
DEFAULT_DIM_STYLE,
|
||||
{ ...DEFAULT_DIM_STYLE, name: 'ISO-50', textHeight: 5, arrowSize: 5 },
|
||||
{ ...DEFAULT_DIM_STYLE, name: 'Architectural', textHeight: 3, arrowSize: 2, arrowStyle: 'tick' },
|
||||
{ ...DEFAULT_DIM_STYLE, name: 'Engineering', textHeight: 2.5, arrowSize: 2.5, decimals: 2 },
|
||||
];
|
||||
|
||||
/** Stil per Name (Fallback: Default). */
|
||||
export function getDimStyle(name: string | undefined): DimStyle {
|
||||
if (!name) return DEFAULT_DIM_STYLE;
|
||||
return DIM_STYLES.find((s) => s.name === name) ?? DEFAULT_DIM_STYLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assoziative Masswert-Berechnung aus der Geometrie.
|
||||
* linear: |dx| (horizontal) bzw. |dy| (vertikal) — der Aufrufer waehlt
|
||||
* die Achse; hier wird die Gesamt-Projektion geliefert und das
|
||||
* Tool waehlt horizontal/vertikal anhand dimSubtype.
|
||||
* aligned: Distanz zwischen p1 und p2.
|
||||
* angular: Winkel zwischen zwei Schenkeln am Scheitel (Grad).
|
||||
* radius/diameter: aus einem Kreis-Element.
|
||||
*/
|
||||
export function computeDimValue(
|
||||
type: DimTypeName,
|
||||
p1?: Pt,
|
||||
p2?: Pt,
|
||||
element?: CADElement,
|
||||
subtype?: string,
|
||||
): number {
|
||||
if (type === 'linear' && p1 && p2) {
|
||||
if (subtype === 'vertical') return Math.abs(p2.y - p1.y);
|
||||
if (subtype === 'horizontal') return Math.abs(p2.x - p1.x);
|
||||
// Ohne explizites Subtype: dominante Achse (CAD-Standardverhalten)
|
||||
return Math.abs(p2.x - p1.x) >= Math.abs(p2.y - p1.y)
|
||||
? Math.abs(p2.x - p1.x)
|
||||
: Math.abs(p2.y - p1.y);
|
||||
}
|
||||
if (type === 'aligned' && p1 && p2) {
|
||||
return Math.hypot(p2.x - p1.x, p2.y - p1.y);
|
||||
}
|
||||
if (type === 'radius' && element) {
|
||||
const r = (element.properties as Record<string, unknown>).radius as number | undefined;
|
||||
return r ?? element.width / 2;
|
||||
}
|
||||
if (type === 'diameter' && element) {
|
||||
const r = (element.properties as Record<string, unknown>).radius as number | undefined;
|
||||
return (r ?? element.width / 2) * 2;
|
||||
}
|
||||
if (type === 'angular' && p1 && p2) {
|
||||
const ang1 = Math.atan2(p1.y, p1.x);
|
||||
const ang2 = Math.atan2(p2.y, p2.x);
|
||||
let diff = Math.abs(ang2 - ang1) * 180 / Math.PI;
|
||||
if (diff > 180) diff = 360 - diff;
|
||||
return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Formatiert einen Masswert nach DimStyle (mit Typ-Praefix). */
|
||||
export function formatDimValue(
|
||||
value: number,
|
||||
style: DimStyle,
|
||||
type?: DimTypeName,
|
||||
): string {
|
||||
const num = value.toFixed(style.decimals);
|
||||
switch (type) {
|
||||
case 'radius': return `R${num}`;
|
||||
case 'diameter': return `\u2300 ${num}`;
|
||||
case 'angular': return `${num}\u00b0`;
|
||||
default: return num;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Task CAD-2 – Echte Hatch-Muster + Boundary-Erkennung.
|
||||
*
|
||||
* Standard-CAD-Schraffurmuster als Linien-Segment-Generatoren für
|
||||
* eine BBox. computeBoundary liefert die umschlossene Fläche aus
|
||||
* einer Kontur (Polygon-Punkte).
|
||||
*/
|
||||
|
||||
export interface HatchSeg {
|
||||
from: { x: number; y: number };
|
||||
to: { x: number; y: number };
|
||||
fill?: string;
|
||||
closed?: boolean;
|
||||
points?: Array<{ x: number; y: number }>;
|
||||
}
|
||||
|
||||
export interface HatchPattern {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
defaultSpacing: number;
|
||||
/** 45°-Ersatzwinkel für Linienfamilien (Grad). */
|
||||
angles: number[];
|
||||
/** Punkte-Muster (DOTS) statt Linien. */
|
||||
dots?: boolean;
|
||||
/** Vollflächige Füllung. */
|
||||
solid?: boolean;
|
||||
}
|
||||
|
||||
export type HatchPatternId = string;
|
||||
|
||||
export const HATCH_PATTERNS: HatchPattern[] = [
|
||||
{ id: 'lines', label: 'Linien (45°)', description: 'Einfache 45°-Schraffur', defaultSpacing: 8, angles: [45] },
|
||||
{ id: 'cross', label: 'Kreuz', description: 'Kreuzschraffur 45°/-45°', defaultSpacing: 8, angles: [45, -45] },
|
||||
{ id: 'ANSI31', label: 'ANSI31 (45°)', description: 'ANSI-Standard 45°', defaultSpacing: 6, angles: [45] },
|
||||
{ id: 'ANSI32', label: 'ANSI32 (Eisen)', description: 'ANSI Eisen 45°/-45°', defaultSpacing: 6, angles: [45, -45] },
|
||||
{ id: 'ANSI37', label: 'ANSI37 (Beton)', description: 'ANSI Beton 45°/-45° dicht', defaultSpacing: 3, angles: [45, -45] },
|
||||
{ id: 'NET', label: 'Netz', description: 'Horizontal + vertikal', defaultSpacing: 10, angles: [0, 90] },
|
||||
{ id: 'GRASS', label: 'Gras', description: 'Kurze Gras-Striche', defaultSpacing: 6, angles: [75] },
|
||||
{ id: 'DOTS', label: 'Punkte', description: 'Punktschraffur', defaultSpacing: 8, angles: [], dots: true },
|
||||
{ id: 'SOLID', label: 'Voll', description: 'Vollflächige Füllung', defaultSpacing: 0, angles: [], solid: true },
|
||||
];
|
||||
|
||||
/** Muster per ID (oder undefined). */
|
||||
export function getHatchPattern(id: string | undefined): HatchPattern | undefined {
|
||||
if (!id) return undefined;
|
||||
return HATCH_PATTERNS.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt Schraffur-Segmente für eine BBox nach Muster.
|
||||
* SOLID liefert EIN Segment mit fill-Flag; DOTS liefert Punkte-Muster.
|
||||
*/
|
||||
export function generateHatchLines(
|
||||
patternId: string,
|
||||
bbox: { minX: number; minY: number; maxX: number; maxY: number },
|
||||
spacing: number,
|
||||
): HatchSeg[] {
|
||||
const pattern = getHatchPattern(patternId);
|
||||
if (!pattern) return [];
|
||||
const { minX, minY, maxX, maxY } = bbox;
|
||||
const w = maxX - minX;
|
||||
const h = maxY - minY;
|
||||
|
||||
if (pattern.solid) {
|
||||
return [{ from: { x: minX, y: minY }, to: { x: maxX, y: maxY }, fill: '#888888' }];
|
||||
}
|
||||
|
||||
if (pattern.dots) {
|
||||
const out: HatchSeg[] = [];
|
||||
for (let y = minY + spacing / 2; y < maxY; y += spacing) {
|
||||
for (let x = minX + spacing / 2; x < maxX; x += spacing) {
|
||||
out.push({
|
||||
from: { x, y },
|
||||
to: { x: x + 1, y: y + 1 },
|
||||
points: [{ x, y }],
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Linienfamilien je Winkel
|
||||
const out: HatchSeg[] = [];
|
||||
for (const angleDeg of pattern.angles) {
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
const cosA = Math.cos(rad);
|
||||
const sinA = Math.sin(rad);
|
||||
const diag = Math.hypot(w, h);
|
||||
const step = spacing;
|
||||
const offsetCount = Math.ceil(diag / step) + 2;
|
||||
for (let i = -offsetCount; i <= offsetCount; i++) {
|
||||
const cx = minX + w / 2 + i * step;
|
||||
const cy = minY + h / 2;
|
||||
// Linie durch (cx,cy) in Richtung (cosA, sinA), begrenzt auf BBox
|
||||
const p1 = { x: cx - cosA * diag, y: cy - sinA * diag };
|
||||
const p2 = { x: cx + cosA * diag, y: cy + sinA * diag };
|
||||
// Clipping an BBox (einfaches Cohen-Sutherland)
|
||||
const cl = clipToBBox(p1, p2, bbox);
|
||||
if (cl) out.push({ from: cl[0], to: cl[1] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Cohen-Sutherland-artiges Clipping an BBox (vereinfacht). */
|
||||
function clipToBBox(
|
||||
p1: { x: number; y: number },
|
||||
p2: { x: number; y: number },
|
||||
b: { minX: number; minY: number; maxX: number; maxY: number },
|
||||
): Array<{ x: number; y: number }> | null {
|
||||
// Liang-Barsky
|
||||
let t0 = 0, t1 = 1;
|
||||
const dx = p2.x - p1.x;
|
||||
const dy = p2.y - p1.y;
|
||||
const clip = (p: number, q: number): boolean => {
|
||||
if (p === 0) return q >= 0;
|
||||
const r = q / p;
|
||||
if (p < 0) {
|
||||
if (r > t1) return false;
|
||||
if (r > t0) t0 = r;
|
||||
} else {
|
||||
if (r < t0) return false;
|
||||
if (r < t1) t1 = r;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
if (!clip(-dx, p1.x - b.minX)) return null;
|
||||
if (!clip(dx, b.maxX - p1.x)) return null;
|
||||
if (!clip(-dy, p1.y - b.minY)) return null;
|
||||
if (!clip(dy, b.maxY - p1.y)) return null;
|
||||
return [
|
||||
{ x: p1.x + t0 * dx, y: p1.y + t0 * dy },
|
||||
{ x: p1.x + t1 * dx, y: p1.y + t1 * dy },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary-Erkennung: BBox aus einer Kontur (Polygon-Punkte).
|
||||
* Weniger als 2 Punkte → null.
|
||||
*/
|
||||
export function computeBoundary(
|
||||
pts: Array<{ x: number; y: number }>,
|
||||
): { minX: number; minY: number; maxX: number; maxY: number } | null {
|
||||
if (!Array.isArray(pts) || pts.length < 2) return null;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const p of pts) {
|
||||
if (p.x < minX) minX = p.x;
|
||||
if (p.y < minY) minY = p.y;
|
||||
if (p.x > maxX) maxX = p.x;
|
||||
if (p.y > maxY) maxY = p.y;
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
@@ -59,8 +59,218 @@ export function importDXF(dxfString: string): ImportResult {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task F8 – SVG-Path-Parser: d-Attribut → Subpaths (Punkte + closed).
|
||||
*
|
||||
* Unterstützte Befehle: M/L/H/V/C/S/Q/T/A/Z — absolut und relativ (lowercase).
|
||||
* Kubische/quadratische Bézier werden mit je 16/12 Segmenten geflattet,
|
||||
* Arc (A) über Endpoint→Center-Parametrisierung (SVG Spec F.6.5) gesampelt.
|
||||
* Implizite Befehlswiederholung („L 10 0 20 0") und M→L-Fortsetzung
|
||||
* sind abgedeckt. viewBox-Offset (vbX/vbY) wird von allen Punkten abgezogen.
|
||||
*/
|
||||
interface PathPoint { x: number; y: number }
|
||||
interface PathSubpath { points: PathPoint[]; closed: boolean }
|
||||
|
||||
function parsePathData(d: string, vbX: number, vbY: number): PathSubpath[] {
|
||||
const tokens = d.match(/[MmLlHhVvCcSsQqTtAaZz]|-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) ?? [];
|
||||
const subpaths: PathSubpath[] = [];
|
||||
let i = 0;
|
||||
let cx = 0, cy = 0; // aktueller Punkt
|
||||
let sx = 0, sy = 0; // Subpath-Start (für Z)
|
||||
let pcx = 0, pcy = 0; // letzter kubischer Kontrollpunkt (für S)
|
||||
let pqx = 0, pqy = 0; // letzter quadratischer Kontrollpunkt (für T)
|
||||
let prevCubic = false, prevQuad = false;
|
||||
let current: PathPoint[] | null = null;
|
||||
let lastCmd = '';
|
||||
|
||||
const flush = (closed: boolean) => {
|
||||
if (current && current.length >= 2) subpaths.push({ points: current, closed });
|
||||
current = null;
|
||||
};
|
||||
const moveTo = (x: number, y: number) => {
|
||||
flush(false);
|
||||
current = [{ x, y }];
|
||||
cx = x; cy = y; sx = x; sy = y;
|
||||
};
|
||||
const lineTo = (x: number, y: number) => {
|
||||
if (!current) current = [{ x: cx, y: cy }];
|
||||
current.push({ x, y });
|
||||
cx = x; cy = y;
|
||||
};
|
||||
const cubicTo = (x1: number, y1: number, x2: number, y2: number, ex: number, ey: number) => {
|
||||
const SEG = 16;
|
||||
for (let s = 1; s <= SEG; s++) {
|
||||
const t = s / SEG;
|
||||
const mt = 1 - t;
|
||||
const px = mt * mt * mt * cx + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t * ex;
|
||||
const py = mt * mt * mt * cy + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t * ey;
|
||||
if (!current) current = [{ x: cx, y: cy }];
|
||||
current.push({ x: px, y: py });
|
||||
}
|
||||
pcx = x2; pcy = y2;
|
||||
prevCubic = true; prevQuad = false;
|
||||
cx = ex; cy = ey;
|
||||
};
|
||||
const quadTo = (x1: number, y1: number, ex: number, ey: number) => {
|
||||
const SEG = 12;
|
||||
for (let s = 1; s <= SEG; s++) {
|
||||
const t = s / SEG;
|
||||
const mt = 1 - t;
|
||||
const px = mt * mt * cx + 2 * mt * t * x1 + t * t * ex;
|
||||
const py = mt * mt * cy + 2 * mt * t * y1 + t * t * ey;
|
||||
if (!current) current = [{ x: cx, y: cy }];
|
||||
current.push({ x: px, y: py });
|
||||
}
|
||||
pqx = x1; pqy = y1;
|
||||
prevQuad = true; prevCubic = false;
|
||||
cx = ex; cy = ey;
|
||||
};
|
||||
const arcTo = (rx: number, ry: number, rotDeg: number, laf: number, sf: number, ex: number, ey: number) => {
|
||||
if (rx === 0 || ry === 0 || (ex === cx && ey === cy)) { lineTo(ex, ey); return; }
|
||||
// Endpoint → Center-Parametrisierung (SVG Spec F.6.5)
|
||||
const rot = (rotDeg * Math.PI) / 180;
|
||||
const cosP = Math.cos(rot), sinP = Math.sin(rot);
|
||||
const dx = (cx - ex) / 2, dy = (cy - ey) / 2;
|
||||
const x1p = cosP * dx + sinP * dy;
|
||||
const y1p = -sinP * dx + cosP * dy;
|
||||
let RX = Math.abs(rx), RY = Math.abs(ry);
|
||||
const lambda = (x1p * x1p) / (RX * RX) + (y1p * y1p) / (RY * RY);
|
||||
if (lambda > 1) {
|
||||
const scale = Math.sqrt(lambda);
|
||||
RX *= scale; RY *= scale;
|
||||
}
|
||||
const num = RX * RX * RY * RY - RX * RX * y1p * y1p - RY * RY * x1p * x1p;
|
||||
const den = RX * RX * y1p * y1p + RY * RY * x1p * x1p;
|
||||
let co = Math.sqrt(Math.max(0, den === 0 ? 0 : num / den));
|
||||
if (laf === sf) co = -co;
|
||||
const cxp = (co * RX * y1p) / RY;
|
||||
const cyp = (-co * RY * x1p) / RX;
|
||||
const ccx = cosP * cxp - sinP * cyp + (cx + ex) / 2;
|
||||
const ccy = sinP * cxp + cosP * cyp + (cy + ey) / 2;
|
||||
const ang = (ux: number, uy: number, vx: number, vy: number): number => {
|
||||
const dot = ux * vx + uy * vy;
|
||||
const len = Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy) || 1;
|
||||
let a = Math.acos(Math.max(-1, Math.min(1, dot / len)));
|
||||
if (ux * vy - uy * vx < 0) a = -a;
|
||||
return a;
|
||||
};
|
||||
const theta1 = ang(1, 0, (x1p - cxp) / RX, (y1p - cyp) / RY);
|
||||
let dTheta = ang((x1p - cxp) / RX, (y1p - cyp) / RY, (-x1p - cxp) / RX, (-y1p - cyp) / RY) % (2 * Math.PI);
|
||||
if (!sf && dTheta > 0) dTheta -= 2 * Math.PI;
|
||||
if (sf && dTheta < 0) dTheta += 2 * Math.PI;
|
||||
const N = 32;
|
||||
for (let s = 1; s <= N; s++) {
|
||||
if (!current) current = [{ x: cx, y: cy }];
|
||||
if (s === N) {
|
||||
// Endpunkt exakt (Float-Rundung der Trigonometrie vermeiden)
|
||||
current.push({ x: ex, y: ey });
|
||||
break;
|
||||
}
|
||||
const th = theta1 + dTheta * (s / N);
|
||||
const px = ccx + cosP * RX * Math.cos(th) - sinP * RY * Math.sin(th);
|
||||
const py = ccy + sinP * RX * Math.cos(th) + cosP * RY * Math.sin(th);
|
||||
current.push({ x: px, y: py });
|
||||
}
|
||||
cx = ex; cy = ey;
|
||||
prevCubic = prevQuad = false;
|
||||
};
|
||||
|
||||
const readNums = (n: number): number[] => Array.from({ length: n }, () => parseFloat(tokens[i++] ?? '0'));
|
||||
const isCmd = (t: string) => /^[MmLlHhVvCcSsQqTtAaZz]$/.test(t);
|
||||
|
||||
while (i < tokens.length) {
|
||||
let cmd = tokens[i++];
|
||||
if (!isCmd(cmd)) {
|
||||
// implizite Wiederholung des letzten Befehls; nach M gilt L
|
||||
i--;
|
||||
if (!lastCmd) break;
|
||||
cmd = lastCmd === 'M' ? 'L' : lastCmd === 'm' ? 'l' : lastCmd;
|
||||
} else {
|
||||
lastCmd = cmd;
|
||||
}
|
||||
const up = cmd.toUpperCase();
|
||||
const abs = cmd === up;
|
||||
switch (up) {
|
||||
case 'M': {
|
||||
const [x, y] = readNums(2);
|
||||
moveTo(abs ? x - vbX : cx + x, abs ? y - vbY : cy + y);
|
||||
break;
|
||||
}
|
||||
case 'L': {
|
||||
const [x, y] = readNums(2);
|
||||
lineTo(abs ? x - vbX : cx + x, abs ? y - vbY : cy + y);
|
||||
break;
|
||||
}
|
||||
case 'H': {
|
||||
const [x] = readNums(1);
|
||||
lineTo(abs ? x - vbX : cx + x, cy);
|
||||
break;
|
||||
}
|
||||
case 'V': {
|
||||
const [y] = readNums(1);
|
||||
lineTo(cx, abs ? y - vbY : cy + y);
|
||||
break;
|
||||
}
|
||||
case 'C': {
|
||||
const n = readNums(6);
|
||||
cubicTo(
|
||||
abs ? n[0] - vbX : cx + n[0], abs ? n[1] - vbY : cy + n[1],
|
||||
abs ? n[2] - vbX : cx + n[2], abs ? n[3] - vbY : cy + n[3],
|
||||
abs ? n[4] - vbX : cx + n[4], abs ? n[5] - vbY : cy + n[5],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'S': {
|
||||
const n = readNums(4);
|
||||
const x1 = prevCubic ? 2 * cx - pcx : cx;
|
||||
const y1 = prevCubic ? 2 * cy - pcy : cy;
|
||||
cubicTo(
|
||||
x1, y1,
|
||||
abs ? n[0] - vbX : cx + n[0], abs ? n[1] - vbY : cy + n[1],
|
||||
abs ? n[2] - vbX : cx + n[2], abs ? n[3] - vbY : cy + n[3],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'Q': {
|
||||
const n = readNums(4);
|
||||
quadTo(
|
||||
abs ? n[0] - vbX : cx + n[0], abs ? n[1] - vbY : cy + n[1],
|
||||
abs ? n[2] - vbX : cx + n[2], abs ? n[3] - vbY : cy + n[3],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'T': {
|
||||
const n = readNums(2);
|
||||
const x1 = prevQuad ? 2 * cx - pqx : cx;
|
||||
const y1 = prevQuad ? 2 * cy - pqy : cy;
|
||||
quadTo(x1, y1, abs ? n[0] - vbX : cx + n[0], abs ? n[1] - vbY : cy + n[1]);
|
||||
break;
|
||||
}
|
||||
case 'A': {
|
||||
const n = readNums(7);
|
||||
arcTo(
|
||||
n[0], n[1], n[2], n[3], n[4],
|
||||
abs ? n[5] - vbX : cx + n[5], abs ? n[6] - vbY : cy + n[6],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'Z': {
|
||||
// Polygon schließt automatisch — keinen duplizierten Endpunkt anhängen
|
||||
flush(true);
|
||||
cx = sx; cy = sy;
|
||||
prevCubic = prevQuad = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(false);
|
||||
return subpaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import SVG string — converts SVG elements to CAD elements.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Task CAD-12b – Globale Keyboard-Shortcuts fuer den Editor.
|
||||
*
|
||||
* ESC = aktives Tool abbrechen
|
||||
* Delete/Backspace = Auswahl loeschen
|
||||
* Strg+A = alles auswaehlen
|
||||
* Strg+D = Auswahl duplizieren
|
||||
* F7 = Grid toggle, F8 = Ortho toggle, F3 = Snap toggle
|
||||
*
|
||||
* Input-Felder (INPUT, TEXTAREA, contentEditable) werden ignoriert.
|
||||
*/
|
||||
|
||||
export interface ShortcutAction {
|
||||
escape: () => void;
|
||||
delete: () => void;
|
||||
selectAll: () => void;
|
||||
duplicate: () => void;
|
||||
toggleGrid: () => void;
|
||||
toggleOrtho: () => void;
|
||||
toggleSnap: () => void;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcutsController {
|
||||
handler: (e: KeyboardEvent) => void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function createKeyboardShortcuts(actions: ShortcutAction): KeyboardShortcutsController {
|
||||
const isInputElement = (target: EventTarget | null): boolean => {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
const tag = el.tagName?.toUpperCase() ?? '';
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable === true;
|
||||
};
|
||||
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
if (isInputElement(e.target)) return;
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
actions.escape();
|
||||
break;
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
e.preventDefault();
|
||||
actions.delete();
|
||||
break;
|
||||
case 'a':
|
||||
case 'A':
|
||||
if (ctrl) { e.preventDefault(); actions.selectAll(); }
|
||||
break;
|
||||
case 'd':
|
||||
case 'D':
|
||||
if (ctrl) { e.preventDefault(); actions.duplicate(); }
|
||||
break;
|
||||
case 'F7':
|
||||
e.preventDefault();
|
||||
actions.toggleGrid();
|
||||
break;
|
||||
case 'F8':
|
||||
e.preventDefault();
|
||||
actions.toggleOrtho();
|
||||
break;
|
||||
case 'F3':
|
||||
e.preventDefault();
|
||||
actions.toggleSnap();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handler,
|
||||
destroy: () => {
|
||||
// cleanup: caller removes listener
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Task G5 – Browser-Print-Service.
|
||||
*
|
||||
* Bereitet die App für window.print() vor: Der Body erhält die Klasse
|
||||
* `print-layout`, das @media print Stylesheet blendet das UI-Chrome aus
|
||||
* und zeigt nur den Druckbereich (Canvas/Layout). Nach dem Druck wird
|
||||
* die Klasse wieder entfernt (kein DOM-Mutation-Restzustand).
|
||||
*/
|
||||
|
||||
const PRINT_CLASS = 'print-layout';
|
||||
|
||||
/** Setzt den Print-Modus (Body-Klasse; @media print regelt das Rendering). */
|
||||
export function preparePrintLayout(): void {
|
||||
document.body.classList.add(PRINT_CLASS);
|
||||
}
|
||||
|
||||
/** Entfernt den Print-Modus (nach Druck oder bei Abbruch). */
|
||||
export function cleanupPrintLayout(): void {
|
||||
document.body.classList.remove(PRINT_CLASS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Druck-Flow: Vorbereitung → Rendern der nächsten Frames (Styles müssen
|
||||
* greifen, bevor der Browser den Print-Dialog aufbaut) → window.print
|
||||
* → Aufräumen. await-fähig für Tests (window.print wird gemockt).
|
||||
*/
|
||||
export async function printLayout(): Promise<void> {
|
||||
preparePrintLayout();
|
||||
// Zwei Frames warten: Style-Anwendung + Layout der Print-Ansicht
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
try {
|
||||
window.print();
|
||||
} finally {
|
||||
cleanupPrintLayout();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Task H2 – Projekt-Templates: Branchen-Presets als Startzustand.
|
||||
*
|
||||
* Jedes Preset definiert: gridSize + unit (Raster), Layer-Set,
|
||||
* enabledPlugins (welche V2-Plugins aktiviert werden) und einen
|
||||
* libraryFolderSeed (Startordner der Block-Bibliothek).
|
||||
*/
|
||||
import type { CADLayer } from '../types/cad.types';
|
||||
import type { CADDocument } from '../kernel/document/CADDocument';
|
||||
|
||||
export type ProjectTemplateId = 'halle' | 'openair' | 'saal' | 'messe' | 'buero' | 'garten';
|
||||
|
||||
export interface ProjectTemplate {
|
||||
id: ProjectTemplateId;
|
||||
name: string;
|
||||
description: string;
|
||||
gridSize: number;
|
||||
unit: 'mm' | 'cm' | 'm';
|
||||
layers: CADLayer[];
|
||||
enabledPlugins: string[];
|
||||
libraryFolderSeed: string;
|
||||
}
|
||||
|
||||
function layer(id: string, name: string, color: string, sortOrder: number): CADLayer {
|
||||
return {
|
||||
id, name, visible: true, locked: false,
|
||||
color, lineType: 'solid', transparency: 0, sortOrder, parentId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const PROJECT_TEMPLATES: ProjectTemplate[] = [
|
||||
{
|
||||
id: 'halle',
|
||||
name: 'Halle',
|
||||
description: 'Veranstaltungshalle: Bestuhlung, Bühne, Technik, Wege',
|
||||
gridSize: 100,
|
||||
unit: 'cm',
|
||||
layers: [
|
||||
layer('tpl-h-wand', 'Wände', '#8a8f98', 0),
|
||||
layer('tpl-h-bestuhlung', 'Bestuhlung', '#4a90d9', 1),
|
||||
layer('tpl-h-buehne', 'Bühne', '#c0392b', 2),
|
||||
layer('tpl-h-technik', 'Technik', '#f39c12', 3),
|
||||
],
|
||||
enabledPlugins: ['event-tools', 'core-drawing', 'core-modify', 'core-annotate', 'builtin-library'],
|
||||
libraryFolderSeed: 'Veranstaltungstechnik',
|
||||
},
|
||||
{
|
||||
id: 'openair',
|
||||
name: 'OpenAir',
|
||||
description: 'Open-Air-Gelände: Zonen, Bestuhlung, Absperrungen, Wege',
|
||||
gridSize: 100,
|
||||
unit: 'cm',
|
||||
layers: [
|
||||
layer('tpl-o-zonen', 'Zonen', '#27ae60', 0),
|
||||
layer('tpl-o-bestuhlung', 'Bestuhlung', '#4a90d9', 1),
|
||||
layer('tpl-o-absperrung', 'Absperrung', '#c0392b', 2),
|
||||
layer('tpl-o-wege', 'Wege', '#b8a888', 3),
|
||||
],
|
||||
enabledPlugins: ['event-tools', 'core-drawing', 'core-modify', 'core-annotate', 'builtin-library'],
|
||||
libraryFolderSeed: 'OpenAir',
|
||||
},
|
||||
{
|
||||
id: 'saal',
|
||||
name: 'Saal',
|
||||
description: 'Festsaal: Bestuhlung, Bühne, Catering, Deko',
|
||||
gridSize: 50,
|
||||
unit: 'cm',
|
||||
layers: [
|
||||
layer('tpl-s-bestuhlung', 'Bestuhlung', '#4a90d9', 0),
|
||||
layer('tpl-s-buehne', 'Bühne', '#c0392b', 1),
|
||||
layer('tpl-s-catering', 'Catering', '#f39c12', 2),
|
||||
layer('tpl-s-deko', 'Deko', '#9b59b6', 3),
|
||||
],
|
||||
enabledPlugins: ['event-tools', 'core-drawing', 'core-modify', 'core-annotate', 'builtin-library'],
|
||||
libraryFolderSeed: 'Eventservice',
|
||||
},
|
||||
{
|
||||
id: 'messe',
|
||||
name: 'Messe',
|
||||
description: 'Messestand: Standlayout, Theken, Medien, Strom',
|
||||
gridSize: 10,
|
||||
unit: 'mm',
|
||||
layers: [
|
||||
layer('tpl-m-stand', 'Standlayout', '#e74c3c', 0),
|
||||
layer('tpl-m-theken', 'Theken', '#f39c12', 1),
|
||||
layer('tpl-m-medien', 'Medien', '#3498db', 2),
|
||||
layer('tpl-m-strom', 'Strom', '#f1c40f', 3),
|
||||
],
|
||||
enabledPlugins: ['core-drawing', 'core-modify', 'core-annotate', 'builtin-library'],
|
||||
libraryFolderSeed: 'Messebau',
|
||||
},
|
||||
{
|
||||
id: 'buero',
|
||||
name: 'Büro',
|
||||
description: 'Büroplanung: Möbel, Wände, Netzwerk, Beschriftung',
|
||||
gridSize: 10,
|
||||
unit: 'mm',
|
||||
layers: [
|
||||
layer('tpl-b-waende', 'Wände', '#8a8f98', 0),
|
||||
layer('tpl-b-moebel', 'Möbel', '#4a90d9', 1),
|
||||
layer('tpl-b-netzwerk', 'Netzwerk', '#27ae60', 2),
|
||||
layer('tpl-b-text', 'Beschriftung', '#9aa0a6', 3),
|
||||
],
|
||||
enabledPlugins: ['core-drawing', 'core-modify', 'core-annotate', 'builtin-library'],
|
||||
libraryFolderSeed: 'Büroeinrichtung',
|
||||
},
|
||||
{
|
||||
id: 'garten',
|
||||
name: 'Garten',
|
||||
description: 'Gartenplanung: Bepflanzung, Wege, Wasser, Beleuchtung',
|
||||
gridSize: 100,
|
||||
unit: 'cm',
|
||||
layers: [
|
||||
layer('tpl-g-bepflanzung', 'Bepflanzung', '#27ae60', 0),
|
||||
layer('tpl-g-wege', 'Wege', '#b8a888', 1),
|
||||
layer('tpl-g-wasser', 'Wasser', '#3498db', 2),
|
||||
layer('tpl-g-licht', 'Beleuchtung', '#f1c40f', 3),
|
||||
],
|
||||
enabledPlugins: ['core-drawing', 'core-modify', 'core-annotate', 'builtin-library'],
|
||||
libraryFolderSeed: 'Gartenlandschaft',
|
||||
},
|
||||
];
|
||||
|
||||
/** Template per ID (oder undefined). */
|
||||
export function getProjectTemplate(id: string): ProjectTemplate | undefined {
|
||||
return PROJECT_TEMPLATES.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Überträgt ein Template auf ein Dokument: Layer-Set wird ERSETZT
|
||||
* (Template = Startzustand). Grid/unit/pluginSeed sind UI-Einstellungen,
|
||||
* die der Aufrufer aus dem Template liest.
|
||||
*/
|
||||
export function applyProjectTemplate(doc: CADDocument, tpl: ProjectTemplate): void {
|
||||
doc.replaceLayers(tpl.layers);
|
||||
}
|
||||
@@ -359,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)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Task G3 – Titelblock-Service.
|
||||
*
|
||||
* Erzeugt eine Titelblock-Blockdefinition mit attributierten Texten
|
||||
* (Projekt/Datum/Maßstab/Autor) nach der ATTDEF-Konvention aus F5/F6
|
||||
* (properties.tag + attdef:true + text 'TAG=Wert'), sodass:
|
||||
* - der DXF-Writer (F6) sie als ATTDEF in Blockdefinitionen exportiert
|
||||
* - das spätere PropertiesPanel (D12) daraus ein Formular bauen kann
|
||||
* - Layouts über frameBlockRef referenzieren (G1)
|
||||
*/
|
||||
import type { CADElement, BlockDefinition } from '../types/cad.types';
|
||||
import type { CADDocument } from '../kernel/document/CADDocument';
|
||||
|
||||
export const TITLE_BLOCK_NAME = 'Titelblock A4';
|
||||
|
||||
export interface TitleBlockMeta {
|
||||
project?: string;
|
||||
date?: string;
|
||||
scale?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
interface AttrSlot {
|
||||
tag: string;
|
||||
prompt: string;
|
||||
/** Position der Textzeile relativ zum Block-Ursprung (unten-links im Rahmen). */
|
||||
dx: number;
|
||||
dy: number;
|
||||
}
|
||||
|
||||
const SLOTS: AttrSlot[] = [
|
||||
{ tag: 'PROJEKT', prompt: 'Projektname', dx: 10, dy: 18 },
|
||||
{ tag: 'DATUM', prompt: 'Datum der Zeichnung', dx: 10, dy: 36 },
|
||||
{ tag: 'MASSSTAB', prompt: 'Zeichnungsmaßstab', dx: 10, dy: 54 },
|
||||
{ tag: 'AUTOR', prompt: 'Bearbeiter/Autor', dx: 10, dy: 72 },
|
||||
];
|
||||
|
||||
const FRAME_W = 180;
|
||||
const FRAME_H = 100;
|
||||
const FALLBACK = '\u2014'; // —
|
||||
|
||||
let idSeq = 0;
|
||||
function nid(prefix: string): string {
|
||||
return `titleblk-${prefix}-${Date.now()}-${idSeq++}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Titelblock-Elemente: Rahmen (Zentrum 0,0) + Titel + 4 attributierte Texte.
|
||||
* Rein — keine Doc-Interaktion (unit-testbar).
|
||||
*/
|
||||
export function createTitleBlockElements(meta: TitleBlockMeta): CADElement[] {
|
||||
const els: CADElement[] = [];
|
||||
|
||||
// Rahmen: rect in App-Konvention (x/y = Zentrum), Umriss unten-links
|
||||
els.push({
|
||||
id: nid('frame'),
|
||||
type: 'rect',
|
||||
layerId: 'layer-0',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: FRAME_W,
|
||||
height: FRAME_H,
|
||||
properties: {
|
||||
stroke: '#e0e0e0',
|
||||
strokeWidth: 1.5,
|
||||
fill: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
// Titel (kein Attribut — fester Blockname als Beschriftung)
|
||||
els.push({
|
||||
id: nid('title'),
|
||||
type: 'text',
|
||||
layerId: 'layer-0',
|
||||
x: FRAME_W / 2 - 10,
|
||||
y: FRAME_H / 2 - 6,
|
||||
width: 0,
|
||||
height: 0,
|
||||
properties: {
|
||||
text: TITLE_BLOCK_NAME,
|
||||
fontSize: 10,
|
||||
stroke: '#9aa0a6',
|
||||
},
|
||||
});
|
||||
|
||||
const valueFor = (tag: string): string => {
|
||||
switch (tag) {
|
||||
case 'PROJEKT': return meta.project?.trim() || FALLBACK;
|
||||
case 'DATUM': return meta.date?.trim() || FALLBACK;
|
||||
case 'MASSSTAB': return meta.scale?.trim() || FALLBACK;
|
||||
case 'AUTOR': return meta.author?.trim() || FALLBACK;
|
||||
default: return FALLBACK;
|
||||
}
|
||||
};
|
||||
|
||||
for (const slot of SLOTS) {
|
||||
els.push({
|
||||
id: nid('attr'),
|
||||
type: 'text',
|
||||
layerId: 'layer-0',
|
||||
x: -FRAME_W / 2 + slot.dx,
|
||||
y: -FRAME_H / 2 + slot.dy,
|
||||
width: 0,
|
||||
height: 0,
|
||||
properties: {
|
||||
text: `${slot.tag}=${valueFor(slot.tag)}`,
|
||||
fontSize: 9,
|
||||
stroke: '#e0e0e0',
|
||||
tag: slot.tag,
|
||||
prompt: slot.prompt,
|
||||
attdef: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return els;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-insert: Erzeugt Blockdefinition + setzt frameBlockRef im Layout.
|
||||
* Bestehende frameBlockRef wird ersetzt (Re-Insert aktualisiert Metadaten
|
||||
* über einen neuen Block; der alte Block bleibt unreferenziert bestehen).
|
||||
* @returns Block-ID des neuen Titelblocks.
|
||||
* @throws wenn Layout nicht existiert.
|
||||
*/
|
||||
export function insertTitleBlockForLayout(
|
||||
doc: CADDocument,
|
||||
layoutId: string,
|
||||
meta: TitleBlockMeta,
|
||||
): string {
|
||||
const layout = doc.getLayout(layoutId);
|
||||
if (!layout) throw new Error(`insertTitleBlockForLayout: Layout '${layoutId}' nicht gefunden`);
|
||||
|
||||
const blockId = nid('blk');
|
||||
const block: BlockDefinition = {
|
||||
id: blockId,
|
||||
name: TITLE_BLOCK_NAME,
|
||||
description: 'Titelblock mit Attributen (Projekt/Datum/Maßstab/Autor) — Task G3',
|
||||
category: 'frame',
|
||||
elements: createTitleBlockElements(meta),
|
||||
};
|
||||
doc.addBlock(block);
|
||||
|
||||
doc.updateLayout(layoutId, { frameBlockRef: blockId });
|
||||
return blockId;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* CAD-14 Phase 4a - toolOptionsService.
|
||||
*
|
||||
* Zentraler Store fuer Werkzeug-Optionen (Laenge, Breite, Gurtrohre,
|
||||
* Winkel, Durchmesser...). Der InteractionDispatcher liest darueber
|
||||
* getOptions; die UI (OptionsPanel) schreibt hier hinein. Defaults
|
||||
* stammen aus dem optionsSchema der V2-Werkzeuge.
|
||||
*/
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
/** Per-Tool gesetzte Werte (override Defaults). */
|
||||
const values = new Map<string, Record<string, unknown>>();
|
||||
/** Per-Tool Defaults (aus optionsSchema gefuellt von der Registry/UI). */
|
||||
const defaults = new Map<string, Record<string, unknown>>();
|
||||
/** Abonnenten (UI-Update bei Aenderung). */
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function notify(): void {
|
||||
for (const l of Array.from(listeners)) l();
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet sinnvolle Defaults aus einem optionsSchema ab:
|
||||
* number -> default ?? min (geclamped), color -> '#9aa0a6', sonst undefined.
|
||||
*/
|
||||
export function defaultsFromSchema(schema: Array<{ key: string; type: string; default?: unknown; min?: number }>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of schema) {
|
||||
if (f.default !== undefined) { out[f.key] = f.default; continue; }
|
||||
if (f.type === 'number') {
|
||||
out[f.key] = typeof f.min === 'number' ? Math.max(f.min, f.min === 0 ? 0 : Math.round(f.min)) : 0;
|
||||
} else if (f.type === 'color') {
|
||||
out[f.key] = '#9aa0a6';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Registriert Defaults fuer ein Werkzeug (aus optionsSchema). */
|
||||
export function setToolOptionsDefaults(toolId: string, defs: Record<string, unknown>): void {
|
||||
defaults.set(toolId, { ...(defaults.get(toolId) ?? {}), ...defs });
|
||||
}
|
||||
|
||||
export function getToolOption<T = unknown>(toolId: string, key: string, fallback?: T): T {
|
||||
const v = values.get(toolId)?.[key];
|
||||
if (v !== undefined) return v as T;
|
||||
const d = defaults.get(toolId)?.[key];
|
||||
if (d !== undefined) return d as T;
|
||||
return fallback as T;
|
||||
}
|
||||
|
||||
export function setToolOption(toolId: string, key: string, value: unknown): void {
|
||||
const cur = values.get(toolId) ?? {};
|
||||
cur[key] = value;
|
||||
values.set(toolId, cur);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function getToolOptions(toolId: string): Record<string, unknown> {
|
||||
return { ...(defaults.get(toolId) ?? {}), ...(values.get(toolId) ?? {}) };
|
||||
}
|
||||
|
||||
export function subscribeToolOptions(fn: Listener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
|
||||
export function resetToolOptions(): void {
|
||||
values.clear();
|
||||
defaults.clear();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+346
-5
@@ -335,7 +335,10 @@ a:hover { text-decoration: underline; }
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: var(--ribbon-h);
|
||||
height: auto;
|
||||
min-height: var(--ribbon-h);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
z-index: 25;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -370,20 +373,22 @@ a:hover { text-decoration: underline; }
|
||||
.ribbon-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
padding: 6px 8px;
|
||||
gap: var(--spacing-lg);
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ribbon-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 4px 0;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
.ribbon-group-btns { display: flex; align-items: center; gap: 2px; flex: 1; }
|
||||
.ribbon-group-btns { display: flex; align-items: center; gap: 2px; flex: 1; flex-wrap: wrap; justify-content: center; }
|
||||
.ribbon-group-label {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
@@ -2796,3 +2801,339 @@ 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); }
|
||||
|
||||
/* ─── Task CAD-9 – BOM-Dialog ─── */
|
||||
.bom-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.bom-dialog {
|
||||
background: #1e1e2e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
min-width: 640px;
|
||||
max-width: 800px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.bom-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.bom-dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.bom-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.bom-close:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.bom-empty {
|
||||
color: #888;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.bom-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.bom-table th,
|
||||
.bom-table td {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
.bom-table th {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #9aa0a6;
|
||||
font-weight: 600;
|
||||
}
|
||||
.bom-table td {
|
||||
color: #d0d0d0;
|
||||
}
|
||||
.bom-sum-row td {
|
||||
background: rgba(74, 144, 217, 0.1);
|
||||
}
|
||||
.bom-dialog-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.bom-btn-primary {
|
||||
background: #4a90d9;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.bom-btn-primary:hover {
|
||||
background: #5ba0e9;
|
||||
}
|
||||
.bom-btn-secondary {
|
||||
background: transparent;
|
||||
color: #9aa0a6;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.bom-btn-secondary:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ─── CAD-10: Zeichnen-Tab V2-Buttons ─── */
|
||||
.ribbon-draw-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 2px 0;
|
||||
width: 100%;
|
||||
}
|
||||
.ribbon-v2-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 52px;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text, #d0d0d0);
|
||||
}
|
||||
.ribbon-v2-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.ribbon-v2-btn.active {
|
||||
background: rgba(74, 144, 217, 0.15);
|
||||
border-color: rgba(74, 144, 217, 0.5);
|
||||
}
|
||||
.ribbon-v2-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.ribbon-v2-label {
|
||||
font-size: 9px;
|
||||
color: var(--color-text-secondary, #9aa0a6);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ribbon-v2-btn.active .ribbon-v2-label {
|
||||
color: #4a90d9;
|
||||
}
|
||||
|
||||
/* CAD-14 Phase 4a: Werkzeug-Optionen */
|
||||
.tool-options-panel { padding: 8px 4px; }
|
||||
.toolopt-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 8px; padding: 4px 0; font-size: 12px; color: var(--color-text, #d0d0d0);
|
||||
}
|
||||
.toolopt-row input[type=number], .toolopt-row select {
|
||||
width: 90px; padding: 3px 6px; background: rgba(255,255,255,0.06);
|
||||
color: #d0d0d0; border: 1px solid rgba(255,255,255,0.15); border-radius: 4px;
|
||||
}
|
||||
.toolopt-row input[type=color] { width: 36px; height: 24px; border: none; background: transparent; cursor: pointer; }
|
||||
|
||||
@@ -588,3 +588,19 @@ export function distance(p1: { x: number; y: number }, p2: { x: number; y: numbe
|
||||
export function angleBetween(p1: { x: number; y: number }, p2: { x: number; y: number }): number {
|
||||
return (Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fläche eines Polygons via Shoelace-Formel (Task H5).
|
||||
* Absolutwert — Uhrzeigersinn und gegen Uhrzeigersinn ergeben dasselbe.
|
||||
* Weniger als 3 Punkte oder kollineare Punkte → 0.
|
||||
*/
|
||||
export function polygonArea(pts: Array<{ x: number; y: number }>): number {
|
||||
if (!Array.isArray(pts) || pts.length < 3) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % pts.length];
|
||||
sum += a.x * b.y - b.x * a.y;
|
||||
}
|
||||
return Math.abs(sum / 2);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ export type ElementType =
|
||||
| 'line' | 'circle' | 'arc' | 'rect' | 'polygon'
|
||||
| 'polyline' | 'text' | 'dimension' | 'block_instance' | 'chair'
|
||||
| 'seating-row' | 'seating-block' | 'table' | 'stage'
|
||||
| 'leader' | 'revcloud' | 'image';
|
||||
| 'leader' | 'revcloud' | 'image'
|
||||
| 'curtain' | 'spotlight' | 'barrier' | 'truss' | 'boxcorner' | 'circletruss';
|
||||
|
||||
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 {
|
||||
|
||||
@@ -3,13 +3,22 @@
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import type { CADElement, CADLayer, BlockDefinition, ToolType } from './cad.types';
|
||||
import type { ToolState } from '../interaction';
|
||||
/** CAD-14 Phase 4c: ToolState/ToolPhase (ehem. interaction/index) — V2-nativ. */
|
||||
export type ToolPhase = 'idle' | 'drawing' | 'modifying' | 'panning' | 'selecting';
|
||||
export interface ToolState {
|
||||
activeTool: string | null;
|
||||
phase: ToolPhase;
|
||||
points: Array<{ x: number; y: number }>;
|
||||
previewElement: import('./cad.types').CADElement | null;
|
||||
ortho: boolean;
|
||||
snapEnabled: boolean;
|
||||
}
|
||||
import type { BackgroundConfig } from '../services/backgroundService';
|
||||
import type { UserCursor } from '../crdt';
|
||||
import type { UnitType } from '../utils/format';
|
||||
|
||||
export type ViewMode = '2d' | 'iso' | 'front' | 'top';
|
||||
export type RibbonTab = 'start' | 'insert' | 'format' | 'view' | 'canvas' | 'tools' | 'ki';
|
||||
export type RibbonTab = 'start' | 'view' | 'ki';
|
||||
export type RightPanel = 'tool' | 'layer' | 'library' | 'ki';
|
||||
export type Theme = 'light' | 'dark';
|
||||
export type DrawerTab = 'tool' | 'layer' | 'library' | 'ki';
|
||||
@@ -172,6 +181,8 @@ export interface RightSidebarProps {
|
||||
layers: CADLayer[];
|
||||
elements?: CADElement[];
|
||||
blocks: BlockDefinition[];
|
||||
/** CAD-14 Phase 4a: Aktives V2-Werkzeug (fuer Werkzeug-Optionen-Panel). */
|
||||
activeToolId?: string | null;
|
||||
activeLayerId?: string;
|
||||
onSelectLayer?: (id: string) => void;
|
||||
onAddLayer?: () => void;
|
||||
|
||||
@@ -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)} m²`;
|
||||
default:
|
||||
return `${mmSquared.toFixed(0)} mm²`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +59,9 @@ describe('RibbonBar', () => {
|
||||
it('should call onTabChange when clicking a tab', () => {
|
||||
const onTabChange = vi.fn();
|
||||
render(<RibbonBar activeTab="start" onTabChange={onTabChange} onAction={() => {}} />);
|
||||
// Click the 'Format' tab (unique text)
|
||||
fireEvent.click(screen.getByText('Format'));
|
||||
expect(onTabChange).toHaveBeenCalledWith('format');
|
||||
// CAD-13-fix: Werkzeuge nur noch in Sidebar — Ribbon hat Start/Ansicht/KI
|
||||
fireEvent.click(screen.getByText('Ansicht'));
|
||||
expect(onTabChange).toHaveBeenCalledWith('view');
|
||||
});
|
||||
|
||||
it('should call onAction when clicking an action button', () => {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Task D10-Rest – array-path: Elemente entlang einer Polyline verteilen.
|
||||
*
|
||||
* Klick1 = Quelle (Auto-HitSelect bei leerer Selektion), Klick2 auf
|
||||
* Pfad-Polyline → Element-Kopien gleichmaessig entlang der Pfad-
|
||||
* laenge verteilt (count aus Options, Default 5), Original bleibt,
|
||||
* ALLES in EINER Transaktion = 1 Undo-Schritt.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { arrayPathTool } from '../src/plugins/builtin/core-modify';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||
|
||||
function mkCtx(elements: CADElement[], options: Record<string, unknown> = {}) {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
for (const el of elements) doc.addElement(el);
|
||||
const selectedIds = new Set<string>();
|
||||
const statuses: string[] = [];
|
||||
const ctx: any = {
|
||||
doc,
|
||||
options,
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (_el: CADElement | null) => {},
|
||||
selection: {
|
||||
getIds: () => Array.from(selectedIds),
|
||||
setIds: (ids: string[]) => {
|
||||
selectedIds.clear();
|
||||
for (const id of ids) selectedIds.add(id);
|
||||
},
|
||||
hitTest: (x: number, y: number, _tol: number) => {
|
||||
// trifft das Element, dessen BBox den Punkt enthaelt (erstes Gewinnt)
|
||||
for (const el of elements) {
|
||||
if (
|
||||
x >= el.x - el.width / 2 && x <= el.x + el.width / 2 &&
|
||||
y >= el.y - el.height / 2 && y <= el.y + el.height / 2
|
||||
) return el;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
return { ctx, doc, selectedIds, statuses };
|
||||
}
|
||||
|
||||
function pe(x: number, y: number): { world: Pt } {
|
||||
return { world: { x, y } } as never;
|
||||
}
|
||||
|
||||
const CHAIR = {
|
||||
id: 'chair-1', type: 'chair', layerId: 'layer-0',
|
||||
x: 0, y: 0, width: 40, height: 40, properties: {},
|
||||
} as unknown as CADElement;
|
||||
|
||||
const PATH = {
|
||||
id: 'path-1', type: 'polyline', layerId: 'layer-0',
|
||||
x: 250, y: 0, width: 500, height: 0,
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 500, y: 0 }] },
|
||||
} as unknown as CADElement;
|
||||
|
||||
beforeEach(() => {
|
||||
arrayPathTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
describe('D10: arrayPathTool', () => {
|
||||
it('verteilt count Kopien gleichmaessig entlang der Pfadlaenge (Original bleibt)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||
selectedIds.add('chair-1');
|
||||
// Klick1: Quelle ist schon selektiert -> Basis direkt setzen
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
// Klick2: auf den Pfad klicken (Pfad-Mitte 250,0)
|
||||
arrayPathTool.handlers.down!(pe(250, 0), ctx);
|
||||
|
||||
const els = doc.getAllElements();
|
||||
// Original + Pfad + 5 Kopien
|
||||
const chairs = els.filter((e) => e.type === 'chair');
|
||||
expect(chairs.length).toBe(1 + 5);
|
||||
// Kopien getrennt vom Original (Original bleibt bei x=0 unangetastet):
|
||||
const copies = chairs.filter((c) => c.id !== 'chair-1');
|
||||
expect(copies).toHaveLength(5);
|
||||
const copyXs = copies.map((c) => c.x).sort((a, b) => a - b);
|
||||
// count=5 gleichmaessig ueber Pfadlaenge 500: s = i/(count-1)*total
|
||||
expect(copyXs[0]).toBeCloseTo(0, 0);
|
||||
expect(copyXs[1]).toBeCloseTo(125, 0);
|
||||
expect(copyXs[2]).toBeCloseTo(250, 0);
|
||||
expect(copyXs[3]).toBeCloseTo(375, 0);
|
||||
expect(copyXs[4]).toBeCloseTo(500, 0);
|
||||
});
|
||||
|
||||
it('Auto-HitSelect bei leerer Selektion (Klick1 auf Element)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||
// Selektion ist leer: Klick auf den Stuhl waehlt ihn automatisch
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
expect(selectedIds.has('chair-1')).toBe(true);
|
||||
// Klick2 auf Pfad -> Commit
|
||||
arrayPathTool.handlers.down!(pe(100, 0), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair').length).toBe(6);
|
||||
});
|
||||
|
||||
it('Klick2 trifft KEINEN Pfad -> kein Commit, Status erklaert', () => {
|
||||
const { ctx, doc, selectedIds, statuses } = mkCtx([CHAIR, PATH]);
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
// Klick weit weg vom Pfad (kein Element getroffen)
|
||||
arrayPathTool.handlers.down!(pe(9999, 9999), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1);
|
||||
expect(statuses[statuses.length - 1]).toContain('Pfad');
|
||||
});
|
||||
|
||||
it('Kopien in EINER Transaktion = 1 Undo entfernt alle', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
arrayPathTool.handlers.down!(pe(250, 0), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(6);
|
||||
doc.undo();
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('count-Option steuert die Anzahl (3 statt 5)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH], { count: 3 });
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
arrayPathTool.handlers.down!(pe(250, 0), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(4); // Original + 3
|
||||
// Kopien getrennt vom Original: count=3 -> s = 0, 250, 500
|
||||
const copies3 = doc.getAllElements().filter((e) => e.type === 'chair' && e.id !== 'chair-1');
|
||||
const copyXs3 = copies3.map((c) => c.x).sort((a, b) => a - b);
|
||||
expect(copyXs3[0]).toBeCloseTo(0, 0);
|
||||
expect(copyXs3[1]).toBeCloseTo(250, 0);
|
||||
expect(copyXs3[2]).toBeCloseTo(500, 0);
|
||||
});
|
||||
|
||||
it('gebogener Pfad (3 Punkte): Kopien folgen der Geometrie', () => {
|
||||
const cornerPath = {
|
||||
id: 'path-2', type: 'polyline', layerId: 'layer-0',
|
||||
x: 100, y: 100, width: 200, height: 200,
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 200, y: 200 }] },
|
||||
} as unknown as CADElement;
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, cornerPath], { count: 4 });
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
arrayPathTool.handlers.down!(pe(100, 10), ctx);
|
||||
const chairs = doc.getAllElements().filter((e) => e.type === 'chair');
|
||||
expect(chairs.length).toBe(5); // Original + 4
|
||||
// Kopien muessen auf dem L-foermigen Pfad liegen: alle x<=200, y>=0
|
||||
for (const c of chairs.slice(1)) {
|
||||
expect(c.x).toBeLessThanOrEqual(200.5);
|
||||
expect(c.y).toBeGreaterThanOrEqual(-0.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('cancel resettet die Sitzung', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
arrayPathTool.handlers.cancel?.(ctx);
|
||||
// Nach cancel: Klick startet frisch (Auto-HitSelect-Pfad wieder aktiv)
|
||||
arrayPathTool.handlers.down!(pe(9999, 9999), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Manifest: id=array-path, tags pro', () => {
|
||||
expect(arrayPathTool.manifest.id).toBe('array-path');
|
||||
expect((arrayPathTool.manifest.tags ?? [])).toContain('pro');
|
||||
const keys = (arrayPathTool.optionsSchema ?? []).map((f) => f.key);
|
||||
expect(keys).toContain('count');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Task CAD-12a - Autosave-Service.
|
||||
*
|
||||
* Der Yjs-CRDT speichert bereits automatisch ueber WebSocket.
|
||||
* Dieser Service macht das SICHTBAR und ergaenzt:
|
||||
* - Save-Status-Text (Automatisch gespeichert / Offline / Speichert...)
|
||||
* - Periodischer Full-Save in SQLite (Redundanz)
|
||||
* - Reconnect-Handling (CRDT synced automatisch nach)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
createAutosaveController,
|
||||
type AutosaveStatus,
|
||||
AUTOSAVE_INTERVAL_MS,
|
||||
} from '../src/services/autosaveService';
|
||||
|
||||
describe('CAD-12a: autosaveService', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('AUTOSAVE_INTERVAL_MS ist 30 Sekunden', () => {
|
||||
expect(AUTOSAVE_INTERVAL_MS).toBe(30000);
|
||||
});
|
||||
|
||||
it('Controller: Status ist "idle" wenn kein CRDT verbunden', () => {
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => false,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: vi.fn(),
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
const status: AutosaveStatus = ctrl.getStatus();
|
||||
expect(status.state).toBe('offline');
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: Status ist "saved" wenn CRDT verbunden und keine ungespeicherten Aenderungen', () => {
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: vi.fn(),
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
expect(ctrl.getStatus().state).toBe('saved');
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: Status wechselt zu "saving" wenn Aenderungen vorhanden und Timer feuert', () => {
|
||||
const fullSave = vi.fn().mockResolvedValue(undefined);
|
||||
const onStatus = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => true,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: onStatus,
|
||||
});
|
||||
vi.advanceTimersByTime(31000);
|
||||
expect(fullSave).toHaveBeenCalledTimes(1);
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: fuehrt KEINEN Full-Save aus wenn keine Aenderungen', () => {
|
||||
const fullSave = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
vi.advanceTimersByTime(31000);
|
||||
expect(fullSave).not.toHaveBeenCalled();
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: fuehrt KEINEN Full-Save aus wenn CRDT offline', () => {
|
||||
const fullSave = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => false,
|
||||
hasUnsavedChanges: () => true,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
vi.advanceTimersByTime(31000);
|
||||
expect(fullSave).not.toHaveBeenCalled();
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: Status-Meldung an onStatusChange bei Aenderung', () => {
|
||||
const onStatus = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: vi.fn(),
|
||||
onStatusChange: onStatus,
|
||||
});
|
||||
expect(onStatus).toHaveBeenCalledWith({ state: 'saved', lastSaved: null });
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: markChanges triggert saving beim naechsten Timer-Tick', () => {
|
||||
const fullSave = vi.fn().mockResolvedValue(undefined);
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
ctrl.markChanges();
|
||||
vi.advanceTimersByTime(31000);
|
||||
expect(fullSave).toHaveBeenCalled();
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: destroy stoppt den Timer', () => {
|
||||
const fullSave = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => true,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
ctrl.destroy();
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(fullSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Task CAD-6 - Hintergrund-Grundrisse mit Maszstab rendern.
|
||||
*
|
||||
* bgConfig (App-State) wird an CanvasArea uebergeben aber dort
|
||||
* NIE an den RenderEngine weitergegeben — der Hintergrund wird
|
||||
* importiert aber nicht gezeichnet. Fix: CanvasArea-Effect
|
||||
* setzt bgConfig in renderEngine.setOptions (backgroundSrc,
|
||||
* scale, offset, rotation, opacity).
|
||||
* PixiRenderer.drawBackground: Sprite-Implementierung.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { PixiRenderer } from '../src/render/pixi/PixiRenderer';
|
||||
|
||||
// ─── PixiRenderer.drawBackground (Sprite-Implementierung) ─────
|
||||
|
||||
describe('CAD-6: PixiRenderer.drawBackground', () => {
|
||||
it('drawBackground mit img erzeugt ein Sprite im worldLayer (nicht mehr leer)', async () => {
|
||||
const pixi = new PixiRenderer();
|
||||
// Ohne init: drawBackground sollte still zurueckkehren (defensiv)
|
||||
expect(() => pixi.drawBackground(null, {})).not.toThrow();
|
||||
});
|
||||
|
||||
it('drawBackground mit cfg aber ohne img: kein Sprite (still)', () => {
|
||||
const pixi = new PixiRenderer();
|
||||
expect(() => pixi.drawBackground(null, { src: 'test', opacity: 0.5, offsetX: 0, offsetY: 0, scale: 1, rotation: 0 })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CanvasArea: bgConfig-Effect (setOptions-Verkabelung) ────
|
||||
|
||||
describe('CAD-6: bgConfig wird an RenderEngine weitergegeben', () => {
|
||||
it('CanvasArea-Quelle enthaelt bgConfig-Verarbeitung in setOptions', async () => {
|
||||
const src = await import('fs').then(m => m.readFileSync('src/components/CanvasArea.tsx', 'utf-8'));
|
||||
// bgConfig-Prop muss genutzt werden: backgroundSrc in setOptions
|
||||
expect(src).toContain('bgConfig');
|
||||
expect(src).toMatch(/backgroundSrc.*bgConfig|bgConfig.*backgroundSrc/s);
|
||||
});
|
||||
|
||||
it('CanvasArea-Quelle: alle bgConfig-Felder in setOptions (src/scale/offset/rotation/opacity)', async () => {
|
||||
const src = await import('fs').then(m => m.readFileSync('src/components/CanvasArea.tsx', 'utf-8'));
|
||||
expect(src).toContain('backgroundScale');
|
||||
expect(src).toContain('backgroundOffsetX');
|
||||
expect(src).toContain('backgroundOffsetY');
|
||||
expect(src).toContain('backgroundRotation');
|
||||
expect(src).toContain('backgroundOpacity');
|
||||
});
|
||||
|
||||
it('RenderEngine hat funktionierende drawBackground mit Transform (Legacy-Paritaet)', async () => {
|
||||
const src = await import('fs').then(m => m.readFileSync('src/canvas/RenderEngine.ts', 'utf-8'));
|
||||
expect(src).toContain('drawImage');
|
||||
expect(src).toContain('globalAlpha');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Task D12 - BlockAttribute: attrDefs + attrValues + Rendering.
|
||||
*
|
||||
* BlockDefinition.attrDefs[]: definiert Attribut-Slots (tag/prompt/
|
||||
* default). Instanz (block_instance) traegt attrValues. Der Render-
|
||||
* Adapter (elementToPrimitives) zeichnet die Attr-Werte als Text-
|
||||
* Primitive an der Instanz-Position.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import { elementToPrimitives } from '../src/render/pixi/elementDrawers';
|
||||
import type { CADElement, BlockDefinition } from '../src/types/cad.types';
|
||||
|
||||
const BLOCK: BlockDefinition = {
|
||||
id: 'blk-1',
|
||||
name: 'Titelblock',
|
||||
description: '',
|
||||
category: 'frame',
|
||||
elements: [],
|
||||
attrDefs: [
|
||||
{ tag: 'PROJEKT', prompt: 'Projektname', defaultValue: 'Unbenannt' },
|
||||
{ tag: 'AUTOR', prompt: 'Bearbeiter', defaultValue: 'WebCAD' },
|
||||
],
|
||||
};
|
||||
|
||||
function instance(attrValues: Record<string, string>): CADElement {
|
||||
return {
|
||||
id: 'inst-1', type: 'block_instance', layerId: 'layer-0',
|
||||
x: 100, y: 100, width: 0, height: 0,
|
||||
properties: { blockId: 'blk-1', scale: 1, attrValues },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
// ─── Typ-Ebene: attrDefs auf BlockDefinition ────────────────
|
||||
|
||||
describe('D12: BlockDefinition.attrDefs', () => {
|
||||
it('attrDefs-Feld existiert mit tag/prompt/defaultValue', () => {
|
||||
expect(BLOCK.attrDefs).toBeDefined();
|
||||
expect(BLOCK.attrDefs!.length).toBe(2);
|
||||
expect(BLOCK.attrDefs![0].tag).toBe('PROJEKT');
|
||||
expect(BLOCK.attrDefs![0].prompt).toBe('Projektname');
|
||||
expect(BLOCK.attrDefs![0].defaultValue).toBe('Unbenannt');
|
||||
});
|
||||
|
||||
it('CADDocument speichert Block mit attrDefs (getBlock liefert sie zurueck)', () => {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
doc.addBlock(BLOCK);
|
||||
const loaded = doc.getBlock('blk-1');
|
||||
expect(loaded!.attrDefs).toHaveLength(2);
|
||||
expect(loaded!.attrDefs![1].tag).toBe('AUTOR');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rendering: elementToPrimitives mit attrDefs ────────────
|
||||
|
||||
describe('D12: RenderAdapter zeichnet Attr-Texte', () => {
|
||||
it('Instanz mit attrValues erzeugt text-Primitives pro Attribut', () => {
|
||||
const el = instance({ PROJEKT: 'Halle A', AUTOR: 'L. Admin' });
|
||||
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||
const texts = prims.filter((p) => p.kind === 'text');
|
||||
expect(texts).toHaveLength(2);
|
||||
const proj = texts.find((t) => (t as { text: string }).text.includes('PROJEKT'));
|
||||
expect(proj).toBeDefined();
|
||||
expect((proj as { text: string }).text).toContain('Halle A');
|
||||
const autor = texts.find((t) => (t as { text: string }).text.includes('AUTOR'));
|
||||
expect((autor as { text: string }).text).toContain('L. Admin');
|
||||
});
|
||||
|
||||
it('Fehlender Wert faellt auf attrDefs.defaultValue zurueck', () => {
|
||||
const el = instance({});
|
||||
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||
const texts = prims.filter((p) => p.kind === 'text');
|
||||
const proj = texts.find((t) => (t as { text: string }).text.includes('PROJEKT'));
|
||||
expect((proj as { text: string }).text).toContain('Unbenannt');
|
||||
});
|
||||
|
||||
it('Ohne attrDefs-Parameter: kein Attr-Text-Rendering (abwaertskompatibel)', () => {
|
||||
const el = instance({ PROJEKT: 'Halle A' });
|
||||
const prims = elementToPrimitives(el);
|
||||
expect(prims.filter((p) => p.kind === 'text')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Instanz ohne attrValues mit attrDefs: defaults werden gezeichnet', () => {
|
||||
const el: CADElement = {
|
||||
id: 'inst-2', type: 'block_instance', layerId: 'layer-0',
|
||||
x: 0, y: 0, width: 0, height: 0,
|
||||
properties: { blockId: 'blk-1', scale: 1 },
|
||||
} as unknown as CADElement;
|
||||
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||
const texts = prims.filter((p) => p.kind === 'text');
|
||||
expect(texts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Text-Positionen gestaffelt unterhalb der Instanz-Position', () => {
|
||||
const el = instance({ PROJEKT: 'A', AUTOR: 'B' });
|
||||
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||
const texts = prims.filter((p) => p.kind === 'text') as Array<{ kind: 'text'; at: { x: number; y: number }; text: string }>;
|
||||
expect(texts[0].at.x).toBe(100);
|
||||
expect(texts[0].at.y).toBeCloseTo(100 - 8, 0);
|
||||
expect(texts[1].at.y).toBeLessThan(texts[0].at.y);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Task CAD-9 - Stueckliste (BOM) fuer Traversen.
|
||||
*
|
||||
* buildBOM: gruppiert Traversen + Boxcorner nach Typ + Mass
|
||||
* (Laenge x Breite), zaehlt Stueckzahlen, summiert Gesamt-meter.
|
||||
* exportBOMCsv: saubere CSV mit Kopfzeile fuer Excel-Import.
|
||||
* Dialog zeigt Tabelle + CSV-Download.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildBOM, exportBOMCsv, type BOMRow } from '../src/services/bomService';
|
||||
import type { CADElement } from '../src/types/cad.types';
|
||||
|
||||
function truss(id: string, x: number, y: number, length: number, rotation: number, width = 29): CADElement {
|
||||
return {
|
||||
id, type: 'truss', layerId: 'l', x, y, width: length, height: width,
|
||||
properties: { length, rotation, trussWidth: width, truss: true },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
function corner(id: string, x: number, y: number, armLength: number, rotation: number, width = 29): CADElement {
|
||||
return {
|
||||
id, type: 'boxcorner', layerId: 'l', x, y, width: 80, height: 80,
|
||||
properties: { armLength, rotation, trussWidth: width, boxcorner: true },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
function chair(id: string): CADElement {
|
||||
return {
|
||||
id, type: 'chair', layerId: 'l', x: 0, y: 0, width: 45, height: 45,
|
||||
properties: {},
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
// ─── buildBOM: Gruppierung ──────────────────────────────────
|
||||
|
||||
describe('CAD-9: buildBOM (Gruppierung + Zaehlung)', () => {
|
||||
it('zwei identische Traversen (200x29) ergeben EINE Position mit Anzahl 2', () => {
|
||||
const els = [
|
||||
truss('t1', 0, 0, 200, 0),
|
||||
truss('t2', 100, 100, 200, 90),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
const rows = bom.rows.filter((r) => r.type === 'Traverse');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].count).toBe(2);
|
||||
});
|
||||
|
||||
it('unterschiedliche Laengen ergetrennte Positionen', () => {
|
||||
const els = [
|
||||
truss('t1', 0, 0, 200, 0),
|
||||
truss('t2', 0, 0, 100, 0),
|
||||
truss('t3', 0, 0, 300, 0),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
const trussRows = bom.rows.filter((r) => r.type === 'Traverse');
|
||||
expect(trussRows).toHaveLength(3);
|
||||
const counts = trussRows.map((r) => r.count);
|
||||
expect(counts).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it('unterschiedliche Breiten = getrennte Positionen (100x29 vs 100x40)', () => {
|
||||
const els = [
|
||||
truss('t1', 0, 0, 100, 0, 29),
|
||||
truss('t2', 0, 0, 100, 0, 40),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
expect(bom.rows.filter((r) => r.type === 'Traverse')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Boxcorner als eigener Typ mit armLength als Laenge', () => {
|
||||
const els = [
|
||||
corner('c1', 0, 0, 50, 0),
|
||||
corner('c2', 100, 100, 50, 90),
|
||||
corner('c3', 200, 200, 75, 0),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
const cornerRows = bom.rows.filter((r) => r.type === 'Eck-Traverse');
|
||||
expect(cornerRows).toHaveLength(2); // 50cm x2 + 75cm x1
|
||||
const r50 = cornerRows.find((r) => r.length === 50)!;
|
||||
expect(r50.count).toBe(2);
|
||||
});
|
||||
|
||||
it('Nicht-Traversen (chair etc.) werden ignoriert', () => {
|
||||
const els = [chair('s1'), truss('t1', 0, 0, 200, 0)];
|
||||
const bom = buildBOM(els);
|
||||
expect(bom.rows.every((r) => r.type !== 'chair')).toBe(true);
|
||||
});
|
||||
|
||||
it('leere Zeichnung: leere Stueckliste ohne Crash', () => {
|
||||
const bom = buildBOM([]);
|
||||
expect(bom.rows).toHaveLength(0);
|
||||
expect(bom.totalLengthM).toBe(0);
|
||||
});
|
||||
|
||||
it('Gesamtlaenge: 200+100+300cm Traversen + 2x50cm Ecken = 7.00m', () => {
|
||||
const els = [
|
||||
truss('t1', 0, 0, 200, 0),
|
||||
truss('t2', 0, 0, 100, 0),
|
||||
truss('t3', 0, 0, 300, 0),
|
||||
corner('c1', 0, 0, 50, 0),
|
||||
corner('c2', 0, 0, 50, 90),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
// Traversen 6.00m + 2 Ecken je 2 Arme = 8.00m
|
||||
expect(bom.totalLengthM).toBeCloseTo(8.0, 2);
|
||||
});
|
||||
|
||||
it('Boxcorner zaehlt BEIDE Arme in die Gesamtlaenge (50er-Ecke = 1.00m)', () => {
|
||||
const els = [corner('c1', 0, 0, 50, 0)];
|
||||
const bom = buildBOM(els);
|
||||
expect(bom.totalLengthM).toBeCloseTo(1.0, 2);
|
||||
});
|
||||
|
||||
it('Positionen sind sortiert: Traversen vor Ecken, dann nach Laenge absteigend', () => {
|
||||
const els = [
|
||||
corner('c1', 0, 0, 50, 0),
|
||||
truss('t1', 0, 0, 100, 0),
|
||||
truss('t2', 0, 0, 300, 0),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
const types = bom.rows.map((r) => r.type);
|
||||
// Traversen zuerst (nach Laenge absteigend), dann Ecken
|
||||
expect(types.indexOf('Traverse')).toBeLessThan(types.indexOf('Eck-Traverse'));
|
||||
const trussLens = bom.rows.filter((r) => r.type === 'Traverse').map((r) => r.length);
|
||||
expect(trussLens[0]).toBeGreaterThanOrEqual(trussLens[trussLens.length - 1]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── exportBOMCsv ─────────────────────────────────────────
|
||||
|
||||
describe('CAD-9: exportBOMCsv', () => {
|
||||
it('CSV hat Kopfzeile und Zeilen pro Position mit Semikolon/ Komma', () => {
|
||||
const els = [truss('t1', 0, 0, 200, 0), truss('t2', 0, 0, 200, 90)];
|
||||
const bom = buildBOM(els);
|
||||
const csv = exportBOMCsv(bom);
|
||||
const lines = csv.split('\n');
|
||||
expect(lines[0]).toContain('Pos');
|
||||
expect(lines[0]).toContain('Typ');
|
||||
expect(lines[0]).toContain('Anzahl');
|
||||
expect(lines).toHaveLength(2 + 1); // Header + 1 Position + ggf. Summenzeile
|
||||
});
|
||||
|
||||
it('CSV enthaelt Laenge und Anzahl der Position', () => {
|
||||
const els = [truss('t1', 0, 0, 200, 0), truss('t2', 0, 0, 200, 0)];
|
||||
const csv = exportBOMCsv(buildBOM(els));
|
||||
expect(csv).toContain('200');
|
||||
expect(csv).toContain('2');
|
||||
});
|
||||
|
||||
it('CSV: Semikolon-Separatoren (Excel-DE-freundlich) oder Komma + konsistent', () => {
|
||||
const csv = exportBOMCsv(buildBOM([truss('t1', 0, 0, 200, 0)]));
|
||||
// Konsistenz: Jede Datenzeile hat dieselbe Anzahl Separatoren
|
||||
const lines = csv.trim().split('\n');
|
||||
const sepCounts = lines.map((l) => (l.match(/;/g) || []).length);
|
||||
expect(new Set(sepCounts).size).toBe(1);
|
||||
});
|
||||
|
||||
it('leere Stueckliste: nur Kopfzeile', () => {
|
||||
const csv = exportBOMCsv(buildBOM([]));
|
||||
expect(csv.trim().split('\n')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Task CAD-3 - Fenster-/Kreuz-Selektion (Box-Select).
|
||||
*
|
||||
* boxSelectElements: reine Funktion. Links-nach-rechts ziehen =
|
||||
* 'window' (Element muss VOLLSTAENDIG in der Box liegen),
|
||||
* rechts-nach-links = 'crossing' (Element darf die Box beruehren).
|
||||
* Im selectTool: Leerer down startet Box-Drag, up selektiert.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { boxSelectElements, boxModeFor } from '../src/plugins/builtin/core-modify';
|
||||
import { selectTool } 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[]) {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
for (const el of elements) doc.addElement(el);
|
||||
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) => {
|
||||
for (const el of elements) {
|
||||
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 - 5 && x <= maxX + 5 && y >= minY - 5 && y <= maxY + 5) return el;
|
||||
}
|
||||
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, previews, statuses };
|
||||
}
|
||||
|
||||
function pe(x: number, y: number, mods: { shift?: boolean } = {}): { world: Pt; shift: boolean; ctrl: boolean } {
|
||||
return { world: { x, y }, shift: mods.shift ?? false, ctrl: false } as never;
|
||||
}
|
||||
|
||||
function rect(id: string, x: number, y: number, w: number, h: number): CADElement {
|
||||
return { id, type: 'rect', layerId: 'l', x, y, width: w, height: h, properties: {} } as unknown as CADElement;
|
||||
}
|
||||
function line(id: string, x1: number, y1: number, x2: number, y2: number): CADElement {
|
||||
return {
|
||||
id, type: 'line', layerId: 'l',
|
||||
x: (x1 + x2) / 2, y: (y1 + y2) / 2,
|
||||
width: Math.abs(x2 - x1), height: Math.abs(y2 - y1),
|
||||
properties: { x1, y1, x2, y2 },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
selectTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
// ─── boxModeFor: Richtung bestimmt Modus ──────────────────────
|
||||
|
||||
describe('CAD-3: boxModeFor', () => {
|
||||
it('Links-nach-rechts = window', () => {
|
||||
expect(boxModeFor({ x: 0, y: 0 }, { x: 100, y: 100 })).toBe('window');
|
||||
});
|
||||
it('Rechts-nach-links = crossing', () => {
|
||||
expect(boxModeFor({ x: 100, y: 0 }, { x: 0, y: 100 })).toBe('crossing');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── boxSelectElements: window (vollstaendig enthalten) ─────
|
||||
|
||||
describe('CAD-3: window-Selektion (vollstaendig enthalten)', () => {
|
||||
it('Element komplett in Box wird selektiert', () => {
|
||||
const els = [rect('r1', 50, 50, 20, 20)];
|
||||
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
|
||||
const hit = boxSelectElements(els, box, 'window');
|
||||
expect(hit).toContain('r1');
|
||||
});
|
||||
|
||||
it('Element, das die Box-Kante durchstoesst, wird NICHT selektiert (window)', () => {
|
||||
// rect 90..110, Box 0..100: ragt raus
|
||||
const els = [rect('r1', 100, 50, 20, 20)];
|
||||
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
|
||||
const hit = boxSelectElements(els, box, 'window');
|
||||
expect(hit).not.toContain('r1');
|
||||
});
|
||||
|
||||
it('Line mit beiden Endpunkten in Box wird selektiert', () => {
|
||||
const els = [line('l1', 10, 10, 80, 80)];
|
||||
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
|
||||
expect(boxSelectElements(els, box, 'window')).toContain('l1');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── boxSelectElements: crossing (Beruehrung reicht) ─────────
|
||||
|
||||
describe('CAD-3: crossing-Selektion (Beruehrung reicht)', () => {
|
||||
it('Element, das die Box nur teilweise durchstoesst, WIRD selektiert', () => {
|
||||
const els = [rect('r1', 100, 50, 20, 20)]; // 90..110
|
||||
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
|
||||
const hit = boxSelectElements(els, box, 'crossing');
|
||||
expect(hit).toContain('r1');
|
||||
});
|
||||
|
||||
it('Element ausserhalb der Box wird nicht selektiert', () => {
|
||||
const els = [rect('r1', 500, 500, 20, 20)];
|
||||
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
|
||||
expect(boxSelectElements(els, box, 'crossing')).not.toContain('r1');
|
||||
});
|
||||
|
||||
it('Element, das die Box nur beruehrt (Kante = Kante), wird selektiert', () => {
|
||||
// rect endet exakt bei x=100 (Kante = Box-Kante)
|
||||
const els = [rect('r1', 95, 50, 10, 20)]; // 90..100
|
||||
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
|
||||
expect(boxSelectElements(els, box, 'crossing')).toContain('r1');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tool-Integration: Box-Drag im selectTool ────────────────
|
||||
|
||||
describe('CAD-3: selectTool Box-Drag', () => {
|
||||
it('Leerer down + move + up fuehrt Box-Selektion aus (window: links nach rechts)', () => {
|
||||
const inside = rect('r1', 50, 50, 20, 20);
|
||||
const outside = rect('r2', 500, 500, 20, 20);
|
||||
const { ctx, selectedIds, previews } = mkCtx([inside, outside]);
|
||||
|
||||
// down auf Leerraum (kein Element getroffen)
|
||||
selectTool.handlers.down!(pe(0, 0), ctx);
|
||||
// move zieht Box auf
|
||||
selectTool.handlers.move!(pe(100, 100), ctx);
|
||||
expect(previews.length).toBeGreaterThanOrEqual(1); // Box-Preview
|
||||
// up schliesst ab
|
||||
selectTool.handlers.up!(pe(100, 100), ctx);
|
||||
|
||||
expect(selectedIds.has('r1')).toBe(true);
|
||||
expect(selectedIds.has('r2')).toBe(false);
|
||||
});
|
||||
|
||||
it('Klick OHNE Zug (winzige Box) leert die Selektion (Legacy-Verhalten bleibt)', () => {
|
||||
const inside = rect('r1', 50, 50, 20, 20);
|
||||
const { ctx, selectedIds } = mkCtx([inside]);
|
||||
selectedIds.add('r1');
|
||||
// down + up am selben Punkt = kein Zug
|
||||
selectTool.handlers.down!(pe(300, 300), ctx);
|
||||
selectTool.handlers.up!(pe(300, 300), ctx);
|
||||
expect(Array.from(selectedIds)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Shift + Box-Select addiert zur bestehenden Selektion', () => {
|
||||
const a = rect('a', 50, 50, 20, 20);
|
||||
const b = rect('b', 200, 50, 20, 20);
|
||||
const { ctx, selectedIds } = mkCtx([a, b]);
|
||||
selectedIds.add('a'); // schon selektiert
|
||||
// Shift-Box um b (Shift an down UND up, wie der echte Dispatcher)
|
||||
selectTool.handlers.down!(pe(150, 0, { shift: true }), ctx);
|
||||
selectTool.handlers.up!(pe(250, 100, { shift: true }), ctx);
|
||||
expect(selectedIds.has('a')).toBe(true);
|
||||
expect(selectedIds.has('b')).toBe(true);
|
||||
});
|
||||
|
||||
it('Status meldet Anzahl und Modus', () => {
|
||||
const els = [rect('r1', 50, 50, 20, 20), rect('r2', 60, 60, 20, 20)];
|
||||
const { ctx, statuses } = mkCtx(els);
|
||||
selectTool.handlers.down!(pe(0, 0), ctx);
|
||||
selectTool.handlers.up!(pe(100, 100), ctx);
|
||||
const last = statuses[statuses.length - 1];
|
||||
expect(last).toContain('2');
|
||||
});
|
||||
|
||||
it('Box-Drag startet NICHT wenn ein Element getroffen wurde', () => {
|
||||
const el = rect('r1', 50, 50, 20, 20);
|
||||
const { ctx, selectedIds } = mkCtx([el]);
|
||||
// down AUF das Element: normale Einzelselektion, kein Box-Start
|
||||
selectTool.handlers.down!(pe(50, 50), ctx);
|
||||
expect(selectedIds.has('r1')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Task D3 – Point/XLine/Ray: Konstruktions-Elemente.
|
||||
*
|
||||
* pointTool: Klick-Platzierer (kleiner Kreis mit point:true-Marker,
|
||||
* r=1.5 wie DXF-POINT-Import). xlineTool: 2 Klicks (Basis+Richtung),
|
||||
* sehr lange Linie in BEIDE Richtungen. rayTool: halblang (nur eine
|
||||
* Richtung). Alle mit construction:true und auf defpoints-Layer
|
||||
* (auto-angelegt falls fehlt).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { pointTool, xlineTool, rayTool } from '../src/plugins/builtin/core-drawing';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||
|
||||
function mkCtx() {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
const previews: (CADElement | null)[] = [];
|
||||
const statuses: string[] = [];
|
||||
const ctx: any = {
|
||||
doc,
|
||||
options: {},
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (el: CADElement | null) => previews.push(el),
|
||||
};
|
||||
return { ctx, doc, previews, statuses };
|
||||
}
|
||||
|
||||
function pe(x: number, y: number): { world: Pt } {
|
||||
return { world: { x, y } } as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pointTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
xlineTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
rayTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
describe('D3: pointTool', () => {
|
||||
it('Klick platziert Kreis mit point-Marker (r=1.5, wie DXF POINT)', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
pointTool.handlers.down!(pe(55, 66), ctx);
|
||||
const els = doc.getAllElements();
|
||||
expect(els).toHaveLength(1);
|
||||
const el = els[0];
|
||||
expect(el.type).toBe('circle');
|
||||
expect(el.x).toBe(55);
|
||||
expect(el.y).toBe(66);
|
||||
expect(el.properties.radius).toBe(1.5);
|
||||
expect(el.properties.point).toBe(true);
|
||||
});
|
||||
|
||||
it('defpoints-Layer wird automatisch angelegt falls fehlt', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
expect(doc.getLayer('defpoints')).toBeUndefined();
|
||||
pointTool.handlers.down!(pe(0, 0), ctx);
|
||||
expect(doc.getLayer('defpoints')).toBeDefined();
|
||||
expect(doc.getLayer('defpoints')!.name).toBe('defpoints');
|
||||
});
|
||||
|
||||
it('Element landet auf defpoints-Layer mit construction:true', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
pointTool.handlers.down!(pe(10, 10), ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.layerId).toBe('defpoints');
|
||||
expect(el.properties.construction).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('D3: xlineTool (Konstruktionslinie, beide Richtungen)', () => {
|
||||
it('2 Klicks erzeugen sehr lange Linie durch beide Punkte in BEIDE Richtungen', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
xlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
xlineTool.handlers.down!(pe(10, 10), ctx);
|
||||
const els = doc.getAllElements();
|
||||
expect(els).toHaveLength(1);
|
||||
const el = els[0];
|
||||
expect(el.type).toBe('line');
|
||||
const dx = (el.properties.x2 as number) - (el.properties.x1 as number);
|
||||
const dy = (el.properties.y2 as number) - (el.properties.y1 as number);
|
||||
// Sehr lange: Länge >> Distanz der beiden Klicks (14.14)
|
||||
expect(Math.hypot(dx, dy)).toBeGreaterThan(100000);
|
||||
// Mittelpunkt der Linie = Basispunkt
|
||||
expect((el.properties.x1 as number) + dx / 2).toBeCloseTo(0, 3);
|
||||
expect((el.properties.y1 as number) + dy / 2).toBeCloseTo(0, 3);
|
||||
});
|
||||
|
||||
it('Element auf defpoints-Layer mit construction:true', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
xlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
xlineTool.handlers.down!(pe(5, 0), ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.layerId).toBe('defpoints');
|
||||
expect(el.properties.construction).toBe(true);
|
||||
});
|
||||
|
||||
it('Preview bei move nach erstem Klick', () => {
|
||||
const { ctx, previews } = mkCtx();
|
||||
xlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
xlineTool.handlers.move!(pe(50, 50), ctx);
|
||||
expect(previews.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('Erster Klick ohne zweiten erzeugt NICHTS (Session-State)', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
xlineTool.handlers.down!(pe(0, 0), ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('D3: rayTool (Strahl, nur EINE Richtung)', () => {
|
||||
it('2 Klicks erzeugen Linie ab Basispunkt NUR in Zugrichtung', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
rayTool.handlers.down!(pe(0, 0), ctx);
|
||||
rayTool.handlers.down!(pe(10, 0), ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.type).toBe('line');
|
||||
expect(el.properties.x1).toBe(0);
|
||||
expect(el.properties.y1).toBe(0);
|
||||
// Endpunkt weit in +x-Richtung
|
||||
expect((el.properties.x2 as number)).toBeGreaterThanOrEqual(100000);
|
||||
// NICHT in Gegenrichtung: x1 ist exakt der Basispunkt
|
||||
expect(el.properties.x1).toBe(0);
|
||||
});
|
||||
|
||||
it('construction:true + defpoints-Layer', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
rayTool.handlers.down!(pe(0, 0), ctx);
|
||||
rayTool.handlers.down!(pe(0, 10), ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.layerId).toBe('defpoints');
|
||||
expect(el.properties.construction).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('D3: Manifeste', () => {
|
||||
it('ids/ribbonTab/tags korrekt', () => {
|
||||
expect(pointTool.manifest.id).toBe('point');
|
||||
expect(xlineTool.manifest.id).toBe('xline');
|
||||
expect(rayTool.manifest.id).toBe('ray');
|
||||
for (const t of [pointTool, xlineTool, rayTool]) {
|
||||
expect(t.manifest.ribbonTab).toBe('canvas');
|
||||
expect((t.manifest.tags ?? [])).toContain('basic');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Task CAD-1 - Assoziative Bemaessung: DimStyles + 5 Typen.
|
||||
*
|
||||
* DimStyle: textHeight/arrowSize/unit/decimals/arrowStyle/offset.
|
||||
* computeDimValue: berechnet Masswert aus Geometrie (assoziativ).
|
||||
* Tools: dimLinear (H/V/aligned), dimAngular, dimRadius, dimDiameter.
|
||||
* RenderAdapter: komplette Primitives mit Extension lines, Masslinien,
|
||||
* Pfeilen und Text.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
DEFAULT_DIM_STYLE,
|
||||
DIM_STYLES,
|
||||
getDimStyle,
|
||||
computeDimValue,
|
||||
formatDimValue,
|
||||
type DimStyle,
|
||||
} from '../src/services/dimStyleService';
|
||||
import {
|
||||
dimLinearTool,
|
||||
dimAlignedTool,
|
||||
dimAngularTool,
|
||||
dimRadiusTool,
|
||||
dimDiameterTool,
|
||||
} from '../src/plugins/builtin/core-annotate';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import { elementToPrimitives } from '../src/render/pixi/elementDrawers';
|
||||
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||
|
||||
function mkCtx(options: Record<string, unknown> = {}) {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
const statuses: string[] = [];
|
||||
const previews: (CADElement | null)[] = [];
|
||||
const ctx: any = {
|
||||
doc,
|
||||
options,
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (el: CADElement | null) => previews.push(el),
|
||||
};
|
||||
return { ctx, doc, statuses, previews };
|
||||
}
|
||||
|
||||
function pe(x: number, y: number): { world: Pt } {
|
||||
return { world: { x, y } } as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const t of [dimLinearTool, dimAlignedTool, dimAngularTool, dimRadiusTool, dimDiameterTool]) {
|
||||
t.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── DimStyle-System ─────────────────────────────────────────
|
||||
|
||||
describe('CAD-1: DimStyles', () => {
|
||||
it('DEFAULT_DIM_STYLE ist ISO-konform', () => {
|
||||
expect(DEFAULT_DIM_STYLE.name).toBe('ISO-25');
|
||||
expect(DEFAULT_DIM_STYLE.textHeight).toBe(2.5);
|
||||
expect(DEFAULT_DIM_STYLE.arrowSize).toBe(2.5);
|
||||
expect(DEFAULT_DIM_STYLE.unit).toBe('mm');
|
||||
expect(DEFAULT_DIM_STYLE.decimals).toBe(0);
|
||||
expect(DEFAULT_DIM_STYLE.arrowStyle).toBe('closed');
|
||||
});
|
||||
|
||||
it('DIM_STYLES enthaelt Standard-Set (ISO-25, ISO-50, Architectural)', () => {
|
||||
expect(DIM_STYLES.map((s) => s.name)).toContain('ISO-25');
|
||||
expect(DIM_STYLES.map((s) => s.name)).toContain('ISO-50');
|
||||
expect(DIM_STYLES.map((s) => s.name)).toContain('Architectural');
|
||||
});
|
||||
|
||||
it('getDimStyle liefert Default fuer unbekannten Namen', () => {
|
||||
expect(getDimStyle('gibts-nicht')).toBe(DEFAULT_DIM_STYLE);
|
||||
expect(getDimStyle('ISO-50')!.textHeight).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── computeDimValue: Assoziative Wert-Berechnung ───────────
|
||||
|
||||
describe('CAD-1: computeDimValue', () => {
|
||||
it('linear horizontal: |x2-x1|', () => {
|
||||
expect(computeDimValue('linear', { x: 0, y: 0 }, { x: 100, y: 50 })).toBe(100);
|
||||
});
|
||||
|
||||
it('linear vertikal: |y2-y1|', () => {
|
||||
expect(computeDimValue('linear', { x: 0, y: 0 }, { x: 50, y: 200 })).toBe(200);
|
||||
});
|
||||
|
||||
it('aligned: Distanz zwischen Punkten', () => {
|
||||
expect(computeDimValue('aligned', { x: 0, y: 0 }, { x: 30, y: 40 })).toBe(50);
|
||||
});
|
||||
|
||||
it('radius: r aus Kreis-Element', () => {
|
||||
const circle = { x: 0, y: 0, properties: { radius: 25 } } as unknown as CADElement;
|
||||
expect(computeDimValue('radius', undefined, undefined, circle)).toBe(25);
|
||||
});
|
||||
|
||||
it('diameter: 2*r aus Kreis-Element', () => {
|
||||
const circle = { x: 0, y: 0, properties: { radius: 25 } } as unknown as CADElement;
|
||||
expect(computeDimValue('diameter', undefined, undefined, circle)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatDimValue ──────────────────────────────────────────
|
||||
|
||||
describe('CAD-1: formatDimValue', () => {
|
||||
it('mm ohne Dezimalstellen bei decimals=0', () => {
|
||||
expect(formatDimValue(123.7, DEFAULT_DIM_STYLE)).toBe('124');
|
||||
});
|
||||
|
||||
it('mm mit 2 Dezimalstellen', () => {
|
||||
const s = { ...DEFAULT_DIM_STYLE, decimals: 2 };
|
||||
expect(formatDimValue(123.456, s)).toBe('123.46');
|
||||
});
|
||||
|
||||
it('Radius-Präfix bei radius-Typ', () => {
|
||||
expect(formatDimValue(25, DEFAULT_DIM_STYLE, 'radius')).toBe('R25');
|
||||
});
|
||||
|
||||
it('Durchmesser-Symbol bei diameter-Typ', () => {
|
||||
expect(formatDimValue(50, DEFAULT_DIM_STYLE, 'diameter')).toBe('\u2300 50');
|
||||
});
|
||||
|
||||
it('Grad bei angular', () => {
|
||||
expect(formatDimValue(45, DEFAULT_DIM_STYLE, 'angular')).toBe('45\u00b0');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── dimLinearTool ─────────────────────────────────────────
|
||||
|
||||
describe('CAD-1: dimLinearTool', () => {
|
||||
it('2 Klicks erzeugen dimension mit dimType, berechnetem value und Style', () => {
|
||||
const { ctx, doc } = mkCtx({ dimStyle: 'ISO-25', dimSubtype: 'horizontal' });
|
||||
dimLinearTool.handlers.down!(pe(0, 0), ctx);
|
||||
dimLinearTool.handlers.down!(pe(100, 50), ctx);
|
||||
const els = doc.getAllElements();
|
||||
expect(els).toHaveLength(1);
|
||||
const el = els[0];
|
||||
expect(el.type).toBe('dimension');
|
||||
expect(el.properties.dimType).toBe('linear');
|
||||
expect(el.properties.dimSubtype).toBe('horizontal');
|
||||
expect(el.properties.value).toBe('100'); // |dx| = 100
|
||||
expect(el.properties.dimStyle).toBe('ISO-25');
|
||||
});
|
||||
|
||||
it('vertikal: |dy| wird gemessen', () => {
|
||||
const { ctx, doc } = mkCtx({ dimSubtype: 'vertical' });
|
||||
dimLinearTool.handlers.down!(pe(10, 0), ctx);
|
||||
dimLinearTool.handlers.down!(pe(20, 200), ctx);
|
||||
expect(doc.getAllElements()[0].properties.value).toBe('200');
|
||||
});
|
||||
|
||||
it('horizontal missachtet y-Differenz', () => {
|
||||
const { ctx, doc } = mkCtx({ dimSubtype: 'horizontal' });
|
||||
dimLinearTool.handlers.down!(pe(0, 0), ctx);
|
||||
dimLinearTool.handlers.down!(pe(100, 500), ctx);
|
||||
expect(doc.getAllElements()[0].properties.value).toBe('100');
|
||||
});
|
||||
|
||||
it('Preview zeigt Bemassung waehrend Drag', () => {
|
||||
const { ctx, previews } = mkCtx();
|
||||
dimLinearTool.handlers.down!(pe(0, 0), ctx);
|
||||
dimLinearTool.handlers.move!(pe(80, 30), ctx);
|
||||
expect(previews.length).toBeGreaterThanOrEqual(1);
|
||||
const last = previews[previews.length - 1];
|
||||
expect(last!.type).toBe('dimension');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── dimAlignedTool ────────────────────────────────────────
|
||||
|
||||
describe('CAD-1: dimAlignedTool', () => {
|
||||
it('misst Distanz entlang der Punkte-Verbindung (3-4-5)', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
dimAlignedTool.handlers.down!(pe(0, 0), ctx);
|
||||
dimAlignedTool.handlers.down!(pe(30, 40), ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.properties.dimType).toBe('aligned');
|
||||
expect(el.properties.value).toBe('50');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── dimAngularTool (3 Punkte: Scheitel + 2 Punkte) ───────
|
||||
|
||||
describe('CAD-1: dimAngularTool', () => {
|
||||
it('3 Klicks: 90 Grad zwischen zwei Schenkeln', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
dimAngularTool.handlers.down!(pe(0, 0), ctx); // Scheitel
|
||||
dimAngularTool.handlers.down!(pe(100, 0), ctx); // Schenkel 1
|
||||
dimAngularTool.handlers.down!(pe(0, 100), ctx); // Schenkel 2
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.properties.dimType).toBe('angular');
|
||||
expect(el.properties.value).toBe('90\u00b0');
|
||||
});
|
||||
|
||||
it('45 Grad wird korrekt gemessen', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
dimAngularTool.handlers.down!(pe(0, 0), ctx);
|
||||
dimAngularTool.handlers.down!(pe(100, 0), ctx);
|
||||
dimAngularTool.handlers.down!(pe(70.7, 70.7), ctx); // ~45
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.properties.value).toContain('45');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── dimRadiusTool / dimDiameterTool (Klick auf Kreis) ────
|
||||
|
||||
describe('CAD-1: dimRadiusTool + dimDiameterTool', () => {
|
||||
it('radius: Klick auf Kreis erzeugt R-Wert', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
const circle = {
|
||||
id: 'c1', type: 'circle', layerId: 'l',
|
||||
x: 100, y: 100, width: 50, height: 50,
|
||||
properties: { radius: 25 },
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(circle);
|
||||
// ctx.selection mocken (hitTest liefert den Kreis)
|
||||
ctx.selection = {
|
||||
hitTest: () => circle,
|
||||
getIds: () => [],
|
||||
setIds: () => {},
|
||||
};
|
||||
dimRadiusTool.handlers.down!(pe(100, 100), ctx);
|
||||
const el = doc.getAllElements().find((e) => e.type === 'dimension');
|
||||
expect(el).toBeDefined();
|
||||
expect(el!.properties.dimType).toBe('radius');
|
||||
expect(el!.properties.value).toBe('R25');
|
||||
});
|
||||
|
||||
it('diameter: Klick auf Kreis erzeugt Durchmesser-Wert', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
const circle = {
|
||||
id: 'c1', type: 'circle', layerId: 'l',
|
||||
x: 0, y: 0, width: 50, height: 50,
|
||||
properties: { radius: 25 },
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(circle);
|
||||
ctx.selection = {
|
||||
hitTest: () => circle,
|
||||
getIds: () => [],
|
||||
setIds: () => {},
|
||||
};
|
||||
dimDiameterTool.handlers.down!(pe(0, 0), ctx);
|
||||
const el = doc.getAllElements().find((e) => e.type === 'dimension');
|
||||
expect(el!.properties.dimType).toBe('diameter');
|
||||
expect(el!.properties.value).toContain('50');
|
||||
});
|
||||
|
||||
it('Klick ohne Kreis-Treffer: kein Element, Status erklaert', () => {
|
||||
const { ctx, doc, statuses } = mkCtx();
|
||||
ctx.selection = { hitTest: () => null, getIds: () => [], setIds: () => {} };
|
||||
dimRadiusTool.handlers.down!(pe(0, 0), ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
expect(statuses[statuses.length - 1]).toContain('Kreis');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── RenderAdapter: komplette Bemassungs-Primitives ────────
|
||||
|
||||
describe('CAD-1: RenderAdapter mit Pfeilen', () => {
|
||||
it('dimension erzeugt jetzt MEHR Primitives (Extension+Masslinie+Text+Pfeile)', () => {
|
||||
const el = {
|
||||
id: 'd1', type: 'dimension', layerId: 'l',
|
||||
x: 50, y: -10, width: 100, height: 20,
|
||||
properties: {
|
||||
x1: 0, y1: 0, x2: 100, y2: 0,
|
||||
dimType: 'linear', dimSubtype: 'horizontal',
|
||||
value: '100', dimStyle: 'ISO-25',
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
const prims = elementToPrimitives(el);
|
||||
// Vorher: 2 Primitives (Linie + Text). Jetzt mit Extension lines + Pfeilen: mehr
|
||||
expect(prims.length).toBeGreaterThanOrEqual(5);
|
||||
const lines = prims.filter((p) => p.kind === 'line');
|
||||
const texts = prims.filter((p) => p.kind === 'text');
|
||||
const polys = prims.filter((p) => p.kind === 'polyline');
|
||||
expect(lines.length).toBeGreaterThanOrEqual(3); // Masslinie + 2 Extension lines
|
||||
expect(texts.length).toBe(1); // Wert-Text
|
||||
expect(polys.length).toBeGreaterThanOrEqual(2); // 2 Pfeilspitzen
|
||||
});
|
||||
|
||||
it('Pfeilspitzen sind geschlossene Polygone an den Enden der Masslinie', () => {
|
||||
const el = {
|
||||
id: 'd1', type: 'dimension', layerId: 'l',
|
||||
x: 50, y: -10, width: 100, height: 20,
|
||||
properties: {
|
||||
x1: 0, y1: 0, x2: 100, y2: 0,
|
||||
dimType: 'linear', value: '100',
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
const prims = elementToPrimitives(el);
|
||||
const arrows = prims.filter(
|
||||
(p) => p.kind === 'polyline' && (p as { close?: boolean }).close === true,
|
||||
);
|
||||
expect(arrows.length).toBe(2);
|
||||
});
|
||||
|
||||
it('radius-dimension rendert Linie vom Zentrum nach aussen + Pfeil', () => {
|
||||
const el = {
|
||||
id: 'd2', type: 'dimension', layerId: 'l',
|
||||
x: 100, y: 100, width: 0, height: 0,
|
||||
properties: {
|
||||
dimType: 'radius', value: 'R25',
|
||||
centerX: 100, centerY: 100,
|
||||
arrowX: 120, arrowY: 100,
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
const prims = elementToPrimitives(el);
|
||||
expect(prims.length).toBeGreaterThanOrEqual(2);
|
||||
expect(prims.some((p) => p.kind === 'line')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,9 @@ import type { ZoomPanController } from '../src/canvas/ZoomPanController';
|
||||
function makeFakeZoomPan(): ZoomPanController {
|
||||
return {
|
||||
screenToWorld: (sx: number, sy: number) => ({ x: sx * 2, y: sy * 3 }),
|
||||
// CAD-14 Phase 4c: natives Pan/Wheel im Dispatcher (spies für Assertions)
|
||||
pan: vi.fn(),
|
||||
zoomAt: vi.fn(),
|
||||
} as unknown as ZoomPanController;
|
||||
}
|
||||
|
||||
@@ -60,11 +63,12 @@ describe('InteractionDispatcher', () => {
|
||||
function setup(tool?: ToolExtensionV2) {
|
||||
canvas = document.createElement('canvas');
|
||||
document.body.appendChild(canvas);
|
||||
const zoomPan = makeFakeZoomPan();
|
||||
const dispatcher = new InteractionDispatcher(canvas, {
|
||||
zoomPan: makeFakeZoomPan(),
|
||||
zoomPan,
|
||||
toolLookup: tool ? () => tool : undefined,
|
||||
});
|
||||
return { dispatcher };
|
||||
return { dispatcher, zoomPan };
|
||||
}
|
||||
|
||||
it('routet down→move→up in Reihenfolge an das aktive Tool', () => {
|
||||
@@ -103,13 +107,17 @@ describe('InteractionDispatcher', () => {
|
||||
expect(log).toEqual(['down', 'cancel']);
|
||||
});
|
||||
|
||||
it('Rechtsklick bricht ab, ohne down durchzureichen', () => {
|
||||
it('Rechtsklick startet natives Pan (kein Tool-Event)', () => {
|
||||
const { tool, log } = makeRecordingTool();
|
||||
const { dispatcher } = setup(tool);
|
||||
const { dispatcher, zoomPan } = setup(tool);
|
||||
dispatcher.setActive('fake');
|
||||
|
||||
fire(canvas, 'pointerdown', { button: 2 });
|
||||
expect(log).toEqual(['cancel']);
|
||||
fire(window, 'pointermove', { clientX: 120, clientY: 60 });
|
||||
fire(window, 'pointerup', { button: 2, clientX: 120, clientY: 60 });
|
||||
// CAD-14 Phase 4c: Rechtsklick = natives Pan, Tool bleibt unberührt
|
||||
expect(log).toEqual([]);
|
||||
expect(zoomPan.pan).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setActive(null) deaktiviert Routing komplett', () => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* CAD-14 Phase 4c: Dispatcher-native Pan/Wheel/Cursor/Snap.
|
||||
* Ersetzt die Legacy-InteractionEngine vollstaendig:
|
||||
* - Snap/Ortho werden auf Tool-Pointer-Events angewandt (falls Deps gesetzt)
|
||||
* - Pan bei Mittel-/Rechtsklick-Drag (auch ohne aktives Tool)
|
||||
* - Wheel-Zoom am Cursor
|
||||
* - Cursor-Meldung bei jedem Move (auch ohne Tool)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { InteractionDispatcher } from '../src/interaction/dispatcher';
|
||||
import type { ToolExtensionV2 } from '../src/plugins/types';
|
||||
|
||||
function makeCanvas(): HTMLCanvasElement {
|
||||
const c = document.createElement('canvas');
|
||||
c.getBoundingClientRect = () => ({ left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600, x: 0, y: 0, toJSON: () => ({}) } as DOMRect);
|
||||
return c;
|
||||
}
|
||||
|
||||
const zoomPan = {
|
||||
screenToWorld: (sx: number, sy: number) => ({ x: sx, y: sy }),
|
||||
pan: vi.fn(),
|
||||
zoomAt: vi.fn(),
|
||||
};
|
||||
|
||||
function lineTool(): ToolExtensionV2 {
|
||||
return {
|
||||
manifest: { id: 'line', label: 'Linie', icon: 'L', ribbonTab: 'canvas', tags: ['basic'] },
|
||||
optionsSchema: [],
|
||||
handlers: {
|
||||
down: vi.fn(),
|
||||
move: vi.fn(),
|
||||
up: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fireMouse(canvas: HTMLCanvasElement, type: string, opts: { button?: number; x?: number; y?: number } = {}) {
|
||||
const e = new MouseEvent(type, { button: opts.button ?? 0, clientX: opts.x ?? 0, clientY: opts.y ?? 0, bubbles: true });
|
||||
canvas.dispatchEvent(e);
|
||||
}
|
||||
|
||||
describe('CAD-14 Phase 4c: dispatcher native pan/wheel/cursor', () => {
|
||||
let canvas: HTMLCanvasElement;
|
||||
let onCursor: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
canvas = makeCanvas();
|
||||
onCursor = vi.fn();
|
||||
});
|
||||
|
||||
it('Pan: Mittelklick-Drag verschiebt via zoomPan.pan (auch ohne Tool)', () => {
|
||||
const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never, onCursorMoved: onCursor });
|
||||
canvas.dispatchEvent(new MouseEvent('pointerdown', { button: 1, clientX: 100, clientY: 100 }));
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 140, clientY: 120 }));
|
||||
window.dispatchEvent(new MouseEvent('pointerup', { button: 1, clientX: 140, clientY: 120 }));
|
||||
expect(zoomPan.pan).toHaveBeenCalled();
|
||||
d.destroy();
|
||||
});
|
||||
|
||||
it('Wheel: zoomAt am Cursor mit Faktor', () => {
|
||||
const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never });
|
||||
canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, clientX: 300, clientY: 200, cancelable: true }));
|
||||
expect(zoomPan.zoomAt).toHaveBeenCalled();
|
||||
d.destroy();
|
||||
});
|
||||
|
||||
it('Cursor-Meldung: onCursorMoved feuert bei pointermove ohne aktives Tool', () => {
|
||||
const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never, onCursorMoved: onCursor });
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 50, clientY: 60 }));
|
||||
expect(onCursor).toHaveBeenCalledWith(50, 60);
|
||||
d.destroy();
|
||||
});
|
||||
|
||||
it('Snap: down/up Positionen werden gesnappt (snap-Deps)', () => {
|
||||
const snap = vi.fn((x: number, y: number) => ({ x: Math.round(x / 10) * 10, y: Math.round(y / 10) * 10 }));
|
||||
const tool = lineTool();
|
||||
const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never, snap, toolLookup: () => tool } as never);
|
||||
d.setActive('line');
|
||||
expect(d.getActive()).toBe(tool);
|
||||
canvas.dispatchEvent(new MouseEvent('pointerdown', { button: 0, clientX: 12, clientY: 12 }));
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 35, clientY: 35 }));
|
||||
window.dispatchEvent(new MouseEvent('pointerup', { button: 0, clientX: 35, clientY: 35 }));
|
||||
expect(tool.handlers.down).toHaveBeenCalled();
|
||||
expect((tool.handlers.down as any).mock.calls[0][0].world).toEqual({ x: 10, y: 10 });
|
||||
expect((tool.handlers.up as any).mock.calls[0][0].world).toEqual({ x: 40, y: 40 });
|
||||
d.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Task F5 – DXF-Parser-Erweiterungstests.
|
||||
*
|
||||
* Neue Entity-Abdeckung: ELLIPSE (als Polyline-Approx), SPLINE (grad<=3 →
|
||||
* Polyline via de-Boor), MTEXT (Formatcodes gecleanup), POINT, ATTDEF,
|
||||
* INSERT mit MINSERT-Arrays und HATCH (forward-kompatibel_via Fake-Entity).
|
||||
* Plus strukturierter Fehlerreport (skippedEntities) neben warnings.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseDXF, entityToCADElement, expandInsert } from '../src/services/dxfParser';
|
||||
import type { CADLayer } from '../src/types/cad.types';
|
||||
|
||||
const layer0: CADLayer = {
|
||||
id: 'dxf-layer-0', name: '0', visible: true, locked: false,
|
||||
color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
|
||||
};
|
||||
const layers = new Map([['0', layer0]]);
|
||||
|
||||
function dxfOf(entityBody: string): string {
|
||||
return `0\nSECTION\n2\nENTITIES\n${entityBody}0\nENDSEC\n0\nEOF\n`;
|
||||
}
|
||||
|
||||
function ptsOf(el: { properties: { points?: Array<{ x: number; y: number }> } }): Array<{ x: number; y: number }> {
|
||||
return el.properties.points ?? [];
|
||||
}
|
||||
|
||||
describe('F5: ELLIPSE', () => {
|
||||
it('volle Ellipse → geschlossene Polyline mit 64 Punkten und korrekter BBox', () => {
|
||||
const el = entityToCADElement({
|
||||
type: 'ELLIPSE', layer: '0',
|
||||
center: { x: 0, y: 0 },
|
||||
majorAxisEndPoint: { x: 100, y: 0 },
|
||||
axisRatio: 0.5,
|
||||
startAngle: 0, endAngle: Math.PI * 2,
|
||||
} as any, layers);
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.type).toBe('polyline');
|
||||
const pts = ptsOf(el!);
|
||||
expect(pts.length).toBe(64);
|
||||
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
|
||||
expect(Math.max(...xs)).toBeCloseTo(100, 0);
|
||||
expect(Math.min(...xs)).toBeCloseTo(-100, 0);
|
||||
expect(Math.max(...ys)).toBeCloseTo(50, 0);
|
||||
expect(Math.min(...ys)).toBeCloseTo(-50, 0);
|
||||
});
|
||||
|
||||
it('rotierte Ellipse respektiert majorAxisEndPoint-Winkel', () => {
|
||||
const el = entityToCADElement({
|
||||
type: 'ELLIPSE', layer: '0',
|
||||
center: { x: 0, y: 0 },
|
||||
majorAxisEndPoint: { x: 0, y: 100 }, // 90° rotiert
|
||||
axisRatio: 0.5,
|
||||
startAngle: 0, endAngle: Math.PI * 2,
|
||||
} as any, layers);
|
||||
const pts = ptsOf(el!);
|
||||
expect(Math.max(...pts.map((p) => p.y))).toBeCloseTo(100, 0);
|
||||
expect(Math.max(...pts.map((p) => p.x))).toBeCloseTo(50, 0);
|
||||
});
|
||||
|
||||
it('partielle Ellipse → offene Polyline', () => {
|
||||
const el = entityToCADElement({
|
||||
type: 'ELLIPSE', layer: '0',
|
||||
center: { x: 0, y: 0 },
|
||||
majorAxisEndPoint: { x: 100, y: 0 },
|
||||
axisRatio: 1,
|
||||
startAngle: 0, endAngle: Math.PI,
|
||||
} as any, layers);
|
||||
const pts = ptsOf(el!);
|
||||
expect(pts.length).toBe(32);
|
||||
});
|
||||
|
||||
it('Integration über parseDXF mit echtem ELLIPSE-DXF-String', () => {
|
||||
const res = parseDXF(dxfOf(
|
||||
'0\nELLIPSE\n8\n0\n10\n0\n20\n0\n30\n0\n11\n100\n21\n0\n31\n0\n41\n0.5\n42\n0\n51\n6.283185307179586\n',
|
||||
));
|
||||
expect(res.skippedEntities).toHaveLength(0);
|
||||
const el = res.elements.find((e) => e.type === 'polyline');
|
||||
expect(el).toBeDefined();
|
||||
expect(ptsOf(el as any).length).toBeGreaterThan(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('F5: SPLINE', () => {
|
||||
const controlPoints = [
|
||||
{ x: 0, y: 0 }, { x: 33, y: 200 }, { x: 66, y: -100 }, { x: 100, y: 0 },
|
||||
];
|
||||
|
||||
it('Grad 3 → Polyline-Approx mit mehr Punkten als Kontrollpunkte', () => {
|
||||
const el = entityToCADElement({
|
||||
type: 'SPLINE', layer: '0',
|
||||
degreeOfSplineCurve: 3, controlPoints, fitPoints: [],
|
||||
} as any, layers);
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.type).toBe('polyline');
|
||||
const pts = ptsOf(el!);
|
||||
expect(pts.length).toBeGreaterThan(16);
|
||||
// Approximation startet/endet bei exakten Kontrollpunkten
|
||||
expect(pts[0].x).toBeCloseTo(0, 3);
|
||||
expect(pts[0].y).toBeCloseTo(0, 3);
|
||||
expect(pts[pts.length - 1].x).toBeCloseTo(100, 3);
|
||||
});
|
||||
|
||||
it('Grad 4 → skipped mit Grund', () => {
|
||||
const el = entityToCADElement({
|
||||
type: 'SPLINE', layer: '0',
|
||||
degreeOfSplineCurve: 4, controlPoints, fitPoints: [],
|
||||
} as any, layers);
|
||||
expect(el).toBeNull();
|
||||
});
|
||||
|
||||
it('ohne Kontrollpunkte → skipped', () => {
|
||||
const el = entityToCADElement({ type: 'SPLINE', layer: '0', degreeOfSplineCurve: 3 } as any, layers);
|
||||
expect(el).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('F5: MTEXT', () => {
|
||||
it('mappt auf text-Element, \\P → Zeilenumbruch, Formatcodes entfernt', () => {
|
||||
const el = entityToCADElement({
|
||||
type: 'MTEXT', layer: '0',
|
||||
position: { x: 10, y: 20 },
|
||||
height: 15, width: 200,
|
||||
text: 'Zeile1\\PZeile2\\A1;\\H2x;Titel',
|
||||
} as any, layers);
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.type).toBe('text');
|
||||
expect(el!.properties.text).toBe('Zeile1\nZeile2Titel');
|
||||
expect(el!.properties.fontSize).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('F5: POINT', () => {
|
||||
it('mappt auf kleinen gefüllten Kreis mit point-Marker', () => {
|
||||
const el = entityToCADElement({
|
||||
type: 'POINT', layer: '0',
|
||||
position: { x: 55, y: 66 },
|
||||
} as any, layers);
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.type).toBe('circle');
|
||||
expect(el!.x).toBeCloseTo(55 - 1.5, 5);
|
||||
expect(el!.properties.radius).toBeCloseTo(1.5, 5);
|
||||
expect(el!.properties.point).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('F5: ATTDEF', () => {
|
||||
it('mappt auf text-Element mit tag=Wert und Metadaten', () => {
|
||||
const el = entityToCADElement({
|
||||
type: 'ATTDEF', layer: '0',
|
||||
tag: 'NAME', text: 'Buehne', prompt: 'Name der Buehne',
|
||||
startPoint: { x: 5, y: 5 }, textHeight: 12,
|
||||
} as any, layers);
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.type).toBe('text');
|
||||
expect(el!.properties.text).toBe('NAME=Buehne');
|
||||
expect(el!.properties.fontSize).toBe(12);
|
||||
expect(el!.properties.tag).toBe('NAME');
|
||||
expect(el!.properties.prompt).toBe('Name der Buehne');
|
||||
});
|
||||
|
||||
it('leerer ATTDEF-Text ohne Tag → skipped', () => {
|
||||
const el = entityToCADElement({ type: 'ATTDEF', layer: '0', startPoint: { x: 0, y: 0 } } as any, layers);
|
||||
expect(el).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('F5: INSERT (MINSERT-Arrays)', () => {
|
||||
it('columnCount/rowCount > 1 erzeugt n-fache Instanzen mit Spacing-Offset', () => {
|
||||
const els = [] as any[];
|
||||
// Der Parser-Einstieg ist parseDXF; INSERT-Expansion passiert dort je Entity — hier via Helper
|
||||
const base = {
|
||||
type: 'INSERT', layer: '0', name: 'stuhl',
|
||||
position: { x: 0, y: 0 }, xScale: 2, yScale: 3, rotation: 0,
|
||||
columnCount: 3, rowCount: 2, columnSpacing: 100, rowSpacing: 50,
|
||||
} as any;
|
||||
const out = expandInsert(base);
|
||||
expect(out).toHaveLength(6);
|
||||
const uniqXs = [...new Set(out.map((e) => e.x))].sort((a, b) => a - b);
|
||||
expect(uniqXs).toEqual([0, 100, 200]);
|
||||
const ys = [...new Set(out.map((e) => e.y))].sort((a, b) => a - b);
|
||||
expect(ys).toEqual([0, 50]);
|
||||
expect(out.every((e) => e.properties.scaleX === 2 && e.properties.scaleY === 3)).toBe(true);
|
||||
});
|
||||
|
||||
it('einfacher INSERT bleibt einzelne Instanz mit kombiniertem scale', () => {
|
||||
const out = expandInsert({
|
||||
type: 'INSERT', layer: '0', name: 'b',
|
||||
position: { x: 5, y: 5 }, xScale: 1, yScale: 1, rotation: 90,
|
||||
} as any);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].properties.blockId).toBe('b');
|
||||
expect(out[0].properties.rotation).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('F5: HATCH (forward-kompatibel_via Entity-Injektion)', () => {
|
||||
it('HATCH solid → polygon mit hatchPattern', () => {
|
||||
const el = entityToCADElement({
|
||||
type: 'HATCH', layer: '0',
|
||||
patternName: 'SOLID', isSolid: true,
|
||||
boundaryPoints: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }],
|
||||
} as any, layers);
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.type).toBe('polygon');
|
||||
expect(el!.properties.hatchPattern).toBe('SOLID');
|
||||
expect(ptsOf(el as any)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('F5: Fehlerreport (skippedEntities)', () => {
|
||||
// Hinweis: dxf-parser 1.1.x verschluckt voellig unbekannte Typen (FOOBAR) schlicht;
|
||||
// valide SPLINE-Entities mit Grad > 3 kommen dagegen durch und werden vom
|
||||
// Mapper mit Grund uebersprungen — genau dafuer ist der Report da.
|
||||
const DEG4_SPLINE = '0\nSPLINE\n8\n0\n71\n4\n11\n0\n21\n0\n11\n10\n21\n0\n11\n20\n21\n0\n11\n30\n21\n0\n';
|
||||
|
||||
it('unbrauchbare Entities (SPLINE Grad 4) landen im Report', () => {
|
||||
const res = parseDXF(dxfOf(DEG4_SPLINE));
|
||||
const types = res.skippedEntities.map((s) => s.type);
|
||||
expect(types).toContain('SPLINE');
|
||||
expect(res.warnings.length).toBeGreaterThan(0);
|
||||
expect(res.skippedEntities[0].reason).toContain('> 3');
|
||||
});
|
||||
|
||||
it('Result enthält skippedEntities-Feld mit type+reason (Design-Vertrag)', () => {
|
||||
const res = parseDXF(dxfOf(DEG4_SPLINE));
|
||||
expect(res.skippedEntities).toHaveLength(1);
|
||||
expect(res.skippedEntities[0].type).toBe('SPLINE');
|
||||
expect(typeof res.skippedEntities[0].reason).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Task F6 – DXF-Writer-Erweiterungstests.
|
||||
*
|
||||
* Deckt ab: BLOCKS+INSERTS mit Name-Mapping/Sanitizing, ATTRIB/SEQEND,
|
||||
* Versionsoption AC1009/AC1015, R12-korrektes POLYLINE statt LWPOLYLINE,
|
||||
* Center-Konvention (circle/arc el.x/el.y = Zentrum) und Grad-Winkel ohne
|
||||
* Doppelkonvertierung — abgesichert über echte write→parse-Roundtrips.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { writeDXF } from '../src/services/dxfWriter';
|
||||
import { parseDXF } from '../src/services/dxfParser';
|
||||
import type { CADElement, CADLayer, ProjectData, BlockDefinition } from '../src/types/cad.types';
|
||||
|
||||
const layer: CADLayer = {
|
||||
id: 'layer-1', name: 'L1', visible: true, locked: false,
|
||||
color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
|
||||
};
|
||||
|
||||
function mkEl(over: Partial<CADElement> = {}): CADElement {
|
||||
return {
|
||||
id: 'e1', type: 'line', layerId: 'layer-1',
|
||||
x: 0, y: 0, width: 0, height: 0, properties: {},
|
||||
...over,
|
||||
} as CADElement;
|
||||
}
|
||||
|
||||
function mkBlock(over: Partial<BlockDefinition> = {}): BlockDefinition {
|
||||
return {
|
||||
id: 'blk-1', name: 'Stuhl Reihe', description: '', category: 'event',
|
||||
elements: [mkEl({ type: 'line', properties: { x1: 0, y1: 0, x2: 100, y2: 0 } })],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function mkProj(elements: CADElement[], blocks: BlockDefinition[] = []): ProjectData {
|
||||
return { version: '1', name: 'T', layers: [layer], elements, blocks } as ProjectData;
|
||||
}
|
||||
|
||||
describe('F6: Version', () => {
|
||||
it('Default ist AC1009 (R12)', () => {
|
||||
expect(writeDXF(mkProj([]))).toContain('AC1009');
|
||||
});
|
||||
it('Option AC1015 setzt Header-Version', () => {
|
||||
expect(writeDXF(mkProj([]), { version: 'AC1015' })).toContain('AC1015');
|
||||
});
|
||||
});
|
||||
|
||||
describe('F6: BLOCKS + INSERTS', () => {
|
||||
it('BLOCKS-Sektion emittiert sanitisierten Blocknamen, INSERT referenziert gemappten Namen', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({ type: 'block_instance', properties: { blockId: 'blk-1' } }),
|
||||
], [mkBlock()]));
|
||||
expect(out).toContain('BLOCKS');
|
||||
expect(out).toContain('BLOCK');
|
||||
expect(out).toContain('ENDBLK');
|
||||
// Name 'Stuhl Reihe' → 'Stuhl_Reihe' (Leerzeichen sanitizing)
|
||||
expect(out.split('Stuhl_Reihe').length).toBeGreaterThanOrEqual(3); // BLOCK 2/3 + INSERT 2
|
||||
expect(out).toContain('INSERT');
|
||||
});
|
||||
|
||||
it('block_instance ohne Definition bleibt BBox-Fallback (kein INSERT)', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({ type: 'block_instance', x: 0, y: 0, width: 100, height: 50, properties: { blockId: 'unknown-block' } }),
|
||||
]));
|
||||
expect(out).not.toContain('INSERT');
|
||||
expect(out).toContain('POLYLINE');
|
||||
});
|
||||
|
||||
it('Roundtrip: INSERT landet als block_instance mit gemapptem Namen', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({ type: 'block_instance', x: 10, y: 20, properties: { blockId: 'blk-1' } }),
|
||||
], [mkBlock()]));
|
||||
const res = parseDXF(out);
|
||||
const inst = res.elements.find((e) => e.type === 'block_instance');
|
||||
expect(inst).toBeDefined();
|
||||
expect(inst!.properties.blockId).toBe('Stuhl_Reihe');
|
||||
expect(inst!.x).toBe(10);
|
||||
expect(inst!.y).toBe(20);
|
||||
});
|
||||
|
||||
it('ATTDEF aus Blockelement (attdef:true + tag) wird in der BLOCK-Definition emittiert', () => {
|
||||
const blk = mkBlock({
|
||||
elements: [
|
||||
mkEl({ type: 'line', properties: { x1: 0, y1: 0, x2: 50, y2: 0 } }),
|
||||
mkEl({ type: 'text', x: 5, y: 5, properties: { text: 'Buehne', tag: 'NAME', attdef: true, fontSize: 12 } }),
|
||||
],
|
||||
});
|
||||
const out = writeDXF(mkProj([], [blk]));
|
||||
expect(out).toContain('ATTDEF');
|
||||
expect(out).toContain('NAME');
|
||||
});
|
||||
|
||||
it('Instanz mit attrValues emittiert 66/ATTRIB/SEQEND', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({
|
||||
type: 'block_instance', x: 0, y: 0,
|
||||
properties: { blockId: 'blk-1', attrValues: { NAME: 'Halle A' } },
|
||||
}),
|
||||
], [mkBlock()]));
|
||||
expect(out).toContain('ATTRIB');
|
||||
expect(out).toContain('SEQEND');
|
||||
expect(out).toContain('Halle A');
|
||||
expect(out).toContain('66');
|
||||
});
|
||||
});
|
||||
|
||||
describe('F6: R12-korrektes POLYLINE', () => {
|
||||
it('polygon → POLYLINE+VERTEX+SEQEND, kein LWPOLYLINE', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({
|
||||
type: 'polygon',
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }] },
|
||||
}),
|
||||
]));
|
||||
expect(out).toContain('POLYLINE');
|
||||
expect(out).toContain('VERTEX');
|
||||
expect(out).toContain('SEQEND');
|
||||
expect(out).not.toContain('LWPOLYLINE');
|
||||
});
|
||||
|
||||
it('Roundtrip: polygon bleibt geschlossen (shape-Flag 70=1)', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({
|
||||
type: 'polygon',
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }] },
|
||||
}),
|
||||
]));
|
||||
const res = parseDXF(out);
|
||||
const poly = res.elements.find((e) => e.type === 'polygon');
|
||||
expect(poly).toBeDefined();
|
||||
expect(res.elements.find((e) => e.type === 'polyline')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('F6: Center- & Winkel-Konvention', () => {
|
||||
it('Writer: circle Zentrum el.x/el.y direkt (kein +width/2)', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({ type: 'circle', x: 100, y: 50, width: 60, height: 60, properties: { radius: 30 } }),
|
||||
]));
|
||||
const res = parseDXF(out);
|
||||
const circ = res.elements.find((e) => e.type === 'circle');
|
||||
expect(circ).toBeDefined();
|
||||
expect(circ!.x).toBeCloseTo(100, 5);
|
||||
expect(circ!.y).toBeCloseTo(50, 5);
|
||||
expect(circ!.properties.radius).toBeCloseTo(30, 5);
|
||||
});
|
||||
|
||||
it('Parser: CIRCLE center landet direkt auf el.x/el.y (Renderer-Konvention)', () => {
|
||||
const dxf = [
|
||||
'0', 'SECTION', '2', 'ENTITIES',
|
||||
'0', 'CIRCLE', '8', '0', '10', '10', '20', '20', '30', '0', '40', '5',
|
||||
'0', 'ENDSEC', '0', 'EOF',
|
||||
].join(String.fromCharCode(10));
|
||||
const res = parseDXF(dxf);
|
||||
const circ = res.elements.find((e) => e.type === 'circle');
|
||||
expect(circ).toBeDefined();
|
||||
expect(circ!.x).toBe(10);
|
||||
expect(circ!.y).toBe(20);
|
||||
expect(circ!.properties.radius).toBe(5);
|
||||
expect(circ!.width).toBe(10);
|
||||
});
|
||||
|
||||
it('arc/rotation Winkel in Grad ohne Doppelkonvertierung', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({ type: 'arc', x: 0, y: 0, width: 20, height: 20, properties: { radius: 10, startAngle: 30, endAngle: 120 } }),
|
||||
mkEl({ type: 'block_instance', x: 0, y: 0, properties: { blockId: 'blk-1', rotation: 45 } }),
|
||||
], [mkBlock()]));
|
||||
expect(out).toContain('50' + String.fromCharCode(10) + '30');
|
||||
expect(out).toContain('51' + String.fromCharCode(10) + '120');
|
||||
expect(out).toContain('50' + String.fromCharCode(10) + '45');
|
||||
const res = parseDXF(out);
|
||||
const arc = res.elements.find((e) => e.type === 'arc');
|
||||
expect(arc!.properties.startAngle).toBeCloseTo(30, 5);
|
||||
});
|
||||
|
||||
it('rect als geschlossene POLYLINE mit Ecken aus Zentrumskonvention', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({ type: 'rect', x: 0, y: 0, width: 100, height: 50, properties: {} }),
|
||||
]));
|
||||
const res = parseDXF(out);
|
||||
const poly = res.elements[0];
|
||||
const pts = (poly!.properties.points ?? []) as Array<{ x: number; y: number }>;
|
||||
expect(pts.length).toBe(4);
|
||||
expect(pts[0].x).toBeCloseTo(-50, 5);
|
||||
expect(pts[0].y).toBeCloseTo(-25, 5);
|
||||
});
|
||||
});
|
||||
@@ -113,14 +113,16 @@ describe('elementToPrimitives', () => {
|
||||
expect(elementToPrimitives(el('x', 'definitely-unknown'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('dimension: Linie + Label aus value/text', () => {
|
||||
it('dimension: komplette Bemassung (Extension+Masslinie+Pfeile+Text, CAD-1)', () => {
|
||||
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');
|
||||
// CAD-1: 2 Extension lines + 1 Masslinie + 2 Pfeilspitzen + 1 Text = 6
|
||||
expect(prims.length).toBe(6);
|
||||
expect(prims.filter((p) => p.kind === 'line').length).toBe(3);
|
||||
expect(prims.filter((p) => p.kind === 'polyline').length).toBe(2);
|
||||
const txt = prims.find((p) => p.kind === 'text');
|
||||
if (!txt || txt.kind !== 'text') throw new Error('erwartete text');
|
||||
expect(txt.text).toBe('250 cm');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Task D1 – Ellipse+Bogen-Tool (core-drawing).
|
||||
*
|
||||
* 3-Klick-Interaktion: Klick1=Zentrum, Klick2=RadiusX (Richtung
|
||||
* definiert die Rotation), Klick3=RadiusY. Commit als geschlossene
|
||||
* Polyline (64 Punkte, F5-Parser-Konvention) mit cx/cy/rx/ry/rotation
|
||||
* Metadaten. Ellipsenbogen via startAngle/endAngle-Options → offene
|
||||
* Polyline mit proportionalen Punkten.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ellipseTool } from '../src/plugins/builtin/core-drawing';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||
|
||||
function mkCtx(options: Record<string, unknown> = {}) {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
const previews: (CADElement | null)[] = [];
|
||||
const statuses: string[] = [];
|
||||
const ctx: any = {
|
||||
doc,
|
||||
options,
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (el: CADElement | null) => previews.push(el),
|
||||
};
|
||||
return { ctx, doc, previews, statuses };
|
||||
}
|
||||
|
||||
function pe(x: number, y: number): { world: Pt } {
|
||||
return { world: { x, y } } as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
ellipseTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
describe('D1: ellipseTool', () => {
|
||||
it('3 Klicks: Zentrum→RadiusX→RadiusY erzeugt geschlossene Ellipse (64 Punkte)', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||
ellipseTool.handlers.down!(pe(100, 0), ctx); // RadiusX=100, Rotation=0
|
||||
ellipseTool.handlers.down!(pe(0, 50), ctx); // RadiusY=50
|
||||
const els = doc.getAllElements();
|
||||
expect(els).toHaveLength(1);
|
||||
const el = els[0];
|
||||
expect(el.type).toBe('polygon');
|
||||
const pts = el.properties.points as Pt[];
|
||||
expect(pts.length).toBe(64);
|
||||
// BBox: rx=100, ry=50
|
||||
const xs = pts.map((p) => p.x);
|
||||
const ys = pts.map((p) => p.y);
|
||||
expect(Math.max(...xs)).toBeCloseTo(100, 0);
|
||||
expect(Math.min(...xs)).toBeCloseTo(-100, 0);
|
||||
expect(Math.max(...ys)).toBeCloseTo(50, 0);
|
||||
expect(Math.min(...ys)).toBeCloseTo(-50, 0);
|
||||
// Metadaten
|
||||
expect(el.properties.cx).toBe(0);
|
||||
expect(el.properties.cy).toBe(0);
|
||||
expect(el.properties.rx).toBe(100);
|
||||
expect(el.properties.ry).toBe(50);
|
||||
expect(el.properties.rotation).toBe(0);
|
||||
});
|
||||
|
||||
it('Zugrichtung definiert Rotation: Klick2 nach (0,100) → rotation=90°', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||
ellipseTool.handlers.down!(pe(0, 100), ctx); // RadiusX zeigt nach unten
|
||||
ellipseTool.handlers.down!(pe(50, 0), ctx); // RadiusY
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.properties.rotation).toBeCloseTo(90, 1);
|
||||
// Rotierte Ellipse: Hauptachse vertikal → maxY ≈ 100
|
||||
const ys = (el.properties.points as Pt[]).map((p) => p.y);
|
||||
expect(Math.max(...ys)).toBeCloseTo(100, 0);
|
||||
});
|
||||
|
||||
it('Preview bei move nach Phase 2 zeigt Ellipse', () => {
|
||||
const { ctx, previews } = mkCtx();
|
||||
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||
ellipseTool.handlers.move!(pe(80, 0), ctx); // RadiusX-Linie
|
||||
ellipseTool.handlers.down!(pe(80, 0), ctx); // RadiusX fixiert
|
||||
ellipseTool.handlers.move!(pe(0, 40), ctx); // Ellipsen-Preview mit live ry
|
||||
expect(previews.length).toBeGreaterThanOrEqual(2);
|
||||
const last = previews[previews.length - 1];
|
||||
expect(last).not.toBeNull();
|
||||
expect(last!.type).toBe('polygon');
|
||||
});
|
||||
|
||||
it('Status führt durch die Phasen', () => {
|
||||
const { ctx, statuses } = mkCtx();
|
||||
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||
expect(statuses[0]).toContain('Zentrum');
|
||||
ellipseTool.handlers.down!(pe(100, 0), ctx);
|
||||
expect(statuses[1]).toContain('Radius');
|
||||
});
|
||||
|
||||
it('cancel resettet die Messung', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||
ellipseTool.handlers.down!(pe(100, 0), ctx);
|
||||
ellipseTool.handlers.cancel?.(ctx);
|
||||
// Nach cancel startet frisch: nächster Klick = Zentrum (kein Commit)
|
||||
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('D1: Ellipsenbogen (startAngle/endAngle)', () => {
|
||||
it('Options startAngle=0/endAngle=180 erzeugen OFFENE Polyline (32 Punkte)', () => {
|
||||
const { ctx, doc } = mkCtx({ startAngle: 0, endAngle: 180 });
|
||||
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||
ellipseTool.handlers.down!(pe(100, 0), ctx);
|
||||
ellipseTool.handlers.down!(pe(0, 50), ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.type).toBe('polyline'); // offen
|
||||
const pts = el.properties.points as Pt[];
|
||||
expect(pts.length).toBe(32); // halbe Ellipse = 64/2
|
||||
});
|
||||
|
||||
it('Winkel-Metadaten in properties', () => {
|
||||
const { ctx, doc } = mkCtx({ startAngle: 45, endAngle: 225 });
|
||||
ellipseTool.handlers.down!(pe(0, 0), ctx);
|
||||
ellipseTool.handlers.down!(pe(100, 0), ctx);
|
||||
ellipseTool.handlers.down!(pe(0, 50), ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.properties.startAngle).toBe(45);
|
||||
expect(el.properties.endAngle).toBe(225);
|
||||
});
|
||||
});
|
||||
|
||||
describe('D1: Manifest', () => {
|
||||
it('id=ellipse, ribbonTab=canvas, tags basic, Version in tools-Array', () => {
|
||||
expect(ellipseTool.manifest.id).toBe('ellipse');
|
||||
expect(ellipseTool.manifest.ribbonTab).toBe('canvas');
|
||||
expect((ellipseTool.manifest.tags ?? [])).toContain('basic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Task E7 – Event-Elemente-Polish: curtain, spotlight, barrier,
|
||||
* stage-height-Attribut.
|
||||
*
|
||||
* Prüft die Tool-Platzierung (Element-Form + Options), die
|
||||
* Pixi-Drawer-Primitives und die height-Durchreichung für stage.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
curtainTool, spotlightTool, barrierTool,
|
||||
} from '../src/plugins/builtin/event-tools';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { elementToPrimitives } from '../src/render/pixi/elementDrawers';
|
||||
import { SeatingService } from '../src/services/seatingService';
|
||||
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||
|
||||
function fireDown(tool: any, world: Pt, options: Record<string, unknown> = {}) {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
const ctx = {
|
||||
doc,
|
||||
options,
|
||||
setStatus: () => {},
|
||||
setPreview: () => {},
|
||||
} as any;
|
||||
tool.handlers.down({ world } as any, ctx);
|
||||
return { added: doc.getAllElements(), doc };
|
||||
}
|
||||
|
||||
describe('E7: curtain', () => {
|
||||
it('curtainTool platziert curtain-Element mit Wellen-Polyline und stageHeight', () => {
|
||||
const { added } = fireDown(curtainTool, { x: 100, y: 50 }, { stageHeight: 120 });
|
||||
expect(added.length).toBe(1);
|
||||
const el = added[0];
|
||||
expect(el.type).toBe('curtain');
|
||||
expect(el.width).toBe(400); // Standard-Breite
|
||||
expect(el.properties.stageHeight).toBe(120);
|
||||
const pts = el.properties.points as Pt[];
|
||||
expect(Array.isArray(pts)).toBe(true);
|
||||
expect(pts.length).toBeGreaterThan(20); // Wellenpunkte
|
||||
});
|
||||
|
||||
it('Drawer: curtain → polyline-Primitive (geschlossen=false)', () => {
|
||||
const { added } = fireDown(curtainTool, { x: 100, y: 50 }, { stageHeight: 120 });
|
||||
const prims = elementToPrimitives(added[0]);
|
||||
expect(prims.length).toBe(1);
|
||||
expect(prims[0].kind).toBe('polyline');
|
||||
expect((prims[0] as any).close).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('E7: spotlight', () => {
|
||||
it('spotlightTool platziert Lichtkegel-Polygon mit angle und color', () => {
|
||||
const { added } = fireDown(spotlightTool, { x: 0, y: 0 }, { angle: 45, color: '#ffcc00' });
|
||||
expect(added.length).toBe(1);
|
||||
const el = added[0];
|
||||
expect(el.type).toBe('spotlight');
|
||||
expect(el.properties.angle).toBe(45);
|
||||
expect(el.properties.color).toBe('#ffcc00');
|
||||
const pts = el.properties.points as Pt[];
|
||||
expect(pts.length).toBe(4); // Kegel: Ursprung + zwei Basis-Ecken + zurück
|
||||
});
|
||||
|
||||
it('Drawer: spotlight → Polygon mit Füllung aus color', () => {
|
||||
const { added } = fireDown(spotlightTool, { x: 0, y: 0 }, { angle: 45, color: '#ffcc00' });
|
||||
const prims = elementToPrimitives(added[0]);
|
||||
expect(prims.length).toBe(1);
|
||||
expect(prims[0].kind).toBe('polyline');
|
||||
expect((prims[0] as any).close).toBe(true);
|
||||
expect((prims[0] as any).stroke.toLowerCase()).toContain('#ffcc00');
|
||||
});
|
||||
|
||||
it('Kegel-Ausrichtung: angle=0 zeigt nach rechts (Richtung +x)', () => {
|
||||
const { added } = fireDown(spotlightTool, { x: 0, y: 0 }, { angle: 0, color: '#fff' });
|
||||
const pts = added[0].properties.points as Pt[];
|
||||
expect(pts[1].x).toBeGreaterThan(100); // Basis-Ecke weiter rechts als Ursprung
|
||||
expect(pts[2].x).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('E7: barrier', () => {
|
||||
it('barrierTool platziert Kette aus Kreis-Paaren entlang Standardlänge', () => {
|
||||
const { added } = fireDown(barrierTool, { x: 0, y: 0 }, {});
|
||||
expect(added.length).toBe(1);
|
||||
const el = added[0];
|
||||
expect(el.type).toBe('barrier');
|
||||
expect(el.width).toBe(200); // Standard-Länge
|
||||
});
|
||||
|
||||
it('Drawer: barrier → mehrere circle-Primitives (Kettenglieder)', () => {
|
||||
const { added } = fireDown(barrierTool, { x: 0, y: 0 }, {});
|
||||
const prims = elementToPrimitives(added[0]);
|
||||
expect(prims.length).toBeGreaterThan(4);
|
||||
expect(prims.every((p) => p.kind === 'circle')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('E7: stage height', () => {
|
||||
it('createStage nimmt height-Option auf (properties.height)', () => {
|
||||
const stage = new SeatingService().createStage(100, 100, 'layer-0', { width: 400, height: 60 });
|
||||
expect(stage.type).toBe('stage');
|
||||
expect(stage.properties.height).toBe(60);
|
||||
expect(stage.width).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Task D9 – GripEditing: Vertex-/Center-Grips im select-Tool.
|
||||
*
|
||||
* getGripPoints: element-spezifische Griff-Punkte (line=Endpunkte,
|
||||
* polyline=Vertices, circle/arc=Zentrum, rect=BBox-Ecken).
|
||||
* Drag eines Vertex-Grips verschiebt NUR diesen Punkt in einer
|
||||
* Transaktion (undo-fähig); BBox-Skalierung (C3fin) bleibt parallel
|
||||
* für Elemente ohne Vertex-Grips (rect u.a.).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { selectTool, getGripPoints } from '../src/plugins/builtin/core-modify';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||
|
||||
function mkCtx() {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
const selectedIds = new Set<string>();
|
||||
const previews: (CADElement | null)[] = [];
|
||||
const statuses: string[] = [];
|
||||
const ctx: any = {
|
||||
doc,
|
||||
options: {},
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (el: CADElement | null) => previews.push(el),
|
||||
selection: {
|
||||
getIds: () => Array.from(selectedIds),
|
||||
setIds: (ids: string[]) => {
|
||||
selectedIds.clear();
|
||||
for (const id of ids) selectedIds.add(id);
|
||||
},
|
||||
hitTest: (x: number, y: number, tol: number) => {
|
||||
// Einfacher Hit-Test: trifft wenn ein Element-BBox den Punkt enthaelt
|
||||
for (const el of doc.getAllElements()) {
|
||||
const p = el.properties as Record<string, unknown>;
|
||||
if (p.x1 !== undefined) {
|
||||
const minX = Math.min(p.x1 as number, p.x2 as number);
|
||||
const maxX = Math.max(p.x1 as number, p.x2 as number);
|
||||
const minY = Math.min(p.y1 as number, p.y2 as number);
|
||||
const maxY = Math.max(p.y1 as number, p.y2 as number);
|
||||
if (x >= minX - tol && x <= maxX + tol && y >= minY - tol && y <= maxY + tol) return el;
|
||||
}
|
||||
if (x >= el.x - el.width / 2 - tol && x <= el.x + el.width / 2 + tol &&
|
||||
y >= el.y - el.height / 2 - tol && y <= el.y + el.height / 2 + tol) return el;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
return { ctx, doc, selectedIds, previews, statuses };
|
||||
}
|
||||
|
||||
function pe(x: number, y: number, mods: { shift?: boolean; ctrl?: boolean } = {}): { world: Pt; shift: boolean; ctrl: boolean } {
|
||||
return { world: { x, y }, shift: mods.shift ?? false, ctrl: mods.ctrl ?? false } as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
selectTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
// ─── getGripPoints (reine Funktion) ────────────────────────
|
||||
|
||||
describe('D9: getGripPoints', () => {
|
||||
it('line: beide Endpunkte als Grips', () => {
|
||||
const el = {
|
||||
id: 'l1', type: 'line', layerId: 'l', x: 50, y: 50, width: 100, height: 0,
|
||||
properties: { x1: 0, y1: 50, x2: 100, y2: 50 },
|
||||
} as unknown as CADElement;
|
||||
const grips = getGripPoints(el);
|
||||
expect(grips.map((g) => g.kind)).toEqual(['vertex', 'vertex']);
|
||||
expect(grips[0].point).toEqual({ x: 0, y: 50 });
|
||||
expect(grips[1].point).toEqual({ x: 100, y: 50 });
|
||||
});
|
||||
|
||||
it('polyline: alle Vertices als Grips', () => {
|
||||
const el = {
|
||||
id: 'p1', type: 'polyline', layerId: 'l', x: 50, y: 0, width: 100, height: 100,
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }] },
|
||||
} as unknown as CADElement;
|
||||
const grips = getGripPoints(el);
|
||||
expect(grips.length).toBe(3);
|
||||
expect(grips.every((g) => g.kind === 'vertex')).toBe(true);
|
||||
expect(grips[2].point).toEqual({ x: 100, y: 100 });
|
||||
});
|
||||
|
||||
it('circle: Zentrum als Center-Grip', () => {
|
||||
const el = {
|
||||
id: 'c1', type: 'circle', layerId: 'l', x: 30, y: 40, width: 20, height: 20,
|
||||
properties: { radius: 10 },
|
||||
} as unknown as CADElement;
|
||||
const grips = getGripPoints(el);
|
||||
expect(grips).toHaveLength(1);
|
||||
expect(grips[0].kind).toBe('center');
|
||||
expect(grips[0].point).toEqual({ x: 30, y: 40 });
|
||||
});
|
||||
|
||||
it('rect: BBox-Ecken als Corner-Grips (C3fin bleibt)', () => {
|
||||
const el = {
|
||||
id: 'r1', type: 'rect', layerId: 'l', x: 50, y: 25, width: 100, height: 50,
|
||||
properties: {},
|
||||
} as unknown as CADElement;
|
||||
const grips = getGripPoints(el);
|
||||
expect(grips.length).toBe(4);
|
||||
expect(grips.every((g) => g.kind === 'corner')).toBe(true);
|
||||
expect(grips[0].point).toEqual({ x: 0, y: 0 });
|
||||
expect(grips[2].point).toEqual({ x: 100, y: 50 });
|
||||
});
|
||||
|
||||
it('Grips tragen index fuer Drag-Zuordnung', () => {
|
||||
const el = {
|
||||
id: 'p1', type: 'polyline', layerId: 'l', x: 0, y: 0, width: 10, height: 10,
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 10, y: 10 }] },
|
||||
} as unknown as CADElement;
|
||||
const grips = getGripPoints(el);
|
||||
expect(grips[0].index).toBe(0);
|
||||
expect(grips[1].index).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Drag-Interaktion: Vertex-Zug (undo-faehig) ─────────────
|
||||
|
||||
describe('D9: Vertex-Drag im selectTool', () => {
|
||||
it('Linien-Endpunkt ziehen verschiebt NUR diesen Punkt (1 Undo)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx();
|
||||
const line = {
|
||||
id: 'l1', type: 'line', layerId: 'l', x: 50, y: 0, width: 100, height: 0,
|
||||
properties: { x1: 0, y1: 0, x2: 100, y2: 0 },
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(line);
|
||||
selectedIds.add('l1');
|
||||
|
||||
// Klick auf Endpunkt (100,0) startet Vertex-Drag
|
||||
selectTool.handlers.down!(pe(100, 0), ctx);
|
||||
selectTool.handlers.up!(pe(120, 30), ctx);
|
||||
|
||||
const updated = doc.getElement('l1')!;
|
||||
expect(updated.properties.x2).toBe(120);
|
||||
expect(updated.properties.y2).toBe(30);
|
||||
// Anderer Endpunkt unveraendert
|
||||
expect(updated.properties.x1).toBe(0);
|
||||
expect(updated.properties.y1).toBe(0);
|
||||
// Undo stellt her
|
||||
doc.undo();
|
||||
const restored = doc.getElement('l1')!;
|
||||
expect(restored.properties.x2).toBe(100);
|
||||
expect(restored.properties.y2).toBe(0);
|
||||
});
|
||||
|
||||
it('Polyline-Vertex ziehen aktualisiert nur diesen Vertex', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx();
|
||||
const poly = {
|
||||
id: 'p1', type: 'polyline', layerId: 'l', x: 50, y: 50, width: 100, height: 100,
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }] },
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(poly);
|
||||
selectedIds.add('p1');
|
||||
|
||||
// Mittleren Vertex (100,0) greifen und auf (100,50) ziehen
|
||||
selectTool.handlers.down!(pe(100, 0), ctx);
|
||||
selectTool.handlers.up!(pe(100, 50), ctx);
|
||||
|
||||
const pts = doc.getElement('p1')!.properties.points as Pt[];
|
||||
expect(pts[1]).toEqual({ x: 100, y: 50 });
|
||||
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||||
expect(pts[2]).toEqual({ x: 100, y: 100 });
|
||||
});
|
||||
|
||||
it('Kreis-Zentrum ziehen verschiebt x/y (Radius bleibt)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx();
|
||||
const circle = {
|
||||
id: 'c1', type: 'circle', layerId: 'l', x: 30, y: 40, width: 20, height: 20,
|
||||
properties: { radius: 10 },
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(circle);
|
||||
selectedIds.add('c1');
|
||||
|
||||
selectTool.handlers.down!(pe(30, 40), ctx);
|
||||
selectTool.handlers.up!(pe(60, 80), ctx);
|
||||
|
||||
const updated = doc.getElement('c1')!;
|
||||
expect(updated.x).toBe(60);
|
||||
expect(updated.y).toBe(80);
|
||||
expect(updated.properties.radius).toBe(10);
|
||||
});
|
||||
|
||||
it('BBox-Corner-Drag bei rect funktioniert weiterhin (Skalierung)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx();
|
||||
const rect = {
|
||||
id: 'r1', type: 'rect', layerId: 'l', x: 50, y: 25, width: 100, height: 50,
|
||||
properties: {},
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(rect);
|
||||
selectedIds.add('r1');
|
||||
|
||||
// Ecke unten-rechts (100,50) greifen und auf (150,75) ziehen
|
||||
selectTool.handlers.down!(pe(100, 50), ctx);
|
||||
selectTool.handlers.up!(pe(150, 75), ctx);
|
||||
|
||||
const updated = doc.getElement('r1')!;
|
||||
expect(updated.width).toBeCloseTo(150, 0);
|
||||
expect(updated.height).toBeCloseTo(75, 0);
|
||||
});
|
||||
|
||||
it('Vertex-Grip gewinnt gegen BBox-Corner bei Ueberlappung (line-Endpunkt am BBox-Rand)', () => {
|
||||
// line (0,0)-(100,0): Endpunkt (0,0) ist AUCH BBox-Ecke.
|
||||
// Der Drag muss als Vertex (Punkt verschieben) wirken, nicht als Scale.
|
||||
const { ctx, doc, selectedIds } = mkCtx();
|
||||
const line = {
|
||||
id: 'l1', type: 'line', layerId: 'l', x: 50, y: 0, width: 100, height: 0,
|
||||
properties: { x1: 0, y1: 0, x2: 100, y2: 0 },
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(line);
|
||||
selectedIds.add('l1');
|
||||
|
||||
selectTool.handlers.down!(pe(0, 0), ctx);
|
||||
selectTool.handlers.up!(pe(-30, -20), ctx);
|
||||
|
||||
const updated = doc.getElement('l1')!;
|
||||
// x1/y1 verschoben, x2 unveraendert → Vertex-Drag, kein Scale
|
||||
expect(updated.properties.x1).toBe(-30);
|
||||
expect(updated.properties.y1).toBe(-20);
|
||||
expect(updated.properties.x2).toBe(100);
|
||||
});
|
||||
|
||||
it('Preview bei Vertex-Drag zeigt aktualisiertes Element', () => {
|
||||
const { ctx, doc, selectedIds, previews } = mkCtx();
|
||||
const line = {
|
||||
id: 'l1', type: 'line', layerId: 'l', x: 50, y: 0, width: 100, height: 0,
|
||||
properties: { x1: 0, y1: 0, x2: 100, y2: 0 },
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(line);
|
||||
selectedIds.add('l1');
|
||||
|
||||
selectTool.handlers.down!(pe(100, 0), ctx);
|
||||
selectTool.handlers.move!(pe(90, 40), ctx);
|
||||
expect(previews.length).toBeGreaterThanOrEqual(1);
|
||||
const last = previews[previews.length - 1];
|
||||
expect(last).not.toBeNull();
|
||||
const lastProps = last!.properties as Record<string, unknown>;
|
||||
expect(lastProps.x2).toBe(90);
|
||||
expect(lastProps.y2).toBe(40);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Task CAD-2 - Echte Hatch-Muster + Boundary-Erkennung.
|
||||
*
|
||||
* HATCH_PATTERNS: Standard-CAD-Muster (ANSI31, NET, GRASS etc.)
|
||||
* als Linien-Segment-Generatoren fuer eine BBox.
|
||||
* computeBoundary: umschlossene Flaeche aus Kontur-Punkten.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
HATCH_PATTERNS,
|
||||
getHatchPattern,
|
||||
generateHatchLines,
|
||||
computeBoundary,
|
||||
type HatchPatternId,
|
||||
} from '../src/services/hatchPatternService';
|
||||
|
||||
describe('CAD-2: HATCH_PATTERNS Bibliothek', () => {
|
||||
it('enthaelt die Standard-CAD-Muster', () => {
|
||||
const ids = HATCH_PATTERNS.map((p) => p.id);
|
||||
expect(ids).toContain('ANSI31');
|
||||
expect(ids).toContain('ANSI32');
|
||||
expect(ids).toContain('ANSI37');
|
||||
expect(ids).toContain('NET');
|
||||
expect(ids).toContain('GRASS');
|
||||
expect(ids).toContain('DOTS');
|
||||
expect(ids).toContain('SOLID');
|
||||
expect(ids).toContain('lines'); // Alte bleiben erhalten
|
||||
expect(ids).toContain('cross');
|
||||
});
|
||||
|
||||
it('jedes Muster hat id, label, description; spacing nur bei Nicht-SOLID', () => {
|
||||
for (const p of HATCH_PATTERNS) {
|
||||
expect(p.id.length).toBeGreaterThan(0);
|
||||
expect(p.label.length).toBeGreaterThan(0);
|
||||
// SOLID braucht keinen Abstand (Vollfuellung)
|
||||
if (!p.solid) {
|
||||
expect(p.defaultSpacing).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('getHatchPattern liefert undefined fuer unbekannte ID', () => {
|
||||
expect(getHatchPattern('gibts-nicht')).toBeUndefined();
|
||||
expect(getHatchPattern('ANSI31')).toBeDefined();
|
||||
expect(getHatchPattern('ANSI31')!.label).toContain('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CAD-2: generateHatchLines (Muster-Geometrie)', () => {
|
||||
const bbox = { minX: 0, minY: 0, maxX: 100, maxY: 50 };
|
||||
const spacing = 10;
|
||||
|
||||
it('ANSI31 (45 Grad): erzeugt Diagonal-Linien', () => {
|
||||
const lines = generateHatchLines('ANSI31', bbox, spacing);
|
||||
expect(lines.length).toBeGreaterThan(3);
|
||||
for (const seg of lines) {
|
||||
const dx = seg.to.x - seg.from.x;
|
||||
const dy = seg.to.y - seg.from.y;
|
||||
expect(Math.abs(dy / dx)).toBeCloseTo(1, 1); // 45 Grad
|
||||
}
|
||||
});
|
||||
|
||||
it('NET: erzeugt horizontale UND vertikale Linien', () => {
|
||||
const lines = generateHatchLines('NET', bbox, spacing);
|
||||
const horiz = lines.filter((l) => l.from.y === l.to.y);
|
||||
const vert = lines.filter((l) => l.from.x === l.to.x);
|
||||
expect(horiz.length).toBeGreaterThan(0);
|
||||
expect(vert.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('DOTS: erzeugt kleine Kreise (als 4-Punkt-Polygone)', () => {
|
||||
const lines = generateHatchLines('DOTS', bbox, spacing);
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
// DOTS sind als polyline-segments repraesentiert
|
||||
});
|
||||
|
||||
it('SOLID: erzeugt EINE grosse Flaeche (fill)', () => {
|
||||
const lines = generateHatchLines('SOLID', bbox, spacing);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect((lines[0] as { fill?: string }).fill).toBeDefined();
|
||||
});
|
||||
|
||||
it('unbekanntes Muster: leeres Array (renderer-sicher)', () => {
|
||||
expect(generateHatchLines('gibts-nicht', bbox, spacing)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CAD-2: computeBoundary (Boundary-Erkennung)', () => {
|
||||
it('Rechteck-Kontur: BBox wird korrekt berechnet', () => {
|
||||
const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 50 }, { x: 0, y: 50 }];
|
||||
const b = computeBoundary(pts);
|
||||
expect(b).toEqual({ minX: 0, minY: 0, maxX: 100, maxY: 50 });
|
||||
});
|
||||
|
||||
it('Dreieck: BBox umschliesst alle Punkte', () => {
|
||||
const pts = [{ x: 10, y: 0 }, { x: 90, y: 80 }, { x: 10, y: 80 }];
|
||||
const b = computeBoundary(pts);
|
||||
expect(b.minX).toBe(10);
|
||||
expect(b.maxX).toBe(90);
|
||||
expect(b.minY).toBe(0);
|
||||
expect(b.maxY).toBe(80);
|
||||
});
|
||||
|
||||
it('leere/wenige Punkte: null-Boundary', () => {
|
||||
expect(computeBoundary([])).toBeNull();
|
||||
expect(computeBoundary([{ x: 0, y: 0 }])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Task H4 – i18n-Grundlage: zentrale strings.ts Maps de/en.
|
||||
*
|
||||
* strings-Modul: t(key) mit de/en-Maps, getLanguage/setLanguage
|
||||
* (localStorage-persistiert, Default de), subscribe + useT-Hook.
|
||||
* Kern-UI-Texte (LayoutBar, Modus-Umschaltung) laufen ueber die
|
||||
* Maps; vollstaendige mechanische Ersetzung aller Komponenten ist
|
||||
* teilautomatisierbares Follow-up (Roadmap-Vermerk).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
|
||||
import {
|
||||
t, getLanguage, setLanguage, subscribeLanguage, useT, LANGUAGES,
|
||||
} from '../src/i18n/strings';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ––– strings-Modul ––––––––––––––––––––––––
|
||||
|
||||
describe('H4: i18n strings', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setLanguage('de');
|
||||
});
|
||||
|
||||
it('Default-Sprache ist de', () => {
|
||||
expect(getLanguage()).toBe('de');
|
||||
});
|
||||
|
||||
it('t liefert deutsche Kern-UI-Texte', () => {
|
||||
expect(t('layouts.title')).toBe('Layouts');
|
||||
expect(t('layouts.add')).toBe('Layout hinzufügen');
|
||||
expect(t('layouts.empty')).toBe('Keine Layouts');
|
||||
});
|
||||
|
||||
it('setLanguage(en) wechselt auf englische Texte', () => {
|
||||
setLanguage('en');
|
||||
expect(t('layouts.title')).toBe('Layouts');
|
||||
expect(t('layouts.add')).toBe('Add layout');
|
||||
expect(t('layouts.empty')).toBe('No layouts');
|
||||
});
|
||||
|
||||
it('Sprache persistiert in localStorage und ueberlebt Reload-Semantik', () => {
|
||||
setLanguage('en');
|
||||
expect(localStorage.getItem('webcad.language')).toBe('en');
|
||||
// erneutes Lesen startet aus persistiertem Zustand
|
||||
expect(getLanguage()).toBe('en');
|
||||
});
|
||||
|
||||
it('LANGUAGES enthaelt genau de und en', () => {
|
||||
expect(LANGUAGES).toEqual(['de', 'en']);
|
||||
});
|
||||
|
||||
it('t mit unbekanntem Key liefert den Key zurueck (Fallback, lint-bar)', () => {
|
||||
setLanguage('de');
|
||||
expect(t('gibts.nicht')).toBe('gibts.nicht');
|
||||
});
|
||||
|
||||
it('subscribeLanguage feuert bei Wechsel und unsubscribed', () => {
|
||||
const cb = vi.fn();
|
||||
const off = subscribeLanguage(cb);
|
||||
setLanguage('en');
|
||||
expect(cb).toHaveBeenCalledWith('en');
|
||||
off();
|
||||
cb.mockClear();
|
||||
setLanguage('de');
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ––– useT-Hook + Komponente ––––––––––––––––––
|
||||
|
||||
describe('H4: useT-Hook rendert LayoutBar zweisprachig', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setLanguage('de');
|
||||
});
|
||||
|
||||
it('LayoutBar zeigt deutsche Texte und wechselt live auf englisch', async () => {
|
||||
const LayoutBar = (await import('../src/components/LayoutBar')).default;
|
||||
render(<LayoutBar doc={null} bridge={null} />);
|
||||
expect(screen.getByText('Layouts')).toBeTruthy();
|
||||
expect(screen.getByText('Keine Layouts')).toBeTruthy();
|
||||
expect(screen.getByTitle('Layout hinzufügen')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
setLanguage('en');
|
||||
});
|
||||
// Live-Update via subscribe (nach act flush)
|
||||
expect(screen.getByText('No layouts')).toBeTruthy();
|
||||
expect(screen.getByTitle('Add layout')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Task CAD-12b - Globale Keyboard-Shortcuts fuer den Editor.
|
||||
*
|
||||
* ESC = aktives Tool abbrechen, Delete/Entf = Auswahl loeschen,
|
||||
* Strg+A = alles auswaehlen, Strg+D = Auswahl duplizieren,
|
||||
* F7 = Grid toggle, F8 = Ortho toggle, F3 = Snap toggle.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createKeyboardShortcuts, type ShortcutAction } from '../src/services/keyboardShortcuts';
|
||||
describe('CAD-12b: keyboardShortcuts', () => {
|
||||
let actions: Record<string, ReturnType<typeof vi.fn>>;
|
||||
let handler: (e: KeyboardEvent) => void;
|
||||
let cleanup: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
actions = {
|
||||
escape: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
selectAll: vi.fn(),
|
||||
duplicate: vi.fn(),
|
||||
toggleGrid: vi.fn(),
|
||||
toggleOrtho: vi.fn(),
|
||||
toggleSnap: vi.fn(),
|
||||
};
|
||||
const ctrl = createKeyboardShortcuts(actions as unknown as ShortcutAction);
|
||||
handler = ctrl.handler;
|
||||
cleanup = ctrl.destroy;
|
||||
});
|
||||
|
||||
afterEach(() => { cleanup(); });
|
||||
|
||||
const fire = (key: string, opts: { ctrl?: boolean; shift?: boolean } = {}) => {
|
||||
handler({ key, ctrlKey: opts.ctrl ?? false, shiftKey: opts.shift ?? false, preventDefault: vi.fn() } as unknown as KeyboardEvent);
|
||||
};
|
||||
|
||||
it('ESC ruft escape auf (Tool abbrechen)', () => {
|
||||
fire('Escape');
|
||||
expect(actions.escape).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Delete und Entf rufen delete auf (Auswahl loeschen)', () => {
|
||||
fire('Delete');
|
||||
expect(actions.delete).toHaveBeenCalledTimes(1);
|
||||
fire('Backspace');
|
||||
expect(actions.delete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Strg+A ruft selectAll auf', () => {
|
||||
fire('a', { ctrl: true });
|
||||
expect(actions.selectAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Strg+D ruft duplicate auf', () => {
|
||||
fire('d', { ctrl: true });
|
||||
expect(actions.duplicate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('F7 ruft toggleGrid auf', () => {
|
||||
fire('F7');
|
||||
expect(actions.toggleGrid).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('F8 ruft toggleOrtho auf', () => {
|
||||
fire('F8');
|
||||
expect(actions.toggleOrtho).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('F3 ruft toggleSnap auf', () => {
|
||||
fire('F3');
|
||||
expect(actions.toggleSnap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Buchstaben ohne Strg loesen KEINE Aktion aus', () => {
|
||||
fire('a');
|
||||
fire('d');
|
||||
fire('x');
|
||||
expect(actions.selectAll).not.toHaveBeenCalled();
|
||||
expect(actions.duplicate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Input-Feld-Fokus wird ignoriert (kein Shortcut wenn in Input/Textarea)', () => {
|
||||
// Simuliere: Event-Target ist ein Input-Element
|
||||
const inputEvent = {
|
||||
key: 'a',
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
target: { tagName: 'INPUT', isContentEditable: false },
|
||||
} as unknown as KeyboardEvent & { target: { tagName: string } };
|
||||
handler(inputEvent);
|
||||
expect(actions.selectAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Task G2 – LayoutBar UI + Viewport-Widget + ZoomPanController.setView.
|
||||
*
|
||||
* LayoutBar listet Layouts des aktiven Dokuments, zeigt je Layout ein
|
||||
* Minimap-Widget (Modellbereich + Viewport-Rechteck), Klick = zoom-to,
|
||||
* „+“ speichert aktuellen Viewport als neues Layout, Löschen entfernt.
|
||||
* ZoomPanController.setView(center, scale) setzt die Absolute Ansicht.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import LayoutBar, { computeMinimapRects } from '../src/components/LayoutBar';
|
||||
import { ZoomPanController } from '../src/canvas/ZoomPanController';
|
||||
import type { LayoutDefinition } from '../src/types/cad.types';
|
||||
|
||||
function createMockCanvas(w = 800, h = 600): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function fakeDoc(layouts: LayoutDefinition[]) {
|
||||
return {
|
||||
getAllLayouts: () => [...layouts],
|
||||
addLayout: vi.fn((l: LayoutDefinition) => { layouts.push(l); }),
|
||||
deleteLayout: vi.fn((id: string) => {
|
||||
const i = layouts.findIndex((l) => l.id === id);
|
||||
if (i >= 0) layouts.splice(i, 1);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeBridge(view = { center: { x: 10, y: 20 }, scale: 2 }) {
|
||||
return {
|
||||
zoomTo: vi.fn(),
|
||||
getView: vi.fn(() => view),
|
||||
};
|
||||
}
|
||||
|
||||
const L1: LayoutDefinition = {
|
||||
id: 'layout-1', name: 'A4 Plan',
|
||||
viewport: { center: { x: 100, y: 50 }, scale: 0.5 },
|
||||
};
|
||||
|
||||
// ─── ZoomPanController.setView ─────────────────────────────
|
||||
|
||||
describe('G2: ZoomPanController.setView', () => {
|
||||
it('setView(center, scale) zentriert Welt-Punkt exakt, Scale direkt', () => {
|
||||
const zpc = new ZoomPanController(createMockCanvas(800, 600));
|
||||
zpc.setView({ x: 100, y: 50 }, 2);
|
||||
expect(zpc.getScale()).toBe(2);
|
||||
const vp = zpc.getViewport();
|
||||
const cx = (vp.minX + vp.maxX) / 2;
|
||||
const cy = (vp.minY + vp.maxY) / 2;
|
||||
expect(cx).toBeCloseTo(100, 5);
|
||||
expect(cy).toBeCloseTo(50, 5);
|
||||
});
|
||||
|
||||
it('setView mit scale 1 bei Origin deckt (0,0) als Zentrum ab', () => {
|
||||
const zpc = new ZoomPanController(createMockCanvas(800, 600));
|
||||
zpc.setView({ x: 0, y: 0 }, 1);
|
||||
const vp = zpc.getViewport();
|
||||
expect((vp.minX + vp.maxX) / 2).toBeCloseTo(0, 5);
|
||||
expect((vp.minY + vp.maxY) / 2).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it('zweiter setView-Aufruf überschreibt ersten komplett', () => {
|
||||
const zpc = new ZoomPanController(createMockCanvas(800, 600));
|
||||
zpc.setView({ x: 500, y: 500 }, 4);
|
||||
zpc.setView({ x: 0, y: 0 }, 0.5);
|
||||
expect(zpc.getScale()).toBe(0.5);
|
||||
const vp = zpc.getViewport();
|
||||
expect((vp.minX + vp.maxX) / 2).toBeCloseTo(0, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── computeMinimapRects (Viewport-Widget-Geometrie) ───────
|
||||
|
||||
describe('G2: computeMinimapRects', () => {
|
||||
const elements = [
|
||||
{ x: 50, y: 50, width: 100, height: 100 }, // bbox 0..100
|
||||
];
|
||||
|
||||
it('Modellbbox füllt Widget (mit Padding), leer → Volles Widget', () => {
|
||||
const r = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 1 }, 100, 80);
|
||||
expect(r.model.w).toBeGreaterThan(80);
|
||||
expect(r.model.h).toBeGreaterThan(60);
|
||||
const empty = computeMinimapRects([], { center: { x: 0, y: 0 }, scale: 1 }, 100, 80);
|
||||
expect(empty.model.w).toBe(100);
|
||||
expect(empty.model.h).toBe(80);
|
||||
});
|
||||
|
||||
it('Viewport-Zentrum liegt im View-Rechteck; höherer Scale = kleinerer Ausschnitt', () => {
|
||||
const r1 = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 1 }, 100, 80);
|
||||
const cx = r1.model.x + (r1.view.x + r1.view.w / 2 - r1.model.x) / r1.model.w * r1.model.w;
|
||||
// View-Zentrum in Widget-Koordinaten:
|
||||
const viewCX = r1.view.x + r1.view.w / 2;
|
||||
const modelCX = r1.model.x + r1.model.w / 2;
|
||||
expect(Math.abs(viewCX - modelCX)).toBeLessThan(1); // 50,50 = BBox-Zentrum
|
||||
const r2 = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 4 }, 100, 80);
|
||||
expect(r2.view.w).toBeLessThan(r1.view.w);
|
||||
expect(r2.view.h).toBeLessThan(r1.view.h);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── LayoutBar UI ──────────────────────────────────────────
|
||||
|
||||
describe('G2: LayoutBar', () => {
|
||||
it('ohne Doc: Empty-State ohne Crash', () => {
|
||||
render(<LayoutBar doc={null} bridge={null} />);
|
||||
expect(screen.getByText(/Keine Layouts/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('listet Layouts mit Name + Minimap-SVG', () => {
|
||||
const doc = fakeDoc([L1]);
|
||||
render(<LayoutBar doc={doc as any} bridge={fakeBridge() as any} />);
|
||||
expect(screen.getByText('A4 Plan')).toBeTruthy();
|
||||
expect(screen.getByTestId('layout-minimap-layout-1')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Klick auf Layout-Eintrag ruft bridge.zoomTo mit gespeichertem Viewport', () => {
|
||||
const bridge = fakeBridge();
|
||||
render(<LayoutBar doc={fakeDoc([L1]) as any} bridge={bridge as any} />);
|
||||
fireEvent.click(screen.getByTestId('layout-entry-layout-1'));
|
||||
expect(bridge.zoomTo).toHaveBeenCalledWith(
|
||||
{ x: 100, y: 50 },
|
||||
0.5,
|
||||
);
|
||||
});
|
||||
|
||||
it('+ Button speichert aktuellen Viewport als neues Layout', () => {
|
||||
const layouts: LayoutDefinition[] = [];
|
||||
const doc = fakeDoc(layouts);
|
||||
const bridge = fakeBridge();
|
||||
render(<LayoutBar doc={doc as any} bridge={bridge as any} />);
|
||||
fireEvent.click(screen.getByTitle('Layout hinzufügen'));
|
||||
expect(doc.addLayout).toHaveBeenCalledTimes(1);
|
||||
const added = (doc.addLayout as ReturnType<typeof vi.fn>).mock.calls[0][0] as LayoutDefinition;
|
||||
expect(added.viewport.center.x).toBe(10);
|
||||
expect(added.viewport.center.y).toBe(20);
|
||||
expect(added.viewport.scale).toBe(2);
|
||||
expect(added.name).toMatch(/Layout \d/);
|
||||
expect(added.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Löschen-Button entfernt Layout über doc.deleteLayout', () => {
|
||||
const doc = fakeDoc([L1]);
|
||||
render(<LayoutBar doc={doc as any} bridge={fakeBridge() as any} />);
|
||||
fireEvent.click(screen.getByTitle('Layout A4 Plan löschen'));
|
||||
expect(doc.deleteLayout).toHaveBeenCalledWith('layout-1');
|
||||
});
|
||||
|
||||
it('ohne Bridge: Klick wirft nicht (no-op)', () => {
|
||||
render(<LayoutBar doc={fakeDoc([L1]) as any} bridge={null} />);
|
||||
expect(() => fireEvent.click(screen.getByTestId('layout-entry-layout-1'))).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Task G1 – Layout-Datenmodell: Yjs Map layoutId → LayoutDefinition.
|
||||
*
|
||||
* LayoutDefinition: { id, name, viewport: { center: {x,y}, scale },
|
||||
* frameBlockRef? }. CRUD über CADDocument, undo-getrackt, Persistence
|
||||
* läuft automatisch über die Yjs-Doc-Serialisierung (drawing json).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
|
||||
function mkDoc(): CADDocument {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
return new CADDocument(ydoc);
|
||||
}
|
||||
|
||||
const LAYOUT = {
|
||||
id: 'layout-1',
|
||||
name: 'A4 Plan',
|
||||
viewport: { center: { x: 100, y: 50 }, scale: 0.5 },
|
||||
frameBlockRef: 'frame-a4',
|
||||
};
|
||||
|
||||
describe('G1: Layout-CRUD', () => {
|
||||
it('addLayout + getLayout: Layout landet mit allen Feldern in der Map', () => {
|
||||
const doc = mkDoc();
|
||||
doc.addLayout(LAYOUT);
|
||||
const loaded = doc.getLayout('layout-1');
|
||||
expect(loaded).toBeDefined();
|
||||
expect(loaded!.name).toBe('A4 Plan');
|
||||
expect(loaded!.viewport.center.x).toBe(100);
|
||||
expect(loaded!.viewport.center.y).toBe(50);
|
||||
expect(loaded!.viewport.scale).toBe(0.5);
|
||||
expect(loaded!.frameBlockRef).toBe('frame-a4');
|
||||
});
|
||||
|
||||
it('getAllLayouts liefert Array; leer ohne Layouts', () => {
|
||||
const doc = mkDoc();
|
||||
expect(doc.getAllLayouts()).toHaveLength(0);
|
||||
doc.addLayout(LAYOUT);
|
||||
doc.addLayout({ ...LAYOUT, id: 'layout-2', name: 'A3 Plan' });
|
||||
const all = doc.getAllLayouts();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all.map((l) => l.name)).toContain('A3 Plan');
|
||||
});
|
||||
|
||||
it('updateLayout ändert name und viewport shallow (id/struktur bleibt)', () => {
|
||||
const doc = mkDoc();
|
||||
doc.addLayout(LAYOUT);
|
||||
doc.updateLayout('layout-1', { name: 'A4 geändert', viewport: { center: { x: 0, y: 0 }, scale: 2 } });
|
||||
const loaded = doc.getLayout('layout-1');
|
||||
expect(loaded!.name).toBe('A4 geändert');
|
||||
expect(loaded!.viewport.scale).toBe(2);
|
||||
expect(loaded!.frameBlockRef).toBe('frame-a4'); // unverändert erhalten
|
||||
});
|
||||
|
||||
it('deleteLayout entfernt Layout still (unbekannte ID kein Fehler)', () => {
|
||||
const doc = mkDoc();
|
||||
doc.addLayout(LAYOUT);
|
||||
doc.deleteLayout('layout-1');
|
||||
expect(doc.getLayout('layout-1')).toBeUndefined();
|
||||
expect(() => doc.deleteLayout('gibts-nicht')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('G1: Undo-Integration', () => {
|
||||
it('Undo entfernt hinzugefügtes Layout wieder', () => {
|
||||
const doc = mkDoc();
|
||||
doc.addLayout(LAYOUT);
|
||||
expect(doc.getLayout('layout-1')).toBeDefined();
|
||||
doc.undo();
|
||||
expect(doc.getLayout('layout-1')).toBeUndefined();
|
||||
doc.redo();
|
||||
expect(doc.getLayout('layout-1')).toBeDefined();
|
||||
});
|
||||
|
||||
it('Layout-Undo ist EIN Schritt (nicht mit Element-Transaktion vermischt)', () => {
|
||||
const doc = mkDoc();
|
||||
doc.addLayout(LAYOUT);
|
||||
doc.deleteLayout('layout-1');
|
||||
// Zwei Schritte: undo stellt Löschung wieder her
|
||||
doc.undo();
|
||||
expect(doc.getLayout('layout-1')).toBeDefined();
|
||||
doc.undo();
|
||||
expect(doc.getLayout('layout-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Task F4 – LibraryProviderExtension Tests.
|
||||
*
|
||||
* Testet die drei Bibliotheksquellen:
|
||||
* - builtin-catalog (statische JSON-Startersets event/architecture/landscape)
|
||||
* - user-library (zeichnungsscoped Blöcke via /api/drawings/:id/blocks)
|
||||
* - global-library (globale Blöcke via /api/global-blocks)
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
import { builtinCatalogProvider } from '../src/plugins/builtin/library/providers';
|
||||
import { makeUserLibraryProvider, makeGlobalLibraryProvider } from '../src/plugins/builtin/library/providers';
|
||||
import { builtinLibraryPlugin } from '../src/plugins/builtin/library';
|
||||
import { pluginRegistry } from '../src/plugins/PluginRegistry';
|
||||
import LibraryProviderPanel from '../src/components/LibraryProviderPanel';
|
||||
|
||||
describe('F4: builtin catalog provider', () => {
|
||||
it('listet genau die 3 Katalog-Ordner', async () => {
|
||||
const folders = await builtinCatalogProvider.listFolders();
|
||||
expect(folders.map(f => f.id).sort()).toEqual(['cat-arch', 'cat-event', 'cat-land']);
|
||||
expect(folders.every(f => f.parentId === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('Startersets: je 15-30 Blöcke mit gültiger Payload und SVG-Thumbnail', async () => {
|
||||
const folders = await builtinCatalogProvider.listFolders();
|
||||
for (const folder of folders) {
|
||||
const blocks = await builtinCatalogProvider.listBlocks(folder.id);
|
||||
expect(blocks.length).toBeGreaterThanOrEqual(15);
|
||||
expect(blocks.length).toBeLessThanOrEqual(30);
|
||||
expect(blocks.every(b => b.folderId === folder.id)).toBe(true);
|
||||
for (const block of blocks) {
|
||||
expect(block.thumbnail).toContain('<svg');
|
||||
expect(block.payload).toBeTruthy();
|
||||
const elements = JSON.parse(block.payload as string);
|
||||
expect(Array.isArray(elements)).toBe(true);
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
for (const el of elements) {
|
||||
expect(typeof el.id).toBe('string');
|
||||
expect(typeof el.type).toBe('string');
|
||||
expect('x' in el && 'y' in el).toBe(true);
|
||||
expect(el.properties).toBeTypeOf('object');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('listBlocks filtert nach Ordner, getBlock liefert den Block mit Payload', async () => {
|
||||
const eventBlocks = await builtinCatalogProvider.listBlocks('cat-event');
|
||||
expect(eventBlocks.length).toBeGreaterThanOrEqual(15);
|
||||
const first = eventBlocks[0];
|
||||
const fetched = await builtinCatalogProvider.getBlock(first.id);
|
||||
expect(fetched.id).toBe(first.id);
|
||||
expect(fetched.payload).toBe(first.payload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('F4: Registry-Anbindung', () => {
|
||||
afterEach(() => {
|
||||
pluginRegistry.unregisterV2(builtinLibraryPlugin.manifest.id);
|
||||
});
|
||||
|
||||
it('Plugin registriert Provider, enableV2 macht ihn sichtbar, disableV2 entfernt ihn', () => {
|
||||
pluginRegistry.registerV2(builtinLibraryPlugin);
|
||||
pluginRegistry.enableV2(builtinLibraryPlugin.manifest.id);
|
||||
const providers = pluginRegistry.getLibraryProviders();
|
||||
expect(providers.some(p => p.id === 'builtin-catalog')).toBe(true);
|
||||
|
||||
pluginRegistry.disableV2(builtinLibraryPlugin.manifest.id);
|
||||
expect(pluginRegistry.getLibraryProviders().some(p => p.id === 'builtin-catalog')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('F4: user-library Provider', () => {
|
||||
it('mappt zeichnungsscoped Blöcke auf LibBlock mit Thumbnail+Payload', async () => {
|
||||
const provider = makeUserLibraryProvider([
|
||||
{
|
||||
id: 'b1', drawing_id: 'd1', name: 'Bühne A', description: null, category: 'stage',
|
||||
elements_json: JSON.stringify([
|
||||
{ id: 'e1', type: 'rect', layerId: 'layer-0', x: 0, y: 0, width: 100, height: 50, properties: { fill: '#3a3a4a' } },
|
||||
]),
|
||||
thumbnail: null,
|
||||
},
|
||||
]);
|
||||
const folders = await provider.listFolders();
|
||||
expect(folders.length).toBe(1);
|
||||
const blocks = await provider.listBlocks(folders[0].id);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].name).toBe('Bühne A');
|
||||
expect(blocks[0].thumbnail).toContain('<svg');
|
||||
const els = JSON.parse(blocks[0].payload as string);
|
||||
expect(els[0].id).toBe('e1');
|
||||
expect((await provider.getBlock(blocks[0].id)).name).toBe('Bühne A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('F4: global-library Provider', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('lädt Ordner und Blöcke der globalen Bibliothek über die API', async () => {
|
||||
const mockFetch = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => [
|
||||
{ id: 'gf1', name: 'Event', parent_id: null, created_at: '2026-01-01' },
|
||||
] })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => [
|
||||
{ id: 'gb1', folder_id: 'gf1', name: 'Stuhl-Reihe', block_data: '[]', svg_data: null, is_favorite: false, created_at: '2026-01-01', updated_at: '2026-01-01' },
|
||||
] });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const provider = makeGlobalLibraryProvider('tok');
|
||||
const folders = await provider.listFolders();
|
||||
expect(folders).toHaveLength(1);
|
||||
expect(folders[0].label).toBe('Event');
|
||||
const blocks = await provider.listBlocks('gf1');
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].name).toBe('Stuhl-Reihe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('F4: LibraryProviderPanel UI', () => {
|
||||
it('rendert Provider-Sektion mit Katalog-Ordnern und Blöcken', async () => {
|
||||
render(<LibraryProviderPanel providers={[builtinCatalogProvider]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/Event-Katalog/).length).toBeGreaterThan(0);
|
||||
});
|
||||
// Jede Sektion listet Blöcke
|
||||
expect(screen.getAllByRole('button', { name: /Stuhl/ }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('DragStart legt die Element-Array-Payload in den Datentransfer', async () => {
|
||||
const setData = vi.fn();
|
||||
render(<LibraryProviderPanel providers={[builtinCatalogProvider]} />);
|
||||
const blockBtn = await screen.findByRole('button', { name: /Stuhl/ });
|
||||
fireEvent.dragStart(blockBtn, { dataTransfer: { setData } });
|
||||
expect(setData).toHaveBeenCalledWith('text/global-block-data', expect.any(String));
|
||||
const payload = JSON.parse(setData.mock.calls[0][1] as string);
|
||||
expect(Array.isArray(payload)).toBe(true);
|
||||
|
||||
expect(payload[0].id).toBeTypeOf('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Task H5 – Flächenberechnung Polygon (m²) + measure-area Tool.
|
||||
*
|
||||
* polygonArea: Shoelace-Formel (Absolutwert, CW/CCW egal),
|
||||
* formatArea: Einheiten-Formatierung analog formatDistance,
|
||||
* measureAreaTool: Klick-Sequenz, Live-Fläche im Status, ENTER
|
||||
* committet ab 3 Punkten, cancel setzt zurück.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { polygonArea } from '../src/tools/modification/geometry';
|
||||
import { formatArea } from '../src/utils/format';
|
||||
import { measureAreaTool } from '../src/plugins/builtin/core-measure';
|
||||
import type { CADElement } from '../src/types/cad.types';
|
||||
|
||||
describe('H5: polygonArea (Shoelace)', () => {
|
||||
it('Rechteck 100×50 = 5000 Welt-Einheiten²', () => {
|
||||
const pts = [
|
||||
{ x: 0, y: 0 }, { x: 100, y: 0 },
|
||||
{ x: 100, y: 50 }, { x: 0, y: 50 },
|
||||
];
|
||||
expect(polygonArea(pts)).toBeCloseTo(5000, 6);
|
||||
});
|
||||
|
||||
it('Dreieck 100×50/2 = 2500', () => {
|
||||
const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 0, y: 50 }];
|
||||
expect(polygonArea(pts)).toBeCloseTo(2500, 6);
|
||||
});
|
||||
|
||||
it('Uhrzeigersinn und gegen Uhrzeigersinn ergeben denselben Absolutwert', () => {
|
||||
const ccw = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }];
|
||||
const cw = [...ccw].reverse();
|
||||
expect(polygonArea(cw)).toBeCloseTo(polygonArea(ccw), 6);
|
||||
expect(polygonArea(cw)).toBeCloseTo(10000, 6);
|
||||
});
|
||||
|
||||
it('weniger als 3 Punkte → 0; kollineare Punkte → 0', () => {
|
||||
expect(polygonArea([{ x: 0, y: 0 }, { x: 1, y: 1 }])).toBe(0);
|
||||
expect(polygonArea([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 200, y: 0 }])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('H5: formatArea', () => {
|
||||
it('mm: 5000 → "5000 mm²" (scaleFactor 1)', () => {
|
||||
expect(formatArea(5000, 'mm', 1)).toBe('5000 mm²');
|
||||
});
|
||||
|
||||
it('cm: 5000mm² → "50.0 cm²"', () => {
|
||||
expect(formatArea(5000, 'cm', 1)).toBe('50.0 cm²');
|
||||
});
|
||||
|
||||
it('m: 5000000mm² → "5.00 m²"', () => {
|
||||
expect(formatArea(5000000, 'm', 1)).toBe('5.00 m²');
|
||||
});
|
||||
|
||||
it('Flächen skalieren QUADRATISCH: 5 WE² bei scaleFactor 1000 = 5000000 mm²', () => {
|
||||
// 1 Welt-Einheit = 1000mm → 1 WE² = 1.000.000 mm²
|
||||
expect(formatArea(5, 'mm', 1000)).toBe('5000000 mm²');
|
||||
});
|
||||
});
|
||||
|
||||
describe('H5: measureAreaTool', () => {
|
||||
// Module-State areaPts leakt zwischen Tests — vor jedem Test resetten:
|
||||
beforeEach(() => {
|
||||
measureAreaTool.handlers.cancel?.({ setStatus: () => {} } as never);
|
||||
});
|
||||
function fireSeq(events: Array<{ type: 'down' | 'move' | 'key' | 'cancel'; x?: number; y?: number; key?: string }>, options: Record<string, unknown> = {}) {
|
||||
const statuses: string[] = [];
|
||||
const ctx: any = {
|
||||
options,
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (_el: CADElement | null) => {},
|
||||
doc: null,
|
||||
};
|
||||
for (const ev of events) {
|
||||
if (ev.type === 'down') measureAreaTool.handlers.down!({ world: { x: ev.x!, y: ev.y! } } as never, ctx);
|
||||
if (ev.type === 'move') measureAreaTool.handlers.move!({ world: { x: ev.x!, y: ev.y! } } as never, ctx);
|
||||
if (ev.type === 'key' && ev.key) measureAreaTool.handlers.key?.({ key: ev.key } as never, ctx);
|
||||
if (ev.type === 'cancel') measureAreaTool.handlers.cancel?.(ctx);
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
it('erster Klick startet Messung, Status fordert weitere Punkte', () => {
|
||||
const s = fireSeq([{ type: 'down', x: 0, y: 0 }]);
|
||||
expect(s[0]).toContain('Fläche');
|
||||
expect(s[s.length - 1]).toContain('klicken');
|
||||
});
|
||||
|
||||
it('Live-Fläche im Status ab 3 Punkten bei move', () => {
|
||||
const s = fireSeq([
|
||||
{ type: 'down', x: 0, y: 0 },
|
||||
{ type: 'down', x: 100, y: 0 },
|
||||
{ type: 'down', x: 100, y: 50 },
|
||||
{ type: 'move', x: 0, y: 50 },
|
||||
]);
|
||||
expect(s.some((m) => m.includes('5000'))).toBe(true);
|
||||
expect(s[s.length - 1]).toContain('mm²');
|
||||
});
|
||||
|
||||
it('ENTER committet ab 3 Punkten mit Endfläche im Status', () => {
|
||||
const s = fireSeq([
|
||||
{ type: 'down', x: 0, y: 0 },
|
||||
{ type: 'down', x: 100, y: 0 },
|
||||
{ type: 'down', x: 100, y: 100 },
|
||||
{ type: 'key', key: 'Enter' },
|
||||
]);
|
||||
// Dreieck (0,0),(100,0),(100,100): Shoelace = 1/2 · 100 · 100 = 5000
|
||||
expect(s[s.length - 1]).toContain('5000');
|
||||
expect(s[s.length - 1]).toContain('3 Punkte');
|
||||
});
|
||||
|
||||
it('ENTER mit weniger als 3 Punkten committet NICHT (Status erklärt)', () => {
|
||||
const s = fireSeq([
|
||||
{ type: 'down', x: 0, y: 0 },
|
||||
{ type: 'down', x: 100, y: 0 },
|
||||
{ type: 'key', key: 'Enter' },
|
||||
]);
|
||||
expect(s[s.length - 1]).toContain('3');
|
||||
});
|
||||
|
||||
it('cancel setzt Messung zurück', () => {
|
||||
const s = fireSeq([
|
||||
{ type: 'down', x: 0, y: 0 },
|
||||
{ type: 'down', x: 50, y: 50 },
|
||||
{ type: 'cancel' },
|
||||
{ type: 'down', x: 0, y: 0 },
|
||||
]);
|
||||
// Nach cancel startet eine frische Messung (kein altes Polygon)
|
||||
expect(s[s.length - 1]).toContain('klicken');
|
||||
});
|
||||
|
||||
it('Manifest: id measure-area, tags basic, ribbonTab canvas', () => {
|
||||
expect(measureAreaTool.manifest.id).toBe('measure-area');
|
||||
expect(measureAreaTool.manifest.ribbonTab).toBe('canvas');
|
||||
expect((measureAreaTool.manifest.tags ?? [])).toContain('basic');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user