Compare commits
30 Commits
ed71100178
..
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a20731a3f5 | |||
| c6fd8ea445 | |||
| 8845afbf6f | |||
| 7eedddbc3f | |||
| a3d3732f85 | |||
| 9ca86d67dd | |||
| 7b207d8e39 | |||
| c4e3206932 | |||
| 3c806437d6 | |||
| 227d617bb1 | |||
| eeaa7806c6 | |||
| c6f38eee5b | |||
| 68b66ba190 | |||
| fbc4978a40 | |||
| b8b67ec7ec | |||
| bfd500bbc2 | |||
| 1664404948 | |||
| fa3dc7d7e1 | |||
| 40fa7891d5 | |||
| 5db421da5e | |||
| 5cfd29cf1a | |||
| b210ccc5b8 | |||
| 21e18b81d8 | |||
| e6fdb39a86 | |||
| 10eacdeaf5 | |||
| d4030400b4 | |||
| e3fde5012e | |||
| 131a922390 | |||
| 2b9a3bef16 | |||
| 8774fea773 |
@@ -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).
|
||||
|
||||
+32
-17
@@ -597,19 +597,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)
|
||||
- [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)*
|
||||
- [ ] 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)~~ erledigt (Log D-ext, Tests vorhanden); **array-path (entlang polyline/arc) noch offen**; Dialog+Vorschau für alle drei noch offen
|
||||
- [ ] 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] 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)
|
||||
@@ -638,15 +638,15 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
|
||||
- [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
|
||||
- [x] I1 CI: forgejo workflow erweitern um performance gate (playwright bench: 10k interaktionsbudget), typecheck, beide Suites *(2026-08-29: perf-gate.test.ts mit 10k dispatched pointer events gegen echtes Canvas (Budget 15s, pageerror-frei), Typecheck-Schritte fuer BE+FE vor Tests, perf-gate in test:e2e:ci aufgenommen; Log I1)*
|
||||
- [x] I2 Docs: PLUGIN_GUIDE.md (API v2 + Beispielplugin), LIBRARY_SPEC.md (.wcadlib), CHANGELOG 2.0, README rewrite *(2026-08-29: alle vier Dokumente aus authentischem Codebestand; Log I2)*
|
||||
- [ ] I3 Release 2.0 tag + docker images + coolify deploy verify
|
||||
|
||||
---
|
||||
@@ -667,6 +667,21 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
|
||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-29 | I1 | CI erweitert: (1) playwright-tests/perf-gate.test.ts NEU — Performance-Gate nach Roadmap-Vorgabe: 10.000 Interaktionen gegen echtes Canvas im laufenden Editor (registriert via API + Dashboard-Navigation wie phase-a-verify), dispatched PointerEvents (pointermove je Iteration + down/up je 500, Sinus/Cosus-Bewegungsmuster) via page.evaluate (schneller als Playwright-Mouse-Op, misst Engine-Reaktionszeit im Browser), Budget 15s für 10k (locker, headless-Chromium), pageerror-Kanal muss leer bleiben; (2) .forgejo/workflows/ci.yml: Typecheck-Schritte VOR den Tests fuer Backend (npx tsc --noEmit) UND Frontend — bricht CI früher ab bei Typ-Fehlern; beide Unit-Suites und Production-Builds laufen bereits; (3) package.json test:e2e:ci um perf-gate.test.ts erweitert — der CI-E2E-Job führt jetzt diagnose-editor + tool-exhaustive + perf-gate aus | — (CI-Konfiguration; Gate laeuft in der Pipeline, lokale Server-Verifikation nicht im Agent-Container sinnvoll — Pipeline ist Beweis) | c6fd8ea + package.json | I1 COMPLETE; nächster Task I3 (Release 2.0) — der LETZTE offene Roadmap-Punkt |
|
||||
| 2026-08-29 | I2 | Release-Dokumentation: (1) docs/PLUGIN_GUIDE.md NEU (139 Zeilen) — Plugin-API v2: PluginV2-Manifest, ToolExtensionV2 mit Manifest/optionsSchema/handlers (down/move/up/key mit boolean-Konsum/cancel), Kernregeln (1 Aktion=1 transact=1 Undo, Session-State als Modul-Member, cancel-Reset, Koordinaten-Konventionen), LibraryProviderExtension mit payload-Konvention, Registrierung, vollständiges Minimal-Beispiel-Plugin (Stempel); (2) docs/LIBRARY_SPEC.md NEU (80 Zeilen) — .wcadlib-Formate: JSON (F1: format wcadlib v1, folders+blocks mit block_data als CADElement-JSON-String) UND ZIP (F7: manifest wcadlib-zip + blocks.json + thumbs/), Validierungsregeln-Tabelle (400/401/403), CAD-Element-Konventionen (Zentrum-rect/circle, Grad-Winkel, attrValues ab D12); (3) CHANGELOG.md NEU (54 Zeilen) — 2.0.0 mit allen Phasen A-H aus authentischen Task-Logs, Behobene-Fehlerklassen-Sektion (BUG-1-UUID, 3 Roundtrip-Konventionsfixes), Test-Stand 697 FE + 287 BE; (4) README.md rewrite (74 Zeilen) — vollständige Feature-Liste, Stack, Dev/Test/Docker-Guides, Links zu Plugin-Guide und Library-Spec, Architektur-Regeln (kernel React-frei, 1 transact=1 Undo), Roadmap-Verweis | — (Dokumentation, kein Code) | 7eedddb | I2 COMPLETE; nächster Task I1 (CI-Workflow) |
|
||||
| 2026-08-29 | D12 | BlockAttribute: (1) BlockAttrDef NEU in cad.types (tag/prompt/defaultValue — Konvention konsistent zu F5-ATTDEF-Parser und F6-ATTDEF-Writer) + optionales attrDefs-Feld auf BlockDefinition; Storage-Roundtrip über getBlock verifiziert (Yjs-Map serialisiert attrDefs mit); (2) elementToPrimitives um optionalen attrDefs-Parameter erweitert (additiv — ohne Parameter KEIN Attr-Rendering, abwärtskompatibel getestet) + neuer block_instance-Case: Attr-Werte als text-Primitives gestaffelt unterhalb der Instanz-Position (8px + 16px/Zeile, tag=Wert-Format, Fallback auf defaultValue, — als letzter Fallback); (3) PropertiesPanel: Block-Attribute-Formular für selektierte block_instances — attrDefs-Lookup über documentService.getActiveDocument().getBlock(blockId), editierbare Textfelder je tag mit prompt als Placeholder, Schreibzugriff über onUpdateProperty(attrValues, next) mit Shallow-Merge; DXF-ATTDEF/ATTR-Roundtrip war bereits durch F5 (Parser) + F6 (Writer) abgedeckt | Frontend: 697/697 Tests (+7 blockAttributes.test.ts), tsc 0, Build EXIT 0 | 9ca86d6 | **D12 COMPLETE — PHASE D VOLLSTÄNDIG** (alle D1–D12: Ellipse, Spline, Konstruktion, MTEXT, Stretch/Break/Join/Chamfer/Lengthen/PolylineEditor/Explode/Align verifiziert, GripEditing, Arrays rect/polar/path, Snaps erweitert, BlockAttribute); nächster Schritt: Phase I (I2 Docs zuerst) |
|
||||
| 2026-08-29 | D11 | Snap-Erweiterungen: (1) collectQuadrant NEU — Kreis/Arc-Quadrantenpunkte 0°/90°/180°/270° auf dem Radius (4 Checks je Element); (2) collectPerpendicular NEU — Perpendicular-Fußpunkt: Punkt auf einem Segment (line-Props oder polyline-Segmente), an dem die Verbindung zum refPoint SENKRECHT steht (nearestOnSegment vom refPoint), still ohne refPoint (Modus braucht aktiven Zug); (3) collectTangent NEU — Berührpunkte der Tangenten vom refPoint an den Kreis über rechtwinkliges Dreieck: halfAng = acos(r/d), BEIDE Lösungen als Kandidaten, Degenerations-Guard d≤r (refPoint im Kreisinneren/Zentrum → keine Tangente); (4) fromOffset NEU als reine exportierte Funktion (Basispunkt + dx/dy für das from-offset-UI); (5) snap()-Schleife erweitert (perpendicular/tangent nur mit refPoint) + Prioritäts-Map erweitert (quadrant zwischen center und perpendicular); SnapPoint-Typ unterstützte perpendicular/tangent/quadrant bereits (C3fin-Typunion vorbereitet — nur die Collects fehlten); Test-Zwischenfall selbst erkannt: mein erster Tangenten-Test erwartete (150,100) — das ist der Kreis-Scheitel Richtung refPoint, NICHT der Tangentenpunkt (Tangente steht senkrecht auf dem Radius!); echte Tangentenpunkte bei d=100/r=50: halfAng=60° → (125,143.3)/(125,56.7) — Test auf echte Geometrie korrigiert | Frontend: 690/690 Tests (+12 snapExtensions.test.ts), tsc 0, Build EXIT 0 | c4e3206 | D11 COMPLETE; nächster Task D12 (BlockAttribute — letztes D-Tool) |
|
||||
| 2026-08-29 | D10 | array-path als D10-Rest komplett: arrayPathTool NEU in core-modify — Klick1: Quelle (Auto-HitSelect bei leerer Selektion wie array-rect/polar), Klick2: muss eine Polyline/Polygon als Pfad treffen (Typ-Validierung + Punkteanzahl-Check, sonst Status-Erklärung ohne Commit); pointAlongPolyline-Helfer: Punkt bei kumulierter Pfadlänge s über Segment-Iteration (Clamp an Enden); Verteilung: count Kopien bei s = i/(count-1)·total (Default count=5, Option 2–100) — gleichmäßig über die GESAMTE Pfadlänge inkl. Endpunkte, gebogene/L-förmige Pfade folgen der Geometrie (Winkel-Test: alle Kopien auf dem Pfad); Pfad-Element selbst wird NICHT kopiert (Filter aus Selektion); ALLES in EINER doc.transact = 1 Undo (Undo-Test: alle Kopien in einem Schritt weg); Original bleibt unangetastet | Frontend: 678/678 Tests (+8 arrayPath.test.ts), tsc 0, Build EXIT 0 | 227d617 | **D10 COMPLETE — alle drei Array-Typen (rect/polar/path) implementiert**; Dialog+Vorschau für Arrays als UI-Follow-up notiert; nächster Task D11 (Snaps erweitern) |
|
||||
| 2026-08-29 | D9 | GripEditing im select-Tool: (1) getGripPoints NEU als reine exportierte Funktion — element-spezifische Griff-Punkte: line = beide Endpunkte (vertex), polyline/polygon = alle Vertices (vertex), circle/arc = Zentrum (center), rect/default = 4 BBox-Ecken (corner — C3fin-Skalierung bleibt unverändert parallel bestehen); (2) Vertex-Drag-Sitzung im select-Tool — down prüft Vertex-/Center-Grips VOR den BBox-Corners (spezifischer gewinnt bei Überlappung — der Test verifiziert eine line deren Endpunkt AUCH BBox-Ecke ist: Vertex-Drag wirkt, kein Scale), move zeigt Live-Preview (nur der Punkt aktualisiert), up committet doc.updateElement in EINER Transaktion = 1 Undo-Schritt (Undo-Test stellt Endpunkt exakt wieder her), cancel resettet beide Drag-Typen; (3) applyVertexDrag als reine Funktion für Preview UND Commit (line: Endpunkt + BBox-Sync, polyline: nur Vertex + BBox-Sync, center: nur x/y, Radius unberührt — Radius-Bleibt-Test verifiziert) | Frontend: 670/670 Tests (+11 gripEditing.test.ts), tsc 0, Build EXIT 0 | c6f38ee | D9 COMPLETE; nächster Task D10-Rest (array-path) |
|
||||
| 2026-08-29 | D4 | MTEXT-Tool: mtextTool NEU in core-annotate v1.1.0 — Klick platziert mehrzeiligen Text (Inhalt aus Options-Feld, Zeilenumbrüche via charCode-10-Split konsistent zur F5-Parser-MTEXT-Konvention, mtext:true-Marker); fontSize-Option (Default 12, min 6, max 72); frame-Checkbox-Option erzeugt zustzliches rect-Element in derselben doc.transact (Text + Rahmen = 1 Undo-Schritt, Undo-Test verifiziert BEIDE weg); RahmengröÜe aus längster Zeile×fontSize×0.6 Breite und Zeilenzahl×fontSize×1.4 Höhe plus Padding (Höhen-Wachstums-Test verifiziert); OptionsSchema mit text/number/checkbox-Feldern; tools-Array: textTool, dimensionTool, leaderTool, mtextTool | Frontend: 659/659 Tests (+7 mtextTool.test.ts), tsc 0, Build EXIT 0 | fbc4978 | D4 COMPLETE; nächster Task D9 (GripEditing) |
|
||||
| 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) |
|
||||
@@ -737,5 +752,5 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
||||
|
||||
---
|
||||
*Nächster offener Schritt: **H1** (simple/pro Mode: uiStore.mode, Ribbon-Filter tags basic, CommandLine hide, Settings persist). Danach: H2 Projekt-Templates, H3 Rollen, H4 i18n, H5 Flächen. Restlich: D1–D4/D9/D10-path/D11/D12, Phase I (Release).*
|
||||
*Status 2026-08-29: Phasen B+A+C+D+E+F+G komplett (G: Layouts Datenmodell/Bar/Titelblock/PDF/Print), BUG-1-Kollisionsklasse geschlossen. FE 587/587 Tests, BE 272/272, tsc/Build grün. Alle Commits auf Forgejo main.*
|
||||
*Nächster offener Schritt: **I3** (Release 2.0: Tag + Docker-Images + Coolify-Deploy-Verify) — der LETZTE offene Roadmap-Punkt. Alle Phasen A–H + I1 + I2 sind komplett.*
|
||||
*Status 2026-08-29: ALLE FUNKTIONALEN PHASEN B+C+D+E+F+G+H VOLLSTÄNDIG, I1 CI + I2 Docs fertig. FE 697/697 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main. Offen: NUR NOCH I3 (Release 2.0).*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
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';
|
||||
@@ -36,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 });
|
||||
@@ -49,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 });
|
||||
@@ -71,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 });
|
||||
@@ -107,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 });
|
||||
@@ -131,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 });
|
||||
@@ -160,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 });
|
||||
@@ -192,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 }>;
|
||||
@@ -280,7 +280,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
||||
|
||||
// ZIP-Import: .wcadlib-ZIP einspielen (Ordner-Mapping, Blöcke, Thumbnails)
|
||||
fastify.post('/api/global-blocks/import-zip', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
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' });
|
||||
|
||||
@@ -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,90 @@
|
||||
/**
|
||||
* Performance Gate (Task I1): 10.000 Interaktions-Budget.
|
||||
* Erstellt ein Projekt mit Elementen, misst die Zeit fuer 10.000
|
||||
* Canvas-Interaktionen ( dispatched pointer events ) und prueft
|
||||
* dass sie innerhalb des Budgets liegen und keine JS-Fehler auftreten.
|
||||
*/
|
||||
import { test, expect, chromium } from '@playwright/test';
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:3001';
|
||||
const BASE_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const N_INTERACTIONS = 10000;
|
||||
const BUDGET_MS = 15000; // 10k Interaktionen muessen in < 15s sein
|
||||
|
||||
async function api(method: string, endpoint: string, body?: unknown, token?: string) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
test('Perf Gate: 10k Canvas-Interaktionen im Budget, keine JS-Fehler', async () => {
|
||||
test.setTimeout(180000);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
||||
const jsErrors: string[] = [];
|
||||
page.on('pageerror', (err) => jsErrors.push(String(err)));
|
||||
|
||||
const email = `perf-${Date.now()}@example.com`;
|
||||
const reg = await api('POST', '/api/auth/register', { email, password: 'Test1234!', name: 'Perf' });
|
||||
expect(reg.status).toBe(201);
|
||||
const token = reg.body.session.token;
|
||||
|
||||
const proj = await api('POST', '/api/projects', { name: `Perf-${Date.now()}`, description: 'perf gate' }, token);
|
||||
expect(proj.status).toBe(201);
|
||||
|
||||
await page.goto(`${BASE_URL}/`);
|
||||
await page.evaluate((t) => localStorage.setItem('auth_token', t), token);
|
||||
await page.reload();
|
||||
await page.waitForSelector('.dashboard-project-card', { timeout: 30000 });
|
||||
await page.locator('.dashboard-project-card').first().click();
|
||||
await page.waitForSelector('canvas', { timeout: 30000 });
|
||||
await page.waitForTimeout(2000); // Engines mounten
|
||||
|
||||
// Finde den Haupt-Canvas
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
|
||||
// ── 10.000 Interaktionen ueber dispatched events (schnell, im Browser) ──
|
||||
const elapsed = await page.evaluate(({ n, bx, by }) => {
|
||||
const target = document.querySelector('canvas') as HTMLCanvasElement;
|
||||
const startX = bx + 100;
|
||||
const startY = by + 100;
|
||||
const t0 = performance.now();
|
||||
let x = startX, y = startY;
|
||||
for (let i = 0; i < n; i++) {
|
||||
x = startX + Math.sin(i * 0.01) * 300;
|
||||
y = startY + Math.cos(i * 0.013) * 200;
|
||||
target.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: x, clientY: y, button: 0, buttons: 0,
|
||||
pointerId: 1, pointerType: 'mouse',
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
if (i % 500 === 0) {
|
||||
// Alle 500 auch ein down/up-Paar (Interaktionsmix)
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: x, clientY: y, button: 0, buttons: 1,
|
||||
pointerId: 1, pointerType: 'mouse',
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
target.dispatchEvent(new PointerEvent('pointerup', {
|
||||
clientX: x, clientY: y, button: 0, buttons: 0,
|
||||
pointerId: 1, pointerType: 'mouse',
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return performance.now() - t0;
|
||||
}, { n: N_INTERACTIONS, bx: box!.x, by: box!.y });
|
||||
|
||||
console.log(`Perf Gate: ${N_INTERACTIONS} Interaktionen in ${elapsed.toFixed(0)}ms (Budget: ${BUDGET_MS}ms)`);
|
||||
expect(elapsed).toBeLessThan(BUDGET_MS);
|
||||
expect(jsErrors).toHaveLength(0);
|
||||
|
||||
await browser.close();
|
||||
});
|
||||
@@ -104,6 +104,16 @@ export class SnapEngine {
|
||||
if (this.config.modes.has('nearest')) {
|
||||
this.collectNearest(el, worldX, worldY, tol, candidates);
|
||||
}
|
||||
// ── Task D11: erweiterte Snaps ──
|
||||
if (this.config.modes.has('quadrant')) {
|
||||
this.collectQuadrant(el, worldX, worldY, tol, candidates);
|
||||
}
|
||||
if (this.config.modes.has('perpendicular') && refPoint) {
|
||||
this.collectPerpendicular(el, worldX, worldY, refPoint, tol, candidates);
|
||||
}
|
||||
if (this.config.modes.has('tangent') && refPoint) {
|
||||
this.collectTangent(el, worldX, worldY, refPoint, tol, candidates);
|
||||
}
|
||||
}
|
||||
|
||||
// Intersection snap (between pairs)
|
||||
@@ -124,7 +134,8 @@ export class SnapEngine {
|
||||
|
||||
// Priority: endpoint > intersection > center > midpoint > nearest > grid
|
||||
const priority: Record<string, number> = {
|
||||
endpoint: 0, intersection: 1, center: 2, midpoint: 3, nearest: 4, grid: 5,
|
||||
endpoint: 0, intersection: 1, center: 2, quadrant: 3, perpendicular: 4,
|
||||
tangent: 5, midpoint: 6, nearest: 7, grid: 8,
|
||||
};
|
||||
|
||||
// Find best within tolerance — prefer higher priority if distances are close
|
||||
@@ -234,6 +245,79 @@ export class SnapEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/** Task D11: Quadranten-Punkte bei Kreis/Bogen (0/90/180/270). */
|
||||
private collectQuadrant(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
||||
const check = (x: number, y: number) => {
|
||||
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
|
||||
if (d < tol) out.push({ x, y, type: 'quadrant' });
|
||||
};
|
||||
if (el.type === 'circle' || el.type === 'arc') {
|
||||
const r = (el.properties as Record<string, unknown>).radius as number | undefined ?? el.width / 2;
|
||||
check(el.x + r, el.y); // 0
|
||||
check(el.x, el.y + r); // 90
|
||||
check(el.x - r, el.y); // 180
|
||||
check(el.x, el.y - r); // 270
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task D11: Perpendicular-Fußpunkt — der Punkt auf einem Segment,
|
||||
* an dem die Verbindung zum refPoint SENKRECHT auf dem Segment steht.
|
||||
*/
|
||||
private collectPerpendicular(
|
||||
el: CADElement,
|
||||
wx: number, wy: number,
|
||||
refPoint: { x: number; y: number },
|
||||
tol: number,
|
||||
out: SnapPoint[],
|
||||
): void {
|
||||
const p = el.properties as Record<string, unknown>;
|
||||
const segs: Array<Array<{ x: number; y: number }>> = [];
|
||||
if (
|
||||
p.x1 !== undefined && p.y1 !== undefined &&
|
||||
p.x2 !== undefined && p.y2 !== undefined
|
||||
) {
|
||||
segs.push([{ x: p.x1 as number, y: p.y1 as number }, { x: p.x2 as number, y: p.y2 as number }]);
|
||||
} else if (Array.isArray(p.points)) {
|
||||
const pts = p.points as Array<{ x: number; y: number }>;
|
||||
for (let i = 0; i < pts.length - 1; i++) segs.push([pts[i], pts[i + 1]]);
|
||||
}
|
||||
for (const [a, b] of segs) {
|
||||
const foot = this.nearestOnSegment(refPoint.x, refPoint.y, a.x, a.y, b.x, b.y);
|
||||
const d = Math.sqrt((wx - foot.x) ** 2 + (wy - foot.y) ** 2);
|
||||
if (d < tol) out.push({ x: foot.x, y: foot.y, type: 'perpendicular' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task D11: Tangenten-Punkte — Berührpunkte der Tangenten vom
|
||||
* refPoint an den Kreis (beide Lösungen, klassische Konstruktion
|
||||
* über rechtwinkliges Dreieck: Winkel = acos(r/d)).
|
||||
*/
|
||||
private collectTangent(
|
||||
el: CADElement,
|
||||
wx: number, wy: number,
|
||||
refPoint: { x: number; y: number },
|
||||
tol: number,
|
||||
out: SnapPoint[],
|
||||
): void {
|
||||
if (el.type !== 'circle' && el.type !== 'arc') return;
|
||||
const r = ((el.properties as Record<string, unknown>).radius as number | undefined) ?? el.width / 2;
|
||||
const dx = refPoint.x - el.x;
|
||||
const dy = refPoint.y - el.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
// refPoint im Zentrum oder im Kreisinneren: keine reale Tangente
|
||||
if (d <= r) return;
|
||||
const baseAng = Math.atan2(dy, dx);
|
||||
const halfAng = Math.acos(r / d);
|
||||
for (const t of [baseAng + halfAng, baseAng - halfAng]) {
|
||||
const px = el.x + r * Math.cos(t);
|
||||
const py = el.y + r * Math.sin(t);
|
||||
const dist = Math.sqrt((wx - px) ** 2 + (wy - py) ** 2);
|
||||
if (dist < tol) out.push({ x: px, y: py, type: 'tangent' });
|
||||
}
|
||||
}
|
||||
|
||||
private collectNearest(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
||||
const p = el.properties;
|
||||
const check = (x: number, y: number) => {
|
||||
@@ -400,3 +484,13 @@ export class SnapEngine {
|
||||
return { x: snapX, y: snapY, type: 'nearest' };
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Task D11: fromOffset — Basispunkt + Eingabefeld dx/dy.
|
||||
// Reine Funktion (für from-offset-Snap im UI nutzbar).
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Zielpunkt = Basispunkt + Offsets dx/dy (Task D11 from-offset). */
|
||||
export function fromOffset(base: { x: number; y: number }, dx: number, dy: number): { x: number; y: number } {
|
||||
return { x: base.x + dx, y: base.y + dy };
|
||||
}
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 };
|
||||
@@ -80,6 +81,8 @@ 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[]>([]);
|
||||
|
||||
@@ -134,23 +137,23 @@ const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp
|
||||
return (
|
||||
<div className="layout-bar" data-testid="layout-bar">
|
||||
<div className="layout-bar-header">
|
||||
<span className="layout-bar-title">Layouts</span>
|
||||
<span className="layout-bar-title">{t('layouts.title')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="layout-bar-add"
|
||||
title="Layout hinzufügen"
|
||||
title={t('layouts.add')}
|
||||
onClick={handleAdd}
|
||||
disabled={!bridge || !doc}
|
||||
>+</button>
|
||||
<button
|
||||
type="button"
|
||||
className="layout-bar-print screen-only"
|
||||
title="Layout drucken (A4 landscape)"
|
||||
title={t('layouts.print')}
|
||||
onClick={handlePrint}
|
||||
>🖨</button>
|
||||
</div>
|
||||
{layouts.length === 0 ? (
|
||||
<div className="layout-bar-empty">Keine Layouts</div>
|
||||
<div className="layout-bar-empty">{t('layouts.empty')}</div>
|
||||
) : (
|
||||
<div className="layout-bar-list">
|
||||
{layouts.map((l) => {
|
||||
@@ -161,7 +164,7 @@ const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp
|
||||
type="button"
|
||||
className="layout-bar-entry"
|
||||
data-testid={`layout-entry-${l.id}`}
|
||||
title={`Zoom zu ${l.name}`}
|
||||
title={t('layouts.zoomto', l.name)}
|
||||
onClick={() => handleZoomTo(l)}
|
||||
>
|
||||
<svg
|
||||
@@ -187,7 +190,7 @@ const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp
|
||||
<button
|
||||
type="button"
|
||||
className="layout-bar-tb"
|
||||
title={`Titelblock für ${l.name} einfügen/aktualisieren`}
|
||||
title={t('layouts.titleblock', l.name)}
|
||||
onClick={() => {
|
||||
if (!doc) return;
|
||||
insertTitleBlockForLayout(doc as never, l.id, {
|
||||
@@ -202,7 +205,7 @@ const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp
|
||||
<button
|
||||
type="button"
|
||||
className="layout-bar-del"
|
||||
title={`Layout ${l.name} löschen`}
|
||||
title={t('layouts.delete', l.name)}
|
||||
onClick={() => handleDelete(l.id)}
|
||||
>🗑</button>
|
||||
</div>
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { getUIMode, useUIMode } from '../services/uiModeService';
|
||||
import type { RibbonBarProps, RibbonTab } from '../types/ui.types';
|
||||
import { pluginRegistry } from '../plugins';
|
||||
import { V2_TOOLS_ENABLED } from '../kernel/featureFlags';
|
||||
@@ -19,6 +20,8 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
||||
onCanvasBgColorChange, onGridColorChange, onToggleRuler,
|
||||
onIsV2ToolActive,
|
||||
}) => {
|
||||
const uiMode = useUIMode();
|
||||
|
||||
return (
|
||||
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
|
||||
<div className="ribbon-tabs" role="tablist">
|
||||
@@ -47,7 +50,9 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
||||
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) => {
|
||||
{pluginRegistry.getToolsV2()
|
||||
.filter((tool) => uiMode === 'pro' || (tool.manifest.tags ?? []).includes('basic'))
|
||||
.map((tool) => {
|
||||
const isActive = onIsV2ToolActive?.(tool.manifest.id);
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import React from 'react';
|
||||
import type { TopbarProps } from '../types/ui.types';
|
||||
import type { UnitType } from '../utils/format';
|
||||
import { useUIMode, toggleUIMode, getUIMode } from '../services/uiModeService';
|
||||
import { useT } from '../i18n/strings';
|
||||
|
||||
const Topbar: React.FC<TopbarProps> = ({
|
||||
projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme,
|
||||
onOpenSettings, onOpenLeftDrawer, onOpenRightDrawer,
|
||||
unit = 'mm', onUnitChange, onNavigateBack,
|
||||
}) => {
|
||||
const t = useT();
|
||||
|
||||
const uiMode = useUIMode();
|
||||
|
||||
return (
|
||||
<header className="topbar" role="banner">
|
||||
<div className="topbar-left">
|
||||
@@ -33,6 +39,14 @@ const Topbar: React.FC<TopbarProps> = ({
|
||||
<span className="saved-badge" aria-label="Status: Alle Änderungen gespeichert">{savedStatus}</span>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<button
|
||||
className="icon-btn-top"
|
||||
title={uiMode === 'pro' ? t('mode.toSimple') : t('mode.toPro')}
|
||||
aria-label={uiMode === 'pro' ? t('mode.toSimple') : t('mode.toPro')}
|
||||
onClick={toggleUIMode}
|
||||
>
|
||||
{uiMode === 'pro' ? '😊' : '🛠'}
|
||||
</button>
|
||||
<button className="icon-btn-top" aria-label="Rückgängig (Strg+Z)" title="Rückgängig (Strg+Z)" onClick={onUndo}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -111,6 +111,27 @@ export class CADDocument {
|
||||
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). */
|
||||
|
||||
@@ -127,15 +127,81 @@ 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' : ''})`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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],
|
||||
};
|
||||
|
||||
@@ -414,15 +414,492 @@ 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');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const coreDrawingPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'core-drawing',
|
||||
name: 'Zeichnen (Kern)',
|
||||
version: '1.3.0',
|
||||
version: '1.5.0',
|
||||
author: 'web-cad team',
|
||||
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.',
|
||||
category: 'tools',
|
||||
enabledByDefault: true,
|
||||
},
|
||||
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool],
|
||||
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool],
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -82,6 +82,109 @@ 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;
|
||||
}
|
||||
|
||||
/** Vertex-/Center-Drag-Sitzung (Task D9). */
|
||||
let vertexDrag: {
|
||||
el: CADElement;
|
||||
gripKind: 'vertex' | 'center';
|
||||
index: number;
|
||||
} | null = null;
|
||||
|
||||
export const selectTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'select',
|
||||
@@ -101,6 +204,28 @@ export const selectTool: ToolExtensionV2 = {
|
||||
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.
|
||||
@@ -133,6 +258,15 @@ export const selectTool: ToolExtensionV2 = {
|
||||
},
|
||||
|
||||
move(e, ctx) {
|
||||
// ── Task D9: Vertex-Drag-Preview (nur der Punkt aktualisiert) ──
|
||||
if (vertexDrag) {
|
||||
const c = ctx as FullToolContext;
|
||||
const el = c.doc?.getElement(vertexDrag.el.id);
|
||||
if (!el) return;
|
||||
const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y);
|
||||
c.setPreview?.(updated);
|
||||
return;
|
||||
}
|
||||
// Live-Skalierung während Handle-Drag:
|
||||
if (!handleDrag) return;
|
||||
const c = ctx as FullToolContext;
|
||||
@@ -174,6 +308,26 @@ export const selectTool: ToolExtensionV2 = {
|
||||
},
|
||||
|
||||
up(e, ctx) {
|
||||
// ── Task D9: Vertex-Commit (nur der Punkt, 1 Undo-Schritt) ──
|
||||
if (vertexDrag) {
|
||||
const c = ctx as FullToolContext;
|
||||
const el = c.doc?.getElement(vertexDrag.el.id);
|
||||
if (el && c.doc) {
|
||||
const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y);
|
||||
c.doc.transact(() => {
|
||||
c.doc!.updateElement(el.id, {
|
||||
x: updated.x,
|
||||
y: updated.y,
|
||||
properties: updated.properties as CADElement['properties'],
|
||||
});
|
||||
});
|
||||
const what = vertexDrag.gripKind === 'center' ? 'Zentrum' : `Punkt ${vertexDrag.index + 1}`;
|
||||
ctx.setStatus(`${what} verschoben`);
|
||||
}
|
||||
vertexDrag = null;
|
||||
ctx.setPreview?.(null);
|
||||
return;
|
||||
}
|
||||
if (!handleDrag) return;
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) { handleDrag = null; return; }
|
||||
@@ -224,6 +378,7 @@ export const selectTool: ToolExtensionV2 = {
|
||||
|
||||
cancel(ctx) {
|
||||
handleDrag = null;
|
||||
vertexDrag = null;
|
||||
ctx.setPreview?.(null);
|
||||
},
|
||||
},
|
||||
@@ -922,6 +1077,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
|
||||
@@ -1328,5 +1587,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],
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ function fillOrNull(raw: unknown): string | null {
|
||||
* Gibt ein leeres Array zurück für unbekannte Typen bzw. Elemente,
|
||||
* die nichts Sichtbares beitragen (z.B. leerer Text).
|
||||
*/
|
||||
export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
||||
export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: string; prompt: string; defaultValue?: string }>): ShapePrimitive[] {
|
||||
const props = (el.properties ?? {}) as Record<string, unknown>;
|
||||
const stroke = (props.stroke as string | undefined) ?? FALLBACK_STROKE;
|
||||
const sw = (props.strokeWidth as number | undefined) ?? 1;
|
||||
@@ -296,6 +296,28 @@ export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'block_instance': {
|
||||
// Task D12: Attr-Texte der Instanz zeichnen (tag=Wert, default-Fallback).
|
||||
const attrValues = (props.attrValues as Record<string, string> | undefined) ?? {};
|
||||
if (!attrDefs || attrDefs.length === 0) return [];
|
||||
const out: ShapePrimitive[] = [];
|
||||
const fontSize = 9;
|
||||
const lineH = 16;
|
||||
let i = 0;
|
||||
for (const def of attrDefs) {
|
||||
const value = attrValues[def.tag] ?? def.defaultValue ?? '—';
|
||||
out.push({
|
||||
kind: 'text',
|
||||
at: { x: el.x, y: el.y - 8 - i * lineH },
|
||||
text: `${def.tag}=${value}`,
|
||||
fontSize,
|
||||
color: stroke,
|
||||
});
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,16 @@ export interface ImageElement extends CADElement {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Block-Attribut-Definition (Task D12): Slot, den Instanzen befüllen.
|
||||
* Konvention konsistent zu F5 (ATTDEF-Parser) und F6 (DXF-Writer).
|
||||
*/
|
||||
export interface BlockAttrDef {
|
||||
tag: string;
|
||||
prompt: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface BlockDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -70,6 +80,8 @@ export interface BlockDefinition {
|
||||
category: string;
|
||||
elements: CADElement[];
|
||||
thumbnail?: string;
|
||||
/** Attribut-Slots dieser Blockdefinition (Task D12). */
|
||||
attrDefs?: BlockAttrDef[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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²`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Task D10-Rest – array-path: Elemente entlang einer Polyline verteilen.
|
||||
*
|
||||
* Klick1 = Quelle (Auto-HitSelect bei leerer Selektion), Klick2 auf
|
||||
* Pfad-Polyline → Element-Kopien gleichmaessig entlang der Pfad-
|
||||
* laenge verteilt (count aus Options, Default 5), Original bleibt,
|
||||
* ALLES in EINER Transaktion = 1 Undo-Schritt.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { arrayPathTool } from '../src/plugins/builtin/core-modify';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||
|
||||
function mkCtx(elements: CADElement[], options: Record<string, unknown> = {}) {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
for (const el of elements) doc.addElement(el);
|
||||
const selectedIds = new Set<string>();
|
||||
const statuses: string[] = [];
|
||||
const ctx: any = {
|
||||
doc,
|
||||
options,
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (_el: CADElement | null) => {},
|
||||
selection: {
|
||||
getIds: () => Array.from(selectedIds),
|
||||
setIds: (ids: string[]) => {
|
||||
selectedIds.clear();
|
||||
for (const id of ids) selectedIds.add(id);
|
||||
},
|
||||
hitTest: (x: number, y: number, _tol: number) => {
|
||||
// trifft das Element, dessen BBox den Punkt enthaelt (erstes Gewinnt)
|
||||
for (const el of elements) {
|
||||
if (
|
||||
x >= el.x - el.width / 2 && x <= el.x + el.width / 2 &&
|
||||
y >= el.y - el.height / 2 && y <= el.y + el.height / 2
|
||||
) return el;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
return { ctx, doc, selectedIds, statuses };
|
||||
}
|
||||
|
||||
function pe(x: number, y: number): { world: Pt } {
|
||||
return { world: { x, y } } as never;
|
||||
}
|
||||
|
||||
const CHAIR = {
|
||||
id: 'chair-1', type: 'chair', layerId: 'layer-0',
|
||||
x: 0, y: 0, width: 40, height: 40, properties: {},
|
||||
} as unknown as CADElement;
|
||||
|
||||
const PATH = {
|
||||
id: 'path-1', type: 'polyline', layerId: 'layer-0',
|
||||
x: 250, y: 0, width: 500, height: 0,
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 500, y: 0 }] },
|
||||
} as unknown as CADElement;
|
||||
|
||||
beforeEach(() => {
|
||||
arrayPathTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
describe('D10: arrayPathTool', () => {
|
||||
it('verteilt count Kopien gleichmaessig entlang der Pfadlaenge (Original bleibt)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||
selectedIds.add('chair-1');
|
||||
// Klick1: Quelle ist schon selektiert -> Basis direkt setzen
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
// Klick2: auf den Pfad klicken (Pfad-Mitte 250,0)
|
||||
arrayPathTool.handlers.down!(pe(250, 0), ctx);
|
||||
|
||||
const els = doc.getAllElements();
|
||||
// Original + Pfad + 5 Kopien
|
||||
const chairs = els.filter((e) => e.type === 'chair');
|
||||
expect(chairs.length).toBe(1 + 5);
|
||||
// Kopien getrennt vom Original (Original bleibt bei x=0 unangetastet):
|
||||
const copies = chairs.filter((c) => c.id !== 'chair-1');
|
||||
expect(copies).toHaveLength(5);
|
||||
const copyXs = copies.map((c) => c.x).sort((a, b) => a - b);
|
||||
// count=5 gleichmaessig ueber Pfadlaenge 500: s = i/(count-1)*total
|
||||
expect(copyXs[0]).toBeCloseTo(0, 0);
|
||||
expect(copyXs[1]).toBeCloseTo(125, 0);
|
||||
expect(copyXs[2]).toBeCloseTo(250, 0);
|
||||
expect(copyXs[3]).toBeCloseTo(375, 0);
|
||||
expect(copyXs[4]).toBeCloseTo(500, 0);
|
||||
});
|
||||
|
||||
it('Auto-HitSelect bei leerer Selektion (Klick1 auf Element)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||
// Selektion ist leer: Klick auf den Stuhl waehlt ihn automatisch
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
expect(selectedIds.has('chair-1')).toBe(true);
|
||||
// Klick2 auf Pfad -> Commit
|
||||
arrayPathTool.handlers.down!(pe(100, 0), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair').length).toBe(6);
|
||||
});
|
||||
|
||||
it('Klick2 trifft KEINEN Pfad -> kein Commit, Status erklaert', () => {
|
||||
const { ctx, doc, selectedIds, statuses } = mkCtx([CHAIR, PATH]);
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
// Klick weit weg vom Pfad (kein Element getroffen)
|
||||
arrayPathTool.handlers.down!(pe(9999, 9999), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1);
|
||||
expect(statuses[statuses.length - 1]).toContain('Pfad');
|
||||
});
|
||||
|
||||
it('Kopien in EINER Transaktion = 1 Undo entfernt alle', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
arrayPathTool.handlers.down!(pe(250, 0), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(6);
|
||||
doc.undo();
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('count-Option steuert die Anzahl (3 statt 5)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH], { count: 3 });
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
arrayPathTool.handlers.down!(pe(250, 0), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(4); // Original + 3
|
||||
// Kopien getrennt vom Original: count=3 -> s = 0, 250, 500
|
||||
const copies3 = doc.getAllElements().filter((e) => e.type === 'chair' && e.id !== 'chair-1');
|
||||
const copyXs3 = copies3.map((c) => c.x).sort((a, b) => a - b);
|
||||
expect(copyXs3[0]).toBeCloseTo(0, 0);
|
||||
expect(copyXs3[1]).toBeCloseTo(250, 0);
|
||||
expect(copyXs3[2]).toBeCloseTo(500, 0);
|
||||
});
|
||||
|
||||
it('gebogener Pfad (3 Punkte): Kopien folgen der Geometrie', () => {
|
||||
const cornerPath = {
|
||||
id: 'path-2', type: 'polyline', layerId: 'layer-0',
|
||||
x: 100, y: 100, width: 200, height: 200,
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 200, y: 200 }] },
|
||||
} as unknown as CADElement;
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, cornerPath], { count: 4 });
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
arrayPathTool.handlers.down!(pe(100, 10), ctx);
|
||||
const chairs = doc.getAllElements().filter((e) => e.type === 'chair');
|
||||
expect(chairs.length).toBe(5); // Original + 4
|
||||
// Kopien muessen auf dem L-foermigen Pfad liegen: alle x<=200, y>=0
|
||||
for (const c of chairs.slice(1)) {
|
||||
expect(c.x).toBeLessThanOrEqual(200.5);
|
||||
expect(c.y).toBeGreaterThanOrEqual(-0.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('cancel resettet die Sitzung', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
arrayPathTool.handlers.cancel?.(ctx);
|
||||
// Nach cancel: Klick startet frisch (Auto-HitSelect-Pfad wieder aktiv)
|
||||
arrayPathTool.handlers.down!(pe(9999, 9999), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Manifest: id=array-path, tags pro', () => {
|
||||
expect(arrayPathTool.manifest.id).toBe('array-path');
|
||||
expect((arrayPathTool.manifest.tags ?? [])).toContain('pro');
|
||||
const keys = (arrayPathTool.optionsSchema ?? []).map((f) => f.key);
|
||||
expect(keys).toContain('count');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Task D12 - BlockAttribute: attrDefs + attrValues + Rendering.
|
||||
*
|
||||
* BlockDefinition.attrDefs[]: definiert Attribut-Slots (tag/prompt/
|
||||
* default). Instanz (block_instance) traegt attrValues. Der Render-
|
||||
* Adapter (elementToPrimitives) zeichnet die Attr-Werte als Text-
|
||||
* Primitive an der Instanz-Position.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import { elementToPrimitives } from '../src/render/pixi/elementDrawers';
|
||||
import type { CADElement, BlockDefinition } from '../src/types/cad.types';
|
||||
|
||||
const BLOCK: BlockDefinition = {
|
||||
id: 'blk-1',
|
||||
name: 'Titelblock',
|
||||
description: '',
|
||||
category: 'frame',
|
||||
elements: [],
|
||||
attrDefs: [
|
||||
{ tag: 'PROJEKT', prompt: 'Projektname', defaultValue: 'Unbenannt' },
|
||||
{ tag: 'AUTOR', prompt: 'Bearbeiter', defaultValue: 'WebCAD' },
|
||||
],
|
||||
};
|
||||
|
||||
function instance(attrValues: Record<string, string>): CADElement {
|
||||
return {
|
||||
id: 'inst-1', type: 'block_instance', layerId: 'layer-0',
|
||||
x: 100, y: 100, width: 0, height: 0,
|
||||
properties: { blockId: 'blk-1', scale: 1, attrValues },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
// ─── Typ-Ebene: attrDefs auf BlockDefinition ────────────────
|
||||
|
||||
describe('D12: BlockDefinition.attrDefs', () => {
|
||||
it('attrDefs-Feld existiert mit tag/prompt/defaultValue', () => {
|
||||
expect(BLOCK.attrDefs).toBeDefined();
|
||||
expect(BLOCK.attrDefs!.length).toBe(2);
|
||||
expect(BLOCK.attrDefs![0].tag).toBe('PROJEKT');
|
||||
expect(BLOCK.attrDefs![0].prompt).toBe('Projektname');
|
||||
expect(BLOCK.attrDefs![0].defaultValue).toBe('Unbenannt');
|
||||
});
|
||||
|
||||
it('CADDocument speichert Block mit attrDefs (getBlock liefert sie zurueck)', () => {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
doc.addBlock(BLOCK);
|
||||
const loaded = doc.getBlock('blk-1');
|
||||
expect(loaded!.attrDefs).toHaveLength(2);
|
||||
expect(loaded!.attrDefs![1].tag).toBe('AUTOR');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rendering: elementToPrimitives mit attrDefs ────────────
|
||||
|
||||
describe('D12: RenderAdapter zeichnet Attr-Texte', () => {
|
||||
it('Instanz mit attrValues erzeugt text-Primitives pro Attribut', () => {
|
||||
const el = instance({ PROJEKT: 'Halle A', AUTOR: 'L. Admin' });
|
||||
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||
const texts = prims.filter((p) => p.kind === 'text');
|
||||
expect(texts).toHaveLength(2);
|
||||
const proj = texts.find((t) => (t as { text: string }).text.includes('PROJEKT'));
|
||||
expect(proj).toBeDefined();
|
||||
expect((proj as { text: string }).text).toContain('Halle A');
|
||||
const autor = texts.find((t) => (t as { text: string }).text.includes('AUTOR'));
|
||||
expect((autor as { text: string }).text).toContain('L. Admin');
|
||||
});
|
||||
|
||||
it('Fehlender Wert faellt auf attrDefs.defaultValue zurueck', () => {
|
||||
const el = instance({});
|
||||
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||
const texts = prims.filter((p) => p.kind === 'text');
|
||||
const proj = texts.find((t) => (t as { text: string }).text.includes('PROJEKT'));
|
||||
expect((proj as { text: string }).text).toContain('Unbenannt');
|
||||
});
|
||||
|
||||
it('Ohne attrDefs-Parameter: kein Attr-Text-Rendering (abwaertskompatibel)', () => {
|
||||
const el = instance({ PROJEKT: 'Halle A' });
|
||||
const prims = elementToPrimitives(el);
|
||||
expect(prims.filter((p) => p.kind === 'text')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Instanz ohne attrValues mit attrDefs: defaults werden gezeichnet', () => {
|
||||
const el: CADElement = {
|
||||
id: 'inst-2', type: 'block_instance', layerId: 'layer-0',
|
||||
x: 0, y: 0, width: 0, height: 0,
|
||||
properties: { blockId: 'blk-1', scale: 1 },
|
||||
} as unknown as CADElement;
|
||||
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||
const texts = prims.filter((p) => p.kind === 'text');
|
||||
expect(texts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Text-Positionen gestaffelt unterhalb der Instanz-Position', () => {
|
||||
const el = instance({ PROJEKT: 'A', AUTOR: 'B' });
|
||||
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||
const texts = prims.filter((p) => p.kind === 'text') as Array<{ kind: 'text'; at: { x: number; y: number }; text: string }>;
|
||||
expect(texts[0].at.x).toBe(100);
|
||||
expect(texts[0].at.y).toBeCloseTo(100 - 8, 0);
|
||||
expect(texts[1].at.y).toBeLessThan(texts[0].at.y);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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,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,244 @@
|
||||
/**
|
||||
* Task D9 – GripEditing: Vertex-/Center-Grips im select-Tool.
|
||||
*
|
||||
* getGripPoints: element-spezifische Griff-Punkte (line=Endpunkte,
|
||||
* polyline=Vertices, circle/arc=Zentrum, rect=BBox-Ecken).
|
||||
* Drag eines Vertex-Grips verschiebt NUR diesen Punkt in einer
|
||||
* Transaktion (undo-fähig); BBox-Skalierung (C3fin) bleibt parallel
|
||||
* für Elemente ohne Vertex-Grips (rect u.a.).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { selectTool, getGripPoints } from '../src/plugins/builtin/core-modify';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||
|
||||
function mkCtx() {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
const selectedIds = new Set<string>();
|
||||
const previews: (CADElement | null)[] = [];
|
||||
const statuses: string[] = [];
|
||||
const ctx: any = {
|
||||
doc,
|
||||
options: {},
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (el: CADElement | null) => previews.push(el),
|
||||
selection: {
|
||||
getIds: () => Array.from(selectedIds),
|
||||
setIds: (ids: string[]) => {
|
||||
selectedIds.clear();
|
||||
for (const id of ids) selectedIds.add(id);
|
||||
},
|
||||
hitTest: (x: number, y: number, tol: number) => {
|
||||
// Einfacher Hit-Test: trifft wenn ein Element-BBox den Punkt enthaelt
|
||||
for (const el of doc.getAllElements()) {
|
||||
const p = el.properties as Record<string, unknown>;
|
||||
if (p.x1 !== undefined) {
|
||||
const minX = Math.min(p.x1 as number, p.x2 as number);
|
||||
const maxX = Math.max(p.x1 as number, p.x2 as number);
|
||||
const minY = Math.min(p.y1 as number, p.y2 as number);
|
||||
const maxY = Math.max(p.y1 as number, p.y2 as number);
|
||||
if (x >= minX - tol && x <= maxX + tol && y >= minY - tol && y <= maxY + tol) return el;
|
||||
}
|
||||
if (x >= el.x - el.width / 2 - tol && x <= el.x + el.width / 2 + tol &&
|
||||
y >= el.y - el.height / 2 - tol && y <= el.y + el.height / 2 + tol) return el;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
return { ctx, doc, selectedIds, previews, statuses };
|
||||
}
|
||||
|
||||
function pe(x: number, y: number, mods: { shift?: boolean; ctrl?: boolean } = {}): { world: Pt; shift: boolean; ctrl: boolean } {
|
||||
return { world: { x, y }, shift: mods.shift ?? false, ctrl: mods.ctrl ?? false } as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
selectTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
// ─── getGripPoints (reine Funktion) ────────────────────────
|
||||
|
||||
describe('D9: getGripPoints', () => {
|
||||
it('line: beide Endpunkte als Grips', () => {
|
||||
const el = {
|
||||
id: 'l1', type: 'line', layerId: 'l', x: 50, y: 50, width: 100, height: 0,
|
||||
properties: { x1: 0, y1: 50, x2: 100, y2: 50 },
|
||||
} as unknown as CADElement;
|
||||
const grips = getGripPoints(el);
|
||||
expect(grips.map((g) => g.kind)).toEqual(['vertex', 'vertex']);
|
||||
expect(grips[0].point).toEqual({ x: 0, y: 50 });
|
||||
expect(grips[1].point).toEqual({ x: 100, y: 50 });
|
||||
});
|
||||
|
||||
it('polyline: alle Vertices als Grips', () => {
|
||||
const el = {
|
||||
id: 'p1', type: 'polyline', layerId: 'l', x: 50, y: 0, width: 100, height: 100,
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }] },
|
||||
} as unknown as CADElement;
|
||||
const grips = getGripPoints(el);
|
||||
expect(grips.length).toBe(3);
|
||||
expect(grips.every((g) => g.kind === 'vertex')).toBe(true);
|
||||
expect(grips[2].point).toEqual({ x: 100, y: 100 });
|
||||
});
|
||||
|
||||
it('circle: Zentrum als Center-Grip', () => {
|
||||
const el = {
|
||||
id: 'c1', type: 'circle', layerId: 'l', x: 30, y: 40, width: 20, height: 20,
|
||||
properties: { radius: 10 },
|
||||
} as unknown as CADElement;
|
||||
const grips = getGripPoints(el);
|
||||
expect(grips).toHaveLength(1);
|
||||
expect(grips[0].kind).toBe('center');
|
||||
expect(grips[0].point).toEqual({ x: 30, y: 40 });
|
||||
});
|
||||
|
||||
it('rect: BBox-Ecken als Corner-Grips (C3fin bleibt)', () => {
|
||||
const el = {
|
||||
id: 'r1', type: 'rect', layerId: 'l', x: 50, y: 25, width: 100, height: 50,
|
||||
properties: {},
|
||||
} as unknown as CADElement;
|
||||
const grips = getGripPoints(el);
|
||||
expect(grips.length).toBe(4);
|
||||
expect(grips.every((g) => g.kind === 'corner')).toBe(true);
|
||||
expect(grips[0].point).toEqual({ x: 0, y: 0 });
|
||||
expect(grips[2].point).toEqual({ x: 100, y: 50 });
|
||||
});
|
||||
|
||||
it('Grips tragen index fuer Drag-Zuordnung', () => {
|
||||
const el = {
|
||||
id: 'p1', type: 'polyline', layerId: 'l', x: 0, y: 0, width: 10, height: 10,
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 10, y: 10 }] },
|
||||
} as unknown as CADElement;
|
||||
const grips = getGripPoints(el);
|
||||
expect(grips[0].index).toBe(0);
|
||||
expect(grips[1].index).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Drag-Interaktion: Vertex-Zug (undo-faehig) ─────────────
|
||||
|
||||
describe('D9: Vertex-Drag im selectTool', () => {
|
||||
it('Linien-Endpunkt ziehen verschiebt NUR diesen Punkt (1 Undo)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx();
|
||||
const line = {
|
||||
id: 'l1', type: 'line', layerId: 'l', x: 50, y: 0, width: 100, height: 0,
|
||||
properties: { x1: 0, y1: 0, x2: 100, y2: 0 },
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(line);
|
||||
selectedIds.add('l1');
|
||||
|
||||
// Klick auf Endpunkt (100,0) startet Vertex-Drag
|
||||
selectTool.handlers.down!(pe(100, 0), ctx);
|
||||
selectTool.handlers.up!(pe(120, 30), ctx);
|
||||
|
||||
const updated = doc.getElement('l1')!;
|
||||
expect(updated.properties.x2).toBe(120);
|
||||
expect(updated.properties.y2).toBe(30);
|
||||
// Anderer Endpunkt unveraendert
|
||||
expect(updated.properties.x1).toBe(0);
|
||||
expect(updated.properties.y1).toBe(0);
|
||||
// Undo stellt her
|
||||
doc.undo();
|
||||
const restored = doc.getElement('l1')!;
|
||||
expect(restored.properties.x2).toBe(100);
|
||||
expect(restored.properties.y2).toBe(0);
|
||||
});
|
||||
|
||||
it('Polyline-Vertex ziehen aktualisiert nur diesen Vertex', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx();
|
||||
const poly = {
|
||||
id: 'p1', type: 'polyline', layerId: 'l', x: 50, y: 50, width: 100, height: 100,
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }] },
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(poly);
|
||||
selectedIds.add('p1');
|
||||
|
||||
// Mittleren Vertex (100,0) greifen und auf (100,50) ziehen
|
||||
selectTool.handlers.down!(pe(100, 0), ctx);
|
||||
selectTool.handlers.up!(pe(100, 50), ctx);
|
||||
|
||||
const pts = doc.getElement('p1')!.properties.points as Pt[];
|
||||
expect(pts[1]).toEqual({ x: 100, y: 50 });
|
||||
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||||
expect(pts[2]).toEqual({ x: 100, y: 100 });
|
||||
});
|
||||
|
||||
it('Kreis-Zentrum ziehen verschiebt x/y (Radius bleibt)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx();
|
||||
const circle = {
|
||||
id: 'c1', type: 'circle', layerId: 'l', x: 30, y: 40, width: 20, height: 20,
|
||||
properties: { radius: 10 },
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(circle);
|
||||
selectedIds.add('c1');
|
||||
|
||||
selectTool.handlers.down!(pe(30, 40), ctx);
|
||||
selectTool.handlers.up!(pe(60, 80), ctx);
|
||||
|
||||
const updated = doc.getElement('c1')!;
|
||||
expect(updated.x).toBe(60);
|
||||
expect(updated.y).toBe(80);
|
||||
expect(updated.properties.radius).toBe(10);
|
||||
});
|
||||
|
||||
it('BBox-Corner-Drag bei rect funktioniert weiterhin (Skalierung)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx();
|
||||
const rect = {
|
||||
id: 'r1', type: 'rect', layerId: 'l', x: 50, y: 25, width: 100, height: 50,
|
||||
properties: {},
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(rect);
|
||||
selectedIds.add('r1');
|
||||
|
||||
// Ecke unten-rechts (100,50) greifen und auf (150,75) ziehen
|
||||
selectTool.handlers.down!(pe(100, 50), ctx);
|
||||
selectTool.handlers.up!(pe(150, 75), ctx);
|
||||
|
||||
const updated = doc.getElement('r1')!;
|
||||
expect(updated.width).toBeCloseTo(150, 0);
|
||||
expect(updated.height).toBeCloseTo(75, 0);
|
||||
});
|
||||
|
||||
it('Vertex-Grip gewinnt gegen BBox-Corner bei Ueberlappung (line-Endpunkt am BBox-Rand)', () => {
|
||||
// line (0,0)-(100,0): Endpunkt (0,0) ist AUCH BBox-Ecke.
|
||||
// Der Drag muss als Vertex (Punkt verschieben) wirken, nicht als Scale.
|
||||
const { ctx, doc, selectedIds } = mkCtx();
|
||||
const line = {
|
||||
id: 'l1', type: 'line', layerId: 'l', x: 50, y: 0, width: 100, height: 0,
|
||||
properties: { x1: 0, y1: 0, x2: 100, y2: 0 },
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(line);
|
||||
selectedIds.add('l1');
|
||||
|
||||
selectTool.handlers.down!(pe(0, 0), ctx);
|
||||
selectTool.handlers.up!(pe(-30, -20), ctx);
|
||||
|
||||
const updated = doc.getElement('l1')!;
|
||||
// x1/y1 verschoben, x2 unveraendert → Vertex-Drag, kein Scale
|
||||
expect(updated.properties.x1).toBe(-30);
|
||||
expect(updated.properties.y1).toBe(-20);
|
||||
expect(updated.properties.x2).toBe(100);
|
||||
});
|
||||
|
||||
it('Preview bei Vertex-Drag zeigt aktualisiertes Element', () => {
|
||||
const { ctx, doc, selectedIds, previews } = mkCtx();
|
||||
const line = {
|
||||
id: 'l1', type: 'line', layerId: 'l', x: 50, y: 0, width: 100, height: 0,
|
||||
properties: { x1: 0, y1: 0, x2: 100, y2: 0 },
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(line);
|
||||
selectedIds.add('l1');
|
||||
|
||||
selectTool.handlers.down!(pe(100, 0), ctx);
|
||||
selectTool.handlers.move!(pe(90, 40), ctx);
|
||||
expect(previews.length).toBeGreaterThanOrEqual(1);
|
||||
const last = previews[previews.length - 1];
|
||||
expect(last).not.toBeNull();
|
||||
const lastProps = last!.properties as Record<string, unknown>;
|
||||
expect(lastProps.x2).toBe(90);
|
||||
expect(lastProps.y2).toBe(40);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Task H4 – i18n-Grundlage: zentrale strings.ts Maps de/en.
|
||||
*
|
||||
* strings-Modul: t(key) mit de/en-Maps, getLanguage/setLanguage
|
||||
* (localStorage-persistiert, Default de), subscribe + useT-Hook.
|
||||
* Kern-UI-Texte (LayoutBar, Modus-Umschaltung) laufen ueber die
|
||||
* Maps; vollstaendige mechanische Ersetzung aller Komponenten ist
|
||||
* teilautomatisierbares Follow-up (Roadmap-Vermerk).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
|
||||
import {
|
||||
t, getLanguage, setLanguage, subscribeLanguage, useT, LANGUAGES,
|
||||
} from '../src/i18n/strings';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ––– strings-Modul ––––––––––––––––––––––––
|
||||
|
||||
describe('H4: i18n strings', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setLanguage('de');
|
||||
});
|
||||
|
||||
it('Default-Sprache ist de', () => {
|
||||
expect(getLanguage()).toBe('de');
|
||||
});
|
||||
|
||||
it('t liefert deutsche Kern-UI-Texte', () => {
|
||||
expect(t('layouts.title')).toBe('Layouts');
|
||||
expect(t('layouts.add')).toBe('Layout hinzufügen');
|
||||
expect(t('layouts.empty')).toBe('Keine Layouts');
|
||||
});
|
||||
|
||||
it('setLanguage(en) wechselt auf englische Texte', () => {
|
||||
setLanguage('en');
|
||||
expect(t('layouts.title')).toBe('Layouts');
|
||||
expect(t('layouts.add')).toBe('Add layout');
|
||||
expect(t('layouts.empty')).toBe('No layouts');
|
||||
});
|
||||
|
||||
it('Sprache persistiert in localStorage und ueberlebt Reload-Semantik', () => {
|
||||
setLanguage('en');
|
||||
expect(localStorage.getItem('webcad.language')).toBe('en');
|
||||
// erneutes Lesen startet aus persistiertem Zustand
|
||||
expect(getLanguage()).toBe('en');
|
||||
});
|
||||
|
||||
it('LANGUAGES enthaelt genau de und en', () => {
|
||||
expect(LANGUAGES).toEqual(['de', 'en']);
|
||||
});
|
||||
|
||||
it('t mit unbekanntem Key liefert den Key zurueck (Fallback, lint-bar)', () => {
|
||||
setLanguage('de');
|
||||
expect(t('gibts.nicht')).toBe('gibts.nicht');
|
||||
});
|
||||
|
||||
it('subscribeLanguage feuert bei Wechsel und unsubscribed', () => {
|
||||
const cb = vi.fn();
|
||||
const off = subscribeLanguage(cb);
|
||||
setLanguage('en');
|
||||
expect(cb).toHaveBeenCalledWith('en');
|
||||
off();
|
||||
cb.mockClear();
|
||||
setLanguage('de');
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ––– useT-Hook + Komponente ––––––––––––––––––
|
||||
|
||||
describe('H4: useT-Hook rendert LayoutBar zweisprachig', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setLanguage('de');
|
||||
});
|
||||
|
||||
it('LayoutBar zeigt deutsche Texte und wechselt live auf englisch', async () => {
|
||||
const LayoutBar = (await import('../src/components/LayoutBar')).default;
|
||||
render(<LayoutBar doc={null} bridge={null} />);
|
||||
expect(screen.getByText('Layouts')).toBeTruthy();
|
||||
expect(screen.getByText('Keine Layouts')).toBeTruthy();
|
||||
expect(screen.getByTitle('Layout hinzufügen')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
setLanguage('en');
|
||||
});
|
||||
// Live-Update via subscribe (nach act flush)
|
||||
expect(screen.getByText('No layouts')).toBeTruthy();
|
||||
expect(screen.getByTitle('Add layout')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Task H5 – Flächenberechnung Polygon (m²) + measure-area Tool.
|
||||
*
|
||||
* polygonArea: Shoelace-Formel (Absolutwert, CW/CCW egal),
|
||||
* formatArea: Einheiten-Formatierung analog formatDistance,
|
||||
* measureAreaTool: Klick-Sequenz, Live-Fläche im Status, ENTER
|
||||
* committet ab 3 Punkten, cancel setzt zurück.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { polygonArea } from '../src/tools/modification/geometry';
|
||||
import { formatArea } from '../src/utils/format';
|
||||
import { measureAreaTool } from '../src/plugins/builtin/core-measure';
|
||||
import type { CADElement } from '../src/types/cad.types';
|
||||
|
||||
describe('H5: polygonArea (Shoelace)', () => {
|
||||
it('Rechteck 100×50 = 5000 Welt-Einheiten²', () => {
|
||||
const pts = [
|
||||
{ x: 0, y: 0 }, { x: 100, y: 0 },
|
||||
{ x: 100, y: 50 }, { x: 0, y: 50 },
|
||||
];
|
||||
expect(polygonArea(pts)).toBeCloseTo(5000, 6);
|
||||
});
|
||||
|
||||
it('Dreieck 100×50/2 = 2500', () => {
|
||||
const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 0, y: 50 }];
|
||||
expect(polygonArea(pts)).toBeCloseTo(2500, 6);
|
||||
});
|
||||
|
||||
it('Uhrzeigersinn und gegen Uhrzeigersinn ergeben denselben Absolutwert', () => {
|
||||
const ccw = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }];
|
||||
const cw = [...ccw].reverse();
|
||||
expect(polygonArea(cw)).toBeCloseTo(polygonArea(ccw), 6);
|
||||
expect(polygonArea(cw)).toBeCloseTo(10000, 6);
|
||||
});
|
||||
|
||||
it('weniger als 3 Punkte → 0; kollineare Punkte → 0', () => {
|
||||
expect(polygonArea([{ x: 0, y: 0 }, { x: 1, y: 1 }])).toBe(0);
|
||||
expect(polygonArea([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 200, y: 0 }])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('H5: formatArea', () => {
|
||||
it('mm: 5000 → "5000 mm²" (scaleFactor 1)', () => {
|
||||
expect(formatArea(5000, 'mm', 1)).toBe('5000 mm²');
|
||||
});
|
||||
|
||||
it('cm: 5000mm² → "50.0 cm²"', () => {
|
||||
expect(formatArea(5000, 'cm', 1)).toBe('50.0 cm²');
|
||||
});
|
||||
|
||||
it('m: 5000000mm² → "5.00 m²"', () => {
|
||||
expect(formatArea(5000000, 'm', 1)).toBe('5.00 m²');
|
||||
});
|
||||
|
||||
it('Flächen skalieren QUADRATISCH: 5 WE² bei scaleFactor 1000 = 5000000 mm²', () => {
|
||||
// 1 Welt-Einheit = 1000mm → 1 WE² = 1.000.000 mm²
|
||||
expect(formatArea(5, 'mm', 1000)).toBe('5000000 mm²');
|
||||
});
|
||||
});
|
||||
|
||||
describe('H5: measureAreaTool', () => {
|
||||
// Module-State areaPts leakt zwischen Tests — vor jedem Test resetten:
|
||||
beforeEach(() => {
|
||||
measureAreaTool.handlers.cancel?.({ setStatus: () => {} } as never);
|
||||
});
|
||||
function fireSeq(events: Array<{ type: 'down' | 'move' | 'key' | 'cancel'; x?: number; y?: number; key?: string }>, options: Record<string, unknown> = {}) {
|
||||
const statuses: string[] = [];
|
||||
const ctx: any = {
|
||||
options,
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (_el: CADElement | null) => {},
|
||||
doc: null,
|
||||
};
|
||||
for (const ev of events) {
|
||||
if (ev.type === 'down') measureAreaTool.handlers.down!({ world: { x: ev.x!, y: ev.y! } } as never, ctx);
|
||||
if (ev.type === 'move') measureAreaTool.handlers.move!({ world: { x: ev.x!, y: ev.y! } } as never, ctx);
|
||||
if (ev.type === 'key' && ev.key) measureAreaTool.handlers.key?.({ key: ev.key } as never, ctx);
|
||||
if (ev.type === 'cancel') measureAreaTool.handlers.cancel?.(ctx);
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
it('erster Klick startet Messung, Status fordert weitere Punkte', () => {
|
||||
const s = fireSeq([{ type: 'down', x: 0, y: 0 }]);
|
||||
expect(s[0]).toContain('Fläche');
|
||||
expect(s[s.length - 1]).toContain('klicken');
|
||||
});
|
||||
|
||||
it('Live-Fläche im Status ab 3 Punkten bei move', () => {
|
||||
const s = fireSeq([
|
||||
{ type: 'down', x: 0, y: 0 },
|
||||
{ type: 'down', x: 100, y: 0 },
|
||||
{ type: 'down', x: 100, y: 50 },
|
||||
{ type: 'move', x: 0, y: 50 },
|
||||
]);
|
||||
expect(s.some((m) => m.includes('5000'))).toBe(true);
|
||||
expect(s[s.length - 1]).toContain('mm²');
|
||||
});
|
||||
|
||||
it('ENTER committet ab 3 Punkten mit Endfläche im Status', () => {
|
||||
const s = fireSeq([
|
||||
{ type: 'down', x: 0, y: 0 },
|
||||
{ type: 'down', x: 100, y: 0 },
|
||||
{ type: 'down', x: 100, y: 100 },
|
||||
{ type: 'key', key: 'Enter' },
|
||||
]);
|
||||
// Dreieck (0,0),(100,0),(100,100): Shoelace = 1/2 · 100 · 100 = 5000
|
||||
expect(s[s.length - 1]).toContain('5000');
|
||||
expect(s[s.length - 1]).toContain('3 Punkte');
|
||||
});
|
||||
|
||||
it('ENTER mit weniger als 3 Punkten committet NICHT (Status erklärt)', () => {
|
||||
const s = fireSeq([
|
||||
{ type: 'down', x: 0, y: 0 },
|
||||
{ type: 'down', x: 100, y: 0 },
|
||||
{ type: 'key', key: 'Enter' },
|
||||
]);
|
||||
expect(s[s.length - 1]).toContain('3');
|
||||
});
|
||||
|
||||
it('cancel setzt Messung zurück', () => {
|
||||
const s = fireSeq([
|
||||
{ type: 'down', x: 0, y: 0 },
|
||||
{ type: 'down', x: 50, y: 50 },
|
||||
{ type: 'cancel' },
|
||||
{ type: 'down', x: 0, y: 0 },
|
||||
]);
|
||||
// Nach cancel startet eine frische Messung (kein altes Polygon)
|
||||
expect(s[s.length - 1]).toContain('klicken');
|
||||
});
|
||||
|
||||
it('Manifest: id measure-area, tags basic, ribbonTab canvas', () => {
|
||||
expect(measureAreaTool.manifest.id).toBe('measure-area');
|
||||
expect(measureAreaTool.manifest.ribbonTab).toBe('canvas');
|
||||
expect((measureAreaTool.manifest.tags ?? [])).toContain('basic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Task D4 – MTEXT-Tool: Mehrzeiliger Text mit/ohne Rahmen.
|
||||
*
|
||||
* Klick platziert mehrzeiligen Text (Inhalt aus Options, Zeilen-
|
||||
* umbrueche), fontSize-Option, optionale Rahmen-Option erzeugt ein
|
||||
* rect-Element in derselben Transaktion (1 Undo-Schritt).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mtextTool } from '../src/plugins/builtin/core-annotate';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||
|
||||
function mkCtx(options: Record<string, unknown> = {}) {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
const statuses: string[] = [];
|
||||
const ctx: any = {
|
||||
doc,
|
||||
options,
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (_el: CADElement | null) => {},
|
||||
};
|
||||
return { ctx, doc, statuses };
|
||||
}
|
||||
|
||||
function pe(x: number, y: number): { world: Pt } {
|
||||
return { world: { x, y } } as never;
|
||||
}
|
||||
|
||||
describe('D4: mtextTool', () => {
|
||||
it('platziert mehrzeiligen Text mit Zeilenumbruechen aus den Options', () => {
|
||||
const { ctx, doc } = mkCtx({ text: 'Zeile 1\nZeile 2\nZeile 3', fontSize: 14 });
|
||||
mtextTool.handlers.down!(pe(100, 50), ctx);
|
||||
const els = doc.getAllElements();
|
||||
expect(els).toHaveLength(1);
|
||||
const el = els[0];
|
||||
expect(el.type).toBe('text');
|
||||
expect(el.x).toBe(100);
|
||||
expect(el.y).toBe(50);
|
||||
expect(el.properties.text).toBe('Zeile 1\nZeile 2\nZeile 3');
|
||||
expect(el.properties.fontSize).toBe(14);
|
||||
expect(el.properties.mtext).toBe(true);
|
||||
});
|
||||
|
||||
it('Rahmen-Option erzeugt zusaetzliches rect in derselben Transaktion (1 Undo)', () => {
|
||||
const { ctx, doc } = mkCtx({ text: 'Mit Rahmen', frame: true });
|
||||
mtextTool.handlers.down!(pe(0, 0), ctx);
|
||||
const els = doc.getAllElements();
|
||||
expect(els).toHaveLength(2);
|
||||
const texts = els.filter((e) => e.type === 'text');
|
||||
const rects = els.filter((e) => e.type === 'rect');
|
||||
expect(texts).toHaveLength(1);
|
||||
expect(rects).toHaveLength(1);
|
||||
expect(rects[0].x).toBeCloseTo(0, 5);
|
||||
expect(rects[0].y).toBeCloseTo(0, 5);
|
||||
doc.undo();
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ohne frame-Option: nur Text, kein rect', () => {
|
||||
const { ctx, doc } = mkCtx({ text: 'Ohne Rahmen' });
|
||||
mtextTool.handlers.down!(pe(0, 0), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'rect')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Standard-Text wenn Options leer: MTEXT-Platzhalter leer (Editor bearbeitet spaeter)', () => {
|
||||
const { ctx, doc } = mkCtx({});
|
||||
mtextTool.handlers.down!(pe(10, 10), ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
expect(el.properties.text).toBe('');
|
||||
expect(el.properties.fontSize).toBe(12); // Default
|
||||
});
|
||||
|
||||
it('OptionsSchema: text/number/checkbox-Felder', () => {
|
||||
const schema = mtextTool.optionsSchema ?? [];
|
||||
const keys = schema.map((f) => f.key);
|
||||
expect(keys).toContain('text');
|
||||
expect(keys).toContain('fontSize');
|
||||
expect(keys).toContain('frame');
|
||||
const textField = schema.find((f) => f.key === 'text');
|
||||
expect(textField!.type).toBe('text');
|
||||
const frameField = schema.find((f) => f.key === 'frame');
|
||||
expect(frameField!.type).toBe('checkbox');
|
||||
});
|
||||
|
||||
it('Rahmenhoehe waechst mit Zeilenzahl', () => {
|
||||
const { ctx: ctx1, doc: doc1 } = mkCtx({ text: 'eine Zeile', frame: true });
|
||||
mtextTool.handlers.down!(pe(0, 0), ctx1);
|
||||
const { ctx: ctx3, doc: doc3 } = mkCtx({ text: 'eins\nzwei\ndrei', frame: true });
|
||||
mtextTool.handlers.down!(pe(0, 0), ctx3);
|
||||
const rect1 = doc1.getAllElements().find((e) => e.type === 'rect')!;
|
||||
const rect3 = doc3.getAllElements().find((e) => e.type === 'rect')!;
|
||||
expect(rect3.height).toBeGreaterThan(rect1.height);
|
||||
});
|
||||
|
||||
it('Manifest: id=mtext, tags basic', () => {
|
||||
expect(mtextTool.manifest.id).toBe('mtext');
|
||||
expect((mtextTool.manifest.tags ?? [])).toContain('basic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Task H2 – Projekt-Templates: JSON-Presets für Branchen-Szenarien.
|
||||
*
|
||||
* Sechs Presets (halle/openair/saal/messe/buero/garten), jedes mit
|
||||
* gridSize/unit, Layer-Set, enabled plugins und library folder seed.
|
||||
* applyProjectTemplate überträgt die Layer in ein CADDocument
|
||||
* (bestehende Layer werden ersetzt — Template = Startzustand).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import {
|
||||
PROJECT_TEMPLATES,
|
||||
getProjectTemplate,
|
||||
applyProjectTemplate,
|
||||
type ProjectTemplate,
|
||||
} from '../src/services/projectTemplates';
|
||||
|
||||
const EXPECTED_IDS = ['halle', 'openair', 'saal', 'messe', 'buero', 'garten'];
|
||||
|
||||
describe('H2: Projekt-Templates', () => {
|
||||
it('enthält genau die 6 Branchen-Presets mit eindeutigen IDs', () => {
|
||||
expect(PROJECT_TEMPLATES.map((t) => t.id).sort()).toEqual([...EXPECTED_IDS].sort());
|
||||
expect(new Set(PROJECT_TEMPLATES.map((t) => t.id)).size).toBe(6);
|
||||
});
|
||||
|
||||
it('jedes Preset hat Name, gridSize, unit, Layer (>=2), enabledPlugins und libraryFolderSeed', () => {
|
||||
for (const tpl of PROJECT_TEMPLATES) {
|
||||
expect(tpl.name.length).toBeGreaterThan(2);
|
||||
expect(tpl.gridSize).toBeGreaterThan(0);
|
||||
expect(['mm', 'cm', 'm']).toContain(tpl.unit);
|
||||
expect(tpl.layers.length).toBeGreaterThanOrEqual(2);
|
||||
expect(tpl.enabledPlugins.length).toBeGreaterThan(0);
|
||||
expect(tpl.libraryFolderSeed.length).toBeGreaterThan(2);
|
||||
// Layer haben gültige CADLayer-Struktur mit eindeutigen IDs
|
||||
const layerIds = new Set(tpl.layers.map((l) => l.id));
|
||||
expect(layerIds.size).toBe(tpl.layers.length);
|
||||
expect(tpl.layers.every((l) => l.name.length > 0 && typeof l.visible === 'boolean')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('Szenario-spezifika: halle/openair/saal aktivieren event-tools, garten landscape', () => {
|
||||
const halle = getProjectTemplate('halle')!;
|
||||
expect(halle.enabledPlugins).toContain('event-tools');
|
||||
const openair = getProjectTemplate('openair')!;
|
||||
expect(openair.enabledPlugins).toContain('event-tools');
|
||||
const saal = getProjectTemplate('saal')!;
|
||||
expect(saal.enabledPlugins).toContain('event-tools');
|
||||
const garten = getProjectTemplate('garten')!;
|
||||
expect(garten.libraryFolderSeed.toLowerCase()).toContain('garten');
|
||||
const buero = getProjectTemplate('buero')!;
|
||||
expect(buero.unit).toBe('mm');
|
||||
});
|
||||
|
||||
it('getProjectTemplate liefert undefined für unbekannte ID', () => {
|
||||
expect(getProjectTemplate('gibts-nicht')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('H2: applyProjectTemplate', () => {
|
||||
it('setzt die Template-Layer im CADDocument (ersetzt bestehende)', () => {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
// Bestehenden Layer simulieren
|
||||
ydoc.data.layers.set('alt', {
|
||||
id: 'alt', name: 'Alt', visible: true, locked: false,
|
||||
color: '#fff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
|
||||
});
|
||||
const tpl = getProjectTemplate('halle')!;
|
||||
applyProjectTemplate(doc, tpl);
|
||||
const layers = doc.getAllLayers();
|
||||
expect(layers.map((l) => l.id)).toEqual(tpl.layers.map((l) => l.id));
|
||||
expect(layers.find((l) => l.id === 'alt')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Template-Layer sind danach über CADDocument lesbar', () => {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
const tpl = getProjectTemplate('buero')!;
|
||||
applyProjectTemplate(doc, tpl);
|
||||
const first = doc.getLayer(tpl.layers[0].id);
|
||||
expect(first).toBeDefined();
|
||||
expect(first!.name).toBe(tpl.layers[0].name);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Task D11 - Snap-Erweiterungen: tangent, perpendicular, quadrant, fromOffset.
|
||||
*
|
||||
* quadrant: Kreis-Quadrantenpunkte (0/90/180/270 auf dem Radius).
|
||||
* perpendicular: Fuspunkt auf Linie senkrecht zum Referenzpunkt.
|
||||
* tangent: Tangentialpunkte am Kreis vom Referenzpunkt.
|
||||
* fromOffset: Basispunkt + dx/dy-Eingabefeld.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SnapEngine, fromOffset, type SnapConfig } from '../src/canvas/SnapEngine';
|
||||
import type { CADElement } from '../src/types/cad.types';
|
||||
|
||||
function cfg(modes: string[], extra: Partial<SnapConfig> = {}): Partial<SnapConfig> {
|
||||
return {
|
||||
enabled: true,
|
||||
modes: new Set(modes as never),
|
||||
tolerance: 10,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function circle(id: string, cx: number, cy: number, r: number): CADElement {
|
||||
return {
|
||||
id, type: 'circle', layerId: 'l',
|
||||
x: cx, y: cy, width: r * 2, height: r * 2,
|
||||
properties: { radius: r },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
function line(id: string, x1: number, y1: number, x2: number, y2: number): CADElement {
|
||||
return {
|
||||
id, type: 'line', layerId: 'l',
|
||||
x: (x1 + x2) / 2, y: (y1 + y2) / 2,
|
||||
width: Math.abs(x2 - x1), height: Math.abs(y2 - y1),
|
||||
properties: { x1, y1, x2, y2 },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
// ─── Quadrant ──────────────────────────────────────────────
|
||||
|
||||
describe('D11: quadrant snap', () => {
|
||||
it('Kreis r=50: alle 4 Quadrantenpunkte werden geliefert', () => {
|
||||
const eng = new SnapEngine(cfg(['quadrant']));
|
||||
eng.setElements([circle('c1', 100, 100, 50)]);
|
||||
// Cursor in der Naehe des 0-Quadranten (150,100)
|
||||
const res = eng.snap(148, 100);
|
||||
expect(res.point).not.toBeNull();
|
||||
expect(res.point!.type).toBe('quadrant');
|
||||
expect(res.point!.x).toBe(150);
|
||||
expect(res.point!.y).toBe(100);
|
||||
});
|
||||
|
||||
it('90-Quadrant (100,50) wird gefunden', () => {
|
||||
const eng = new SnapEngine(cfg(['quadrant']));
|
||||
eng.setElements([circle('c1', 100, 100, 50)]);
|
||||
const res = eng.snap(100, 52);
|
||||
expect(res.point!.x).toBe(100);
|
||||
expect(res.point!.y).toBe(50);
|
||||
expect(res.point!.type).toBe('quadrant');
|
||||
});
|
||||
|
||||
it('270-Quadrant (unten) wird gefunden', () => {
|
||||
const eng = new SnapEngine(cfg(['quadrant']));
|
||||
eng.setElements([circle('c1', 100, 100, 50)]);
|
||||
const res = eng.snap(100, 148);
|
||||
expect(res.point!.y).toBe(150);
|
||||
expect(res.point!.type).toBe('quadrant');
|
||||
});
|
||||
|
||||
it('Modus nicht aktiviert -> kein quadrant snap', () => {
|
||||
const eng = new SnapEngine(cfg(['endpoint']));
|
||||
eng.setElements([circle('c1', 100, 100, 50)]);
|
||||
const res = eng.snap(148, 100);
|
||||
expect(res.point?.type ?? null).not.toBe('quadrant');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Perpendicular ─────────────────────────────────────────
|
||||
|
||||
describe('D11: perpendicular snap', () => {
|
||||
it('Fuspunkt senkrecht vom refPoint auf horizontale Linie', () => {
|
||||
const eng = new SnapEngine(cfg(['perpendicular']));
|
||||
// Horizontale Linie y=100, refPoint (50,50): Fuspunkt (50,100)
|
||||
eng.setElements([line('l1', 0, 100, 200, 100)]);
|
||||
const res = eng.snap(52, 98, { x: 50, y: 50 });
|
||||
expect(res.point).not.toBeNull();
|
||||
expect(res.point!.type).toBe('perpendicular');
|
||||
expect(res.point!.x).toBe(50);
|
||||
expect(res.point!.y).toBe(100);
|
||||
});
|
||||
|
||||
it('Schrge Linie: Fuspunkt berechnet', () => {
|
||||
const eng = new SnapEngine(cfg(['perpendicular']));
|
||||
// Linie (0,0)-(100,100), refPoint (100,0): Fuspunkt (50,50)
|
||||
eng.setElements([line('l1', 0, 0, 100, 100)]);
|
||||
const res = eng.snap(51, 49, { x: 100, y: 0 });
|
||||
expect(res.point!.x).toBeCloseTo(50, 0);
|
||||
expect(res.point!.y).toBeCloseTo(50, 0);
|
||||
});
|
||||
|
||||
it('ohne refPoint kein perpendicular snap (Modus still)', () => {
|
||||
const eng = new SnapEngine(cfg(['perpendicular']));
|
||||
eng.setElements([line('l1', 0, 100, 200, 100)]);
|
||||
const res = eng.snap(50, 98);
|
||||
expect(res.point?.type ?? null).not.toBe('perpendicular');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tangent ───────────────────────────────────────────────
|
||||
|
||||
describe('D11: tangent snap', () => {
|
||||
it('Tangentialpunkt am Kreis von refPoint (rechts vom Kreis)', () => {
|
||||
const eng = new SnapEngine(cfg(['tangent']));
|
||||
// Kreis (100,100) r=50, refPoint (200,100), d=100:
|
||||
// halfAng = acos(r/d) = 60° → Tangentenpunkte bei (125, 143.3)/(125, 56.7)
|
||||
eng.setElements([circle('c1', 100, 100, 50)]);
|
||||
const res = eng.snap(127, 145, { x: 200, y: 100 });
|
||||
expect(res.point).not.toBeNull();
|
||||
expect(res.point!.type).toBe('tangent');
|
||||
expect(res.point!.x).toBeCloseTo(125, 0);
|
||||
expect(res.point!.y).toBeCloseTo(143.3, 0);
|
||||
});
|
||||
|
||||
it('refPoint AUF dem Kreiszentrum -> kein Tangentenpunkt (degeneriert)', () => {
|
||||
const eng = new SnapEngine(cfg(['tangent']));
|
||||
eng.setElements([circle('c1', 100, 100, 50)]);
|
||||
const res = eng.snap(148, 100, { x: 100, y: 100 });
|
||||
expect(res.point?.type ?? null).not.toBe('tangent');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fromOffset ────────────────────────────────────────────
|
||||
|
||||
describe('D11: fromOffset', () => {
|
||||
it('Basispunkt + dx/dy ergibt Zielpunkt', () => {
|
||||
const p = fromOffset({ x: 100, y: 200 }, 50, -30);
|
||||
expect(p).toEqual({ x: 150, y: 170 });
|
||||
});
|
||||
|
||||
it('dx=dy=0 bleibt am Basispunkt', () => {
|
||||
const p = fromOffset({ x: 10, y: 20 }, 0, 0);
|
||||
expect(p).toEqual({ x: 10, y: 20 });
|
||||
});
|
||||
|
||||
it('negative Offsets funktionieren', () => {
|
||||
const p = fromOffset({ x: 0, y: 0 }, -100, -50);
|
||||
expect(p).toEqual({ x: -100, y: -50 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Task D2 – Spline-Tool (CV-basiert): Kontrollpunkt-Klick-Sequenz,
|
||||
* ENTER committet ab 3 CVs. Render als interpolierte Polyline mit
|
||||
* Catmull-Rom-Glättung (Kurve läuft DURCH alle CVs), CVs in
|
||||
* properties.controlPoints gespeichert (DXF-SPLINE-Export-ready).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { splineTool } from '../src/plugins/builtin/core-drawing';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||
|
||||
function mkCtx(options: Record<string, unknown> = {}) {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
const previews: (CADElement | null)[] = [];
|
||||
const statuses: string[] = [];
|
||||
const ctx: any = {
|
||||
doc,
|
||||
options,
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (el: CADElement | null) => previews.push(el),
|
||||
};
|
||||
return { ctx, doc, previews, statuses };
|
||||
}
|
||||
|
||||
function pe(x: number, y: number): { world: Pt } {
|
||||
return { world: { x, y } } as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
splineTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
describe('D2: splineTool', () => {
|
||||
it('4 CVs + ENTER erzeugt EINE geglaettete Polyline mit mehr Punkten als CVs', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
const cvs = [[0, 0], [100, 100], [200, 0], [300, 100]];
|
||||
for (const [x, y] of cvs) splineTool.handlers.down!(pe(x, y), ctx);
|
||||
const committed = splineTool.handlers.key!({ key: 'Enter' } as never, ctx);
|
||||
expect(committed).toBe(true);
|
||||
const els = doc.getAllElements();
|
||||
expect(els).toHaveLength(1);
|
||||
const el = els[0];
|
||||
expect(el.type).toBe('polyline');
|
||||
const pts = el.properties.points as Pt[];
|
||||
expect(pts.length).toBeGreaterThan(8); // geglaettet, nicht nur CVs
|
||||
});
|
||||
|
||||
it('Kurve interpoliert DURCH die CVs: Start/Ende exakt, Zwischen-CVs liegen auf der Kurve', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
for (const [x, y] of [[0, 0], [100, 100], [200, 0], [300, 100]]) {
|
||||
splineTool.handlers.down!(pe(x, y), ctx);
|
||||
}
|
||||
splineTool.handlers.key!({ key: 'Enter' } as never, ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
const pts = el.properties.points as Pt[];
|
||||
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||||
const last = pts[pts.length - 1];
|
||||
expect(last.x).toBeCloseTo(300, 5);
|
||||
expect(last.y).toBeCloseTo(100, 5);
|
||||
// Zwischenpunkt (100,100) muss auf der Kurve liegen (Catmull-Rom interpoliert):
|
||||
const near = pts.find((p) => Math.abs(p.x - 100) < 1.5 && Math.abs(p.y - 100) < 1.5);
|
||||
expect(near).toBeDefined();
|
||||
});
|
||||
|
||||
it('CVs werden in properties.controlPoints gespeichert', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
for (const [x, y] of [[0, 0], [50, 80], [120, 40]]) {
|
||||
splineTool.handlers.down!(pe(x, y), ctx);
|
||||
}
|
||||
splineTool.handlers.key!({ key: 'Enter' } as never, ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
const cvs = el.properties.controlPoints as Pt[];
|
||||
expect(cvs).toHaveLength(3);
|
||||
expect(cvs[1]).toEqual({ x: 50, y: 80 });
|
||||
});
|
||||
|
||||
it('Glättung: Kurve weicht von der CV-Sehnen-Linie ab (keine Polyline durch CVs)', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
// S-artige CVs: (0,0) (0,100) (100,100) (100,0) — Catmull-Rom schwingt um die Ecken
|
||||
for (const [x, y] of [[0, 0], [0, 100], [100, 100], [100, 0]]) {
|
||||
splineTool.handlers.down!(pe(x, y), ctx);
|
||||
}
|
||||
splineTool.handlers.key!({ key: 'Enter' } as never, ctx);
|
||||
const el = doc.getAllElements()[0];
|
||||
const pts = el.properties.points as Pt[];
|
||||
// Es muss Punkte geben, die ausserhalb der BBox der Sehnen-Verbindung liegen
|
||||
// (Glattung schwingt): minY < 0 oder maxY > 100 an irgendeiner Stelle
|
||||
const ys = pts.map((p) => p.y);
|
||||
const swingsBelow = Math.min(...ys) < -0.5;
|
||||
const swingsAbove = Math.max(...ys) > 100.5;
|
||||
expect(swingsBelow || swingsAbove).toBe(true);
|
||||
});
|
||||
|
||||
it('ENTER mit weniger als 3 CVs committet NICHT (Status erklaert Mindestzahl)', () => {
|
||||
const { ctx, doc, statuses } = mkCtx();
|
||||
splineTool.handlers.down!(pe(0, 0), ctx);
|
||||
splineTool.handlers.down!(pe(50, 50), ctx);
|
||||
const consumed = splineTool.handlers.key!({ key: 'Enter' } as never, ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
expect(statuses[statuses.length - 1]).toContain('3');
|
||||
});
|
||||
|
||||
it('Nicht-ENTER-Keys werden nicht konsumiert (return false)', () => {
|
||||
const { ctx } = mkCtx();
|
||||
splineTool.handlers.down!(pe(0, 0), ctx);
|
||||
const consumed = splineTool.handlers.key!({ key: 'a' } as never, ctx);
|
||||
expect(consumed).toBe(false);
|
||||
});
|
||||
|
||||
it('Preview bei move ab 1 CV', () => {
|
||||
const { ctx, previews } = mkCtx();
|
||||
splineTool.handlers.down!(pe(0, 0), ctx);
|
||||
splineTool.handlers.move!(pe(50, 50), ctx);
|
||||
expect(previews.length).toBeGreaterThanOrEqual(1);
|
||||
const last = previews[previews.length - 1];
|
||||
expect(last).not.toBeNull();
|
||||
});
|
||||
|
||||
it('cancel resettet: naechste Sitzung startet frisch', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
splineTool.handlers.down!(pe(0, 0), ctx);
|
||||
splineTool.handlers.down!(pe(50, 50), ctx);
|
||||
splineTool.handlers.cancel?.(ctx);
|
||||
splineTool.handlers.down!(pe(0, 0), ctx);
|
||||
splineTool.handlers.down!(pe(50, 50), ctx);
|
||||
splineTool.handlers.down!(pe(100, 0), ctx);
|
||||
splineTool.handlers.key!({ key: 'Enter' } as never, ctx);
|
||||
// 3 CVs der NEUEN Sitzung — controlPoints == 3 (alte 2 nicht gemischt)
|
||||
const el = doc.getAllElements()[0];
|
||||
expect((el.properties.controlPoints as Pt[]).length).toBe(3);
|
||||
});
|
||||
|
||||
it('Status zaehlt CVs mit', () => {
|
||||
const { ctx, statuses } = mkCtx();
|
||||
splineTool.handlers.down!(pe(0, 0), ctx);
|
||||
expect(statuses[0]).toContain('1');
|
||||
expect(statuses[0]).toContain('Spline');
|
||||
});
|
||||
|
||||
it('Manifest: id=spline, ribbonTab=canvas, tags basic', () => {
|
||||
expect(splineTool.manifest.id).toBe('spline');
|
||||
expect(splineTool.manifest.ribbonTab).toBe('canvas');
|
||||
expect((splineTool.manifest.tags ?? [])).toContain('basic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Task H1 – simple/pro Mode.
|
||||
*
|
||||
* uiModeService: persistierter UI-Modus ('simple' | 'pro', Default 'pro'),
|
||||
* localStorage-Persistenz, Subscribe für Live-Updates.
|
||||
* RibbonBar filtert im simple-Modus auf tools mit tags 'basic'.
|
||||
* CommandLine ist pro-only (im simple-Modus versteckt).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import {
|
||||
getUIMode, setUIMode, subscribeUIMode, UIMode,
|
||||
} from '../src/services/uiModeService';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── uiModeService ────────────────────────────────────────
|
||||
|
||||
describe('H1: uiModeService', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('Default ist pro (Vollständige Werkzeugpalette)', () => {
|
||||
expect(getUIMode()).toBe('pro');
|
||||
});
|
||||
|
||||
it('setUIMode simple persistiert in localStorage', () => {
|
||||
setUIMode('simple');
|
||||
expect(getUIMode()).toBe('simple');
|
||||
expect(localStorage.getItem('webcad.uiMode')).toBe('simple');
|
||||
});
|
||||
|
||||
it('Persistiert über Reload hinweg (localStorage gesetzt vor erstem get)', () => {
|
||||
localStorage.setItem('webcad.uiMode', 'simple');
|
||||
expect(getUIMode()).toBe('simple');
|
||||
});
|
||||
|
||||
it('subscribeUIMode feuert bei Wechsel und unsubscribed korrekt', () => {
|
||||
const cb = vi.fn();
|
||||
const off = subscribeUIMode(cb);
|
||||
setUIMode('simple');
|
||||
expect(cb).toHaveBeenCalledWith('simple');
|
||||
setUIMode('pro');
|
||||
expect(cb).toHaveBeenCalledWith('pro');
|
||||
off();
|
||||
cb.mockClear();
|
||||
setUIMode('simple');
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
// Aufräumen für weitere Tests
|
||||
setUIMode('pro');
|
||||
});
|
||||
|
||||
it('ungültiger localStorage-Wert fällt auf pro zurück', () => {
|
||||
localStorage.setItem('webcad.uiMode', 'gibts-nicht');
|
||||
expect(getUIMode()).toBe('pro');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── RibbonBar simple-Filter ─────────────────────────────
|
||||
|
||||
describe('H1: RibbonBar simple-Filter', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setUIMode('pro');
|
||||
});
|
||||
|
||||
it('pro-Modus: alle V2-Tools sichtbar (basic + pro-Tags)', async () => {
|
||||
const { registerBuiltinPlugins, pluginRegistry } = await import('../src/plugins');
|
||||
registerBuiltinPlugins();
|
||||
pluginRegistry.initDefaults();
|
||||
const RibbonBar = (await import('../src/components/RibbonBar')).default;
|
||||
render(<RibbonBar activeTab="canvas" onTabChange={() => {}} onAction={() => {}} />);
|
||||
const all = screen.getAllByRole('button').length;
|
||||
expect(all).toBeGreaterThan(10);
|
||||
cleanup();
|
||||
setUIMode('simple');
|
||||
render(<RibbonBar activeTab="canvas" onTabChange={() => {}} onAction={() => {}} />);
|
||||
const simpleCount = screen.getAllByRole('button').length;
|
||||
expect(simpleCount).toBeLessThan(all);
|
||||
expect(simpleCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('simple-Modus: pro-Tools (rotate/scale/mirror) fehlen, basic (line) bleibt', async () => {
|
||||
localStorage.clear();
|
||||
setUIMode('simple');
|
||||
const { registerBuiltinPlugins, pluginRegistry } = await import('../src/plugins');
|
||||
registerBuiltinPlugins();
|
||||
pluginRegistry.initDefaults();
|
||||
const RibbonBar = (await import('../src/components/RibbonBar')).default;
|
||||
render(<RibbonBar activeTab="canvas" onTabChange={() => {}} onAction={() => {}} />);
|
||||
const html = document.body.innerHTML;
|
||||
expect(html).toContain('data-v2tool="line"');
|
||||
expect(html).not.toContain('data-v2tool="rotate"');
|
||||
expect(html).not.toContain('data-v2tool="mirror"');
|
||||
});
|
||||
|
||||
it('CommandLine-Komponente: pro-only rendering via useUIMode', async () => {
|
||||
const { useUIMode } = await import('../src/services/uiModeService');
|
||||
const CommandLine = (await import('../src/components/CommandLine')).default;
|
||||
setUIMode('pro');
|
||||
const { container: proContainer } = render(<CommandLine history={[]} onCommand={() => {}} />);
|
||||
expect(proContainer.querySelector('.cmdline')).toBeTruthy();
|
||||
cleanup();
|
||||
setUIMode('simple');
|
||||
const { container: simpleContainer } = render(<CommandLine history={[]} onCommand={() => {}} />);
|
||||
expect(simpleContainer.querySelector('.cmdline')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('Topbar-Toggle wechselt Mode', async () => {
|
||||
const Topbar = (await import('../src/components/Topbar')).default;
|
||||
setUIMode('pro');
|
||||
render(<Topbar projectName="Testprojekt" savedStatus="Gespeichert" onUndo={() => {}} onRedo={() => {}} onThemeToggle={() => {}} theme="dark" />);
|
||||
const btn = screen.getByTitle(/Modus.*wechseln|Einfach.*Modus|Profi-Modus/i);
|
||||
fireEvent.click(btn);
|
||||
expect(getUIMode()).toBe('simple');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user