Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40fa7891d5 | |||
| 5db421da5e |
+4
-3
@@ -597,7 +597,7 @@ export interface RenderAdapter {
|
|||||||
|
|
||||||
## Phase D — Werkzeugpalette komplett (alles V2-Plugins)
|
## Phase D — Werkzeugpalette komplett (alles V2-Plugins)
|
||||||
Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Workflowtest + Commit `task(D*)`):
|
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)
|
- [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)*
|
||||||
- [ ] D2 Spline (CV-basierte Bézier, Speicherung Kontrollpunkte, Render als interpolierte Polyline mit Glättung)
|
- [ ] 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)
|
- [ ] D3 Point, XLine, Ray (construction-Layer-Tag `properties.construction:true`, Standard-Layer 'defpoints' auto-anlegen)
|
||||||
- [ ] D4 MTEXT (Mehrzeilig,
|
- [ ] D4 MTEXT (Mehrzeilig,
|
||||||
@@ -667,6 +667,7 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
|||||||
|
|
||||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-29 | 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 | 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 | 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 | 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) |
|
||||||
@@ -742,5 +743,5 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
|||||||
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
||||||
|
|
||||||
---
|
---
|
||||||
*Nächster offener Schritt: **D1** (Ellipse+Bogen-Tool: cx,cy,rx,ry,rotation; Interaktion Center→RadiusX→RadiusY). Danach: D2 Spline, D3 Point/XLine/Ray, D4 MTEXT, D9 GripEditing, D10 array-path, D11 Snaps, D12 BlockAttribute. Anschließend: Phase I (Docs/CI/Release).*
|
*Nächster offener Schritt: **D2** (Spline-Tool: CV-basierte Bézier, Kontrollpunkt-Speicherung, Render als interpolierte Polyline mit Glättung). Danach: D3 Point/XLine/Ray, D4 MTEXT, D9 GripEditing, D10 array-path, D11 Snaps, D12 BlockAttribute, Phase I.*
|
||||||
*Status 2026-08-29: Phasen B+A+C+D+E+F+G+H komplett (H: simple/pro, Templates, Rollen-Guard, i18n-Grundlage, Flächen), BUG-1-Kollisionsklasse geschlossen. FE 624/624 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main. Offen: Rest-D-Tools (D1–D4/D9/D10-path/D11/D12), Phase I (Release).*
|
*Status 2026-08-29: Phasen B+A+C+D+E+F+G+H komplett, D1 fertig (Ellipse-Tool), BUG-1-Kollisionsklasse geschlossen. FE 632/632 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main. Offen: D2–D4/D9/D10-path/D11/D12, Phase I (Release).*
|
||||||
|
|||||||
@@ -414,6 +414,150 @@ function buildRevCloud(pts: Pt[], ctx: FullToolContext): CADElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** V2-Plugin: Kern-Zeichenwerkzeuge (A4.x schrittweise migriert). */
|
/** V2-Plugin: Kern-Zeichenwerkzeuge (A4.x schrittweise migriert). */
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task D1: Ellipse (3-Klick: Zentrum→RadiusX→RadiusY) +
|
||||||
|
// Ellipsenbogen via startAngle/endAngle-Options.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Ellipsen-Sitzung: Phase 0=nichts, 1=Zentrum, 2=RadiusX fixiert. */
|
||||||
|
let ellipsePhase = 0;
|
||||||
|
let ellipseCenter: Pt | null = null;
|
||||||
|
let ellipseRadiusX = 0;
|
||||||
|
/** Rotation aus der Klick2-Zugrichtung (Grad, 0°=+x, CW-positiv). */
|
||||||
|
let ellipseRotationDeg = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ellipse als Polyline-Approximation (F5-Parser-Konvention):
|
||||||
|
* Voll-Ellipse → 64 Punkte geschlossen (polygon), Bogen → proportional
|
||||||
|
* offene Polyline. rotation aus der Klick2-Zugrichtung (Grad, 0°=+x).
|
||||||
|
*/
|
||||||
|
function buildEllipse(
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
rx: number,
|
||||||
|
ry: number,
|
||||||
|
rotationDeg: number,
|
||||||
|
ctx: FullToolContext,
|
||||||
|
startAngle = 0,
|
||||||
|
endAngle = 360,
|
||||||
|
): CADElement {
|
||||||
|
const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0';
|
||||||
|
const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0';
|
||||||
|
const phi = (rotationDeg * Math.PI) / 180;
|
||||||
|
const cosP = Math.cos(phi), sinP = Math.sin(phi);
|
||||||
|
const FULL = 360;
|
||||||
|
const span = Math.min(endAngle, FULL) - Math.min(startAngle, FULL);
|
||||||
|
const isFull = span >= FULL - 0.001 || span <= 0.001;
|
||||||
|
const n = Math.max(2, Math.round(64 * (Math.max(span, 1) / FULL)));
|
||||||
|
const pts: Pt[] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const t = ((startAngle + (span * i) / (isFull ? n : (n - 1))) * Math.PI) / 180;
|
||||||
|
const x = rx * Math.cos(t);
|
||||||
|
const y = ry * Math.sin(t);
|
||||||
|
pts.push({
|
||||||
|
x: cx + x * cosP - y * sinP,
|
||||||
|
y: cy + x * sinP + y * cosP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
|
||||||
|
const minX = Math.min(...xs), minY = Math.min(...ys);
|
||||||
|
const type = isFull ? 'polygon' : 'polyline';
|
||||||
|
return {
|
||||||
|
id: uid('ellipse'),
|
||||||
|
type,
|
||||||
|
layerId,
|
||||||
|
x: minX + (Math.max(...xs) - minX) / 2,
|
||||||
|
y: minY + (Math.max(...ys) - minY) / 2,
|
||||||
|
width: Math.max(...xs) - minX,
|
||||||
|
height: Math.max(...ys) - minY,
|
||||||
|
properties: {
|
||||||
|
points: pts,
|
||||||
|
stroke,
|
||||||
|
strokeWidth: 1,
|
||||||
|
cx, cy, rx, ry,
|
||||||
|
rotation: rotationDeg,
|
||||||
|
...(isFull ? {} : { startAngle, endAngle }),
|
||||||
|
},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ellipseTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'ellipse',
|
||||||
|
label: 'Ellipse',
|
||||||
|
icon: '\u2b2d',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Ellipse zeichnen: Zentrum klicken, RadiusX, RadiusY (Bogen via Optionen)',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
|
||||||
|
{ key: 'startAngle', label: 'Bogen-Start (°)', type: 'number', min: 0, max: 360 },
|
||||||
|
{ key: 'endAngle', label: 'Bogen-Ende (°)', type: 'number', min: 0, max: 360 },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (ellipsePhase === 0) {
|
||||||
|
ellipseCenter = e.world;
|
||||||
|
ellipsePhase = 1;
|
||||||
|
ctx.setStatus('Ellipse: Zentrum gesetzt – RadiusX klicken (Richtung = Rotation)');
|
||||||
|
} else if (ellipsePhase === 1) {
|
||||||
|
if (!ellipseCenter) { ellipsePhase = 0; return; }
|
||||||
|
const dx = e.world.x - ellipseCenter.x;
|
||||||
|
const dy = e.world.y - ellipseCenter.y;
|
||||||
|
ellipseRadiusX = Math.hypot(dx, dy);
|
||||||
|
ellipseRotationDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
|
||||||
|
ellipsePhase = 2;
|
||||||
|
ctx.setStatus('Ellipse: RadiusX gesetzt – RadiusY klicken');
|
||||||
|
} else {
|
||||||
|
// Phase 2: Commit
|
||||||
|
if (!ellipseCenter || !c.doc) { ellipsePhase = 0; ellipseCenter = null; return; }
|
||||||
|
const dx = e.world.x - ellipseCenter.x;
|
||||||
|
const dy = e.world.y - ellipseCenter.y;
|
||||||
|
const ry = Math.hypot(dx, dy);
|
||||||
|
// Rotation: Winkel des Klick2-Vektors gegenüber der +x-Achse
|
||||||
|
const startAngle = (ctx.options.startAngle as number | undefined) ?? 0;
|
||||||
|
const endAngle = (ctx.options.endAngle as number | undefined) ?? 360;
|
||||||
|
const el = buildEllipse(
|
||||||
|
ellipseCenter.x, ellipseCenter.y,
|
||||||
|
ellipseRadiusX, ry,
|
||||||
|
ellipseRotationDeg,
|
||||||
|
c, startAngle, endAngle,
|
||||||
|
);
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
ellipsePhase = 0;
|
||||||
|
ellipseCenter = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`Ellipse erstellt (${el.id})`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (ellipsePhase === 1 && ellipseCenter) {
|
||||||
|
// RadiusX-Linie als Preview
|
||||||
|
const r = Math.hypot(e.world.x - ellipseCenter.x, e.world.y - ellipseCenter.y);
|
||||||
|
const rotDeg = ((Math.atan2(e.world.y - ellipseCenter.y, e.world.x - ellipseCenter.x) * 180) / Math.PI + 360) % 360;
|
||||||
|
const startAngle = (ctx.options.startAngle as number | undefined) ?? 0;
|
||||||
|
const endAngle = (ctx.options.endAngle as number | undefined) ?? 360;
|
||||||
|
ctx.setPreview?.(buildEllipse(ellipseCenter.x, ellipseCenter.y, r, r, rotDeg, c, startAngle, endAngle));
|
||||||
|
} else if (ellipsePhase === 2 && ellipseCenter) {
|
||||||
|
const ry = Math.hypot(e.world.x - ellipseCenter.x, e.world.y - ellipseCenter.y);
|
||||||
|
const rotDeg = ellipseRotationDeg;
|
||||||
|
const startAngle = (ctx.options.startAngle as number | undefined) ?? 0;
|
||||||
|
const endAngle = (ctx.options.endAngle as number | undefined) ?? 360;
|
||||||
|
ctx.setPreview?.(buildEllipse(ellipseCenter.x, ellipseCenter.y, ellipseRadiusX, ry, rotDeg, c, startAngle, endAngle));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
ellipsePhase = 0;
|
||||||
|
ellipseCenter = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Ellipse abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const coreDrawingPlugin: PluginV2 = {
|
export const coreDrawingPlugin: PluginV2 = {
|
||||||
manifest: {
|
manifest: {
|
||||||
id: 'core-drawing',
|
id: 'core-drawing',
|
||||||
@@ -424,5 +568,5 @@ export const coreDrawingPlugin: PluginV2 = {
|
|||||||
category: 'tools',
|
category: 'tools',
|
||||||
enabledByDefault: true,
|
enabledByDefault: true,
|
||||||
},
|
},
|
||||||
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool],
|
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user