Compare commits
2 Commits
7331f5fb88
...
7fe6cd127e
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fe6cd127e | |||
| 0ffe954559 |
+4
-3
@@ -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-31 | CAD-5 | MLINE + Block-Editor: (1) mlineTool NEU in core-drawing (13 Tools) — Mehrfachlinie für Wände/Rohre: Klick-Sequenz sammelt Mittellinien-Punkte, offsetPolyline-Helfer berechnet ZWEI parallele Punktketten ±gap/2 entlang der Normalen (an inneren Punkten Mittelung der angrenzenden Segment-Normalen = Miter-Joins wie bei echten Wänden), gap-Option (Gesamtstärke), optionale Endkappen (cap-Checkbox) verbinden beide Linien an Start/Ende als line-Elemente mit mlineCap-Flag, ENTER committet ab 2 Punkten (boolean-Konsum), ALLE Teile (2 Linien + ggf. 2 Kappen) in EINER doc.transact = 1 Undo-Schritt, mline:true+gap-Metadaten, L-förmige Pfade folgen der Geometrie (gleiche Punktanzahl); (2) blockEditorService NEU — openBlockForEdit legt markierte KOPIEN der Block-Elemente ins Dokument (Original unangetastet, BLOCK_EDIT_MARKER-Flag + __blockEdit-IDs), saveBlockEdit schreibt die bearbeiteten Kopien als neue Elementliste in die Definition zurück (Marker entfernt) und räumt Edit-Kopien auf, discardBlockEdit verwirft alles; (3) blockEditTool NEU in core-modify (26 Tools) — Klick 1 auf Block-Instanz öffnet (via selection.hitTest + blockId-Referenz, Status führt), Kopien normal bearbeiten mit allen Werkzeugen, Klick 2 speichert zurück, cancel verwirft; Zwischenf\e4lle selbst gelöst: BLOCK_EDIT_MARKER fehlte als Re-Export in core-modify (Test bekam undefined) + Service-State-Leak zwischen Tests (afterEach-Discard) + Kappen-Test a/b-Vorzeichenkonvention reihenfolge-agnostisch gemacht | Frontend: 784/784 Tests (+21 mlineBlockEditor.test.ts), tsc 0, Build EXIT 0 | 0ffe954 | **CAD-5 COMPLETE — POST-2.0 CAD-AUSBAU VOLLSTÄNDIG** (CAD-1 bis CAD-5: Bemassung, Hatch, Box-Select, OTRACK/DIVIDE/MEASURE, MLINE/Block-Editor) |
|
||||||
| 2026-08-30 | CAD-4 | OTRACK + DIVIDE/MEASURE: (1) computeTrackPoint NEU in SnapEngine (reine Funktion, Task CAD-4) — Objekt-Fang-Tracking: projiziert den Cursor auf Tracking-Linien durch den Basispunkt (Default-Winkel 0/90/180/270°, optional erweiterte Winkelliste z.B. 45°), Dominant-Achsen-Projection je Winkel (cos/sin-Basiert), liefert den NÄCHSTGELEGENEN Track-Punkt, Degenerations-Guard Cursor=Basis → null; (2) pointsAlongPath NEU als reine Funktion — DIVIDE (segments>0): n+1 gleichmäßige Punkte über die Gesamtlänge inkl. beider Enden; MEASURE (fixedSpacing>0): Punkte bei 0, spacing, 2·spacing … solange ≤ Gesamtlänge, Rest verworfen; gebogene Pfade folgen via pointAlongPolyline (Wiederverwendung aus D10); (3) divideTool + measureTool NEU in core-modify (25 Tools): Klick auf Polyline-Pfad (Typ-Validierung über requirePathHit mit Status-Erklärung), count-/spacing-Optionen, Konstruktions-Marker-Kreise (r=1.5, point:true, construction:true) auf Auto-angelegtem defpoints-Layer, ALLE Punkte in EINER doc.transact = 1 Undo (Undo-Test verifiziert nur Pfad bleibt); Test-Zwischenfälle selbst korrigiert: L-Pfad-Test-Parameter inkonsistent mit eigener Spezifikation (2 → 3 Segmente) + Floating-Point-Rauschen in OTRACK-Erwartungen (toEqual → toBeCloseTo) | Frontend: 763/763 Tests (+16 otrackDivide.test.ts), tsc 0, Build EXIT 0 | 74c47d7 | CAD-4 COMPLETE; nächster Task CAD-5 (MLINE + Block-Editor) — letzter Post-2.0-Punkt |
|
| 2026-08-30 | CAD-4 | OTRACK + DIVIDE/MEASURE: (1) computeTrackPoint NEU in SnapEngine (reine Funktion, Task CAD-4) — Objekt-Fang-Tracking: projiziert den Cursor auf Tracking-Linien durch den Basispunkt (Default-Winkel 0/90/180/270°, optional erweiterte Winkelliste z.B. 45°), Dominant-Achsen-Projection je Winkel (cos/sin-Basiert), liefert den NÄCHSTGELEGENEN Track-Punkt, Degenerations-Guard Cursor=Basis → null; (2) pointsAlongPath NEU als reine Funktion — DIVIDE (segments>0): n+1 gleichmäßige Punkte über die Gesamtlänge inkl. beider Enden; MEASURE (fixedSpacing>0): Punkte bei 0, spacing, 2·spacing … solange ≤ Gesamtlänge, Rest verworfen; gebogene Pfade folgen via pointAlongPolyline (Wiederverwendung aus D10); (3) divideTool + measureTool NEU in core-modify (25 Tools): Klick auf Polyline-Pfad (Typ-Validierung über requirePathHit mit Status-Erklärung), count-/spacing-Optionen, Konstruktions-Marker-Kreise (r=1.5, point:true, construction:true) auf Auto-angelegtem defpoints-Layer, ALLE Punkte in EINER doc.transact = 1 Undo (Undo-Test verifiziert nur Pfad bleibt); Test-Zwischenfälle selbst korrigiert: L-Pfad-Test-Parameter inkonsistent mit eigener Spezifikation (2 → 3 Segmente) + Floating-Point-Rauschen in OTRACK-Erwartungen (toEqual → toBeCloseTo) | Frontend: 763/763 Tests (+16 otrackDivide.test.ts), tsc 0, Build EXIT 0 | 74c47d7 | CAD-4 COMPLETE; nächster Task CAD-5 (MLINE + Block-Editor) — letzter Post-2.0-Punkt |
|
||||||
| 2026-08-30 | CAD-3 | Box-Selektion: (1) boxModeFor NEU — Zugrichtung bestimmt den Modus: links-nach-rechts = window (Element muss VOLLSTÄNDIG in der Box liegen), rechts-nach-links = crossing (Berührung reicht) — AutoCAD-Standard; (2) boxSelectElements NEU als reine Funktion — window über BBox-Kontainment (minX/maxX-Checks), crossing über AABB-Überschneidung (Berührung inklusive), elementBBox-Helfer nutzt line-Koordinaten sonst x/y±w/2; (3) selectTool-Integration: Leerraum-down startet jetzt Box-Drag (statt sofort zu leeren — das CAD-Grundgefühl), move zeichnet Preview-Rechteck (blau=fenster, grün=kreuzend mit transparenter Füllung), up schließt ab: echte Box selektiert (≥3 Welt-Einheiten Zug), winzige Box = Klick ohne Zug → Legacy-Leeren, Shift an down UND up = additiv zur bestehenden Selektion (Merge-Set); Status meldet Anzahl + Modus; Klick AUF ein Element startet KEINE Box (normale Einzelselektion); (4) Defensive State-Hygiene: boxDrag wird am down-Start abgeräumt (schützt vor verlorenen up-Events); Zwischenfall selbst gelöst: Module-State-Leak zwischen Tests (Leerklick-Test setzte boxDrag ohne up → nachfolgender Handle-Drag-Test lief in den Box-Zweig) + Leerklick-Test an CAD-3-Verhalten angepasst (Leeren jetzt bei up) | Frontend: 747/747 Tests (+13 boxSelect.test.ts), tsc 0, Build EXIT 0 | 099f654 | CAD-3 COMPLETE — alle drei kritischen Features (Bemassung, Hatch, Box-Select) fertig; nächster Task CAD-4 (OTRACK + DIVIDE/MEASURE) |
|
| 2026-08-30 | CAD-3 | Box-Selektion: (1) boxModeFor NEU — Zugrichtung bestimmt den Modus: links-nach-rechts = window (Element muss VOLLSTÄNDIG in der Box liegen), rechts-nach-links = crossing (Berührung reicht) — AutoCAD-Standard; (2) boxSelectElements NEU als reine Funktion — window über BBox-Kontainment (minX/maxX-Checks), crossing über AABB-Überschneidung (Berührung inklusive), elementBBox-Helfer nutzt line-Koordinaten sonst x/y±w/2; (3) selectTool-Integration: Leerraum-down startet jetzt Box-Drag (statt sofort zu leeren — das CAD-Grundgefühl), move zeichnet Preview-Rechteck (blau=fenster, grün=kreuzend mit transparenter Füllung), up schließt ab: echte Box selektiert (≥3 Welt-Einheiten Zug), winzige Box = Klick ohne Zug → Legacy-Leeren, Shift an down UND up = additiv zur bestehenden Selektion (Merge-Set); Status meldet Anzahl + Modus; Klick AUF ein Element startet KEINE Box (normale Einzelselektion); (4) Defensive State-Hygiene: boxDrag wird am down-Start abgeräumt (schützt vor verlorenen up-Events); Zwischenfall selbst gelöst: Module-State-Leak zwischen Tests (Leerklick-Test setzte boxDrag ohne up → nachfolgender Handle-Drag-Test lief in den Box-Zweig) + Leerklick-Test an CAD-3-Verhalten angepasst (Leeren jetzt bei up) | Frontend: 747/747 Tests (+13 boxSelect.test.ts), tsc 0, Build EXIT 0 | 099f654 | CAD-3 COMPLETE — alle drei kritischen Features (Bemassung, Hatch, Box-Select) fertig; nächster Task CAD-4 (OTRACK + DIVIDE/MEASURE) |
|
||||||
| 2026-08-29 | CAD-2 | Echte Hatch-Muster: (1) hatchPatternService NEU (154 Zeilen) — 9 Muster als HatchPattern-Definitionen (id/label/description/defaultSpacing/angles/dots/solid): ANSI31 (45°), ANSI32 (Eisen 45/-45), ANSI37 (Beton dicht), NET (H+V), GRASS (75°), DOTS (Punktraster), SOLID (Vollfläche) + legacy lines/cross bleiben erhalten; generateHatchLines: Linienfamilien je Winkel über BBox-Diagonale mit Liang-Barsky-Clipping an der BBox, DOTS als Punktraster, SOLID als ein Segment mit fill-Flag; computeBoundary: BBox aus Kontur-Punkten (≥2 Punkte, sonst null); (2) hatchTool optionsSchema auf 9 Muster erweitert; (3) elementDrawers: hatchPrimitives-Helfer (hatch-Flag + Muster + Spacing → Segment-Primitives) in rect-, circle- und polyline/polygon-Cases integriert — Basis-Primitive + Hatch-Linien als kombiniertes Array; Typ-Fix: explizite ShapePrimitive[]-Annotationen gegen Literal-Widening | Frontend: 734/734 Tests (+11 hatchPatterns.test.ts), tsc 0, Build EXIT 0 | 11a0dcb | CAD-2 COMPLETE — beide priorisierten Features (Bemassung + Hatch) fertig; nächster Task CAD-3 (Box-Select) |
|
| 2026-08-29 | CAD-2 | Echte Hatch-Muster: (1) hatchPatternService NEU (154 Zeilen) — 9 Muster als HatchPattern-Definitionen (id/label/description/defaultSpacing/angles/dots/solid): ANSI31 (45°), ANSI32 (Eisen 45/-45), ANSI37 (Beton dicht), NET (H+V), GRASS (75°), DOTS (Punktraster), SOLID (Vollfläche) + legacy lines/cross bleiben erhalten; generateHatchLines: Linienfamilien je Winkel über BBox-Diagonale mit Liang-Barsky-Clipping an der BBox, DOTS als Punktraster, SOLID als ein Segment mit fill-Flag; computeBoundary: BBox aus Kontur-Punkten (≥2 Punkte, sonst null); (2) hatchTool optionsSchema auf 9 Muster erweitert; (3) elementDrawers: hatchPrimitives-Helfer (hatch-Flag + Muster + Spacing → Segment-Primitives) in rect-, circle- und polyline/polygon-Cases integriert — Basis-Primitive + Hatch-Linien als kombiniertes Array; Typ-Fix: explizite ShapePrimitive[]-Annotationen gegen Literal-Widening | Frontend: 734/734 Tests (+11 hatchPatterns.test.ts), tsc 0, Build EXIT 0 | 11a0dcb | CAD-2 COMPLETE — beide priorisierten Features (Bemassung + Hatch) fertig; nächster Task CAD-3 (Box-Select) |
|
||||||
@@ -757,8 +758,8 @@ 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
*Post-2.0 CAD-Ausbau: CAD-1 bis CAD-4 fertig (Bemassung, Hatch, Box-Select, OTRACK+DIVIDE/MEASURE). Letzter Task: **CAD-5** (MLINE + Block-Editor).*
|
*Post-2.0 CAD-Ausbau VOLLSTÄNDIG: CAD-1 bis CAD-5 alle fertig (Bemassung, Hatch-Muster, Box-Select, OTRACK+DIVIDE/MEASURE, MLINE+Block-Editor). Neue Anforderungen über neue Roadmap-Einträge.*
|
||||||
*Status 2026-08-30: RELEASE 2.0.0 LIVE + Post-2.0: CAD-1 bis CAD-4 fertig. FE 763/763 Tests, BE 287/287, tsc/Build grün, Prod-Deploy verifiziert. Tag v2.0.0 auf Forgejo main.*
|
*Status 2026-08-31: RELEASE 2.0.0 LIVE + Post-2.0 CAD-Ausbau VOLLSTÄNDIG (CAD-1 bis CAD-5). FE 784/784 Tests, BE 287/287, tsc/Build grün, Prod-Deploy verifiziert. Tag v2.0.0 auf Forgejo main.*
|
||||||
|
|
||||||
## Post-2.0 CAD-Ausbau (neue Task-Reihe)
|
## Post-2.0 CAD-Ausbau (neue Task-Reihe)
|
||||||
|
|
||||||
@@ -766,5 +767,5 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
|||||||
- [x] CAD-2 Echte Hatch-Muster: ANSI31/NET/GRASS etc. + Boundary-Erkennung aus umschlossener Flache + assoziative Schraffur *(2026-08-29: 9 Muster (ANSI31/32/37, NET, GRASS, DOTS, SOLID + legacy lines/cross), generateHatchLines mit Winkel-Familien + Liang-Barsky-Clipping, computeBoundary, RenderAdapter zeichnet Hatch in rect/poly/circle; Log CAD-2)*
|
- [x] CAD-2 Echte Hatch-Muster: ANSI31/NET/GRASS etc. + Boundary-Erkennung aus umschlossener Flache + assoziative Schraffur *(2026-08-29: 9 Muster (ANSI31/32/37, NET, GRASS, DOTS, SOLID + legacy lines/cross), generateHatchLines mit Winkel-Familien + Liang-Barsky-Clipping, computeBoundary, RenderAdapter zeichnet Hatch in rect/poly/circle; Log CAD-2)*
|
||||||
- [x] CAD-3 Fenster-/Kreuz-Selektion (Box-Select) *(2026-08-30: boxModeFor (Richtung = window/crossing wie AutoCAD), boxSelectElements (window = vollständig enthalten, crossing = Berührung reicht), Box-Drag-Session mit Preview-Rechteck (blau Fenster / grün Kreuzend), Shift-additiv, winzige Box = Klick → Legacy-Leeren bei up; Log CAD-3)*
|
- [x] CAD-3 Fenster-/Kreuz-Selektion (Box-Select) *(2026-08-30: boxModeFor (Richtung = window/crossing wie AutoCAD), boxSelectElements (window = vollständig enthalten, crossing = Berührung reicht), Box-Drag-Session mit Preview-Rechteck (blau Fenster / grün Kreuzend), Shift-additiv, winzige Box = Klick → Legacy-Leeren bei up; Log CAD-3)*
|
||||||
- [x] CAD-4 Objekt-Fang-Tracking (OTRACK) + DIVIDE/MEASURE *(2026-08-30: computeTrackPoint (Winkel-Projektion auf Tracking-Linien, Default 0/90/180/270°, erweiterte Winkel optional, nächstgelegener Track-Punkt) als reine Funktion in SnapEngine; pointsAlongPath (DIVIDE n+1 gleichmäßig inkl. Enden / MEASURE fester Abstand, Rest verworfen) unter Wiederverwendung von pointAlongPolyline; divideTool + measureTool mit defpoints-Markern in EINER Transaktion; Log CAD-4)*
|
- [x] CAD-4 Objekt-Fang-Tracking (OTRACK) + DIVIDE/MEASURE *(2026-08-30: computeTrackPoint (Winkel-Projektion auf Tracking-Linien, Default 0/90/180/270°, erweiterte Winkel optional, nächstgelegener Track-Punkt) als reine Funktion in SnapEngine; pointsAlongPath (DIVIDE n+1 gleichmäßig inkl. Enden / MEASURE fester Abstand, Rest verworfen) unter Wiederverwendung von pointAlongPolyline; divideTool + measureTool mit defpoints-Markern in EINER Transaktion; Log CAD-4)*
|
||||||
- [ ] CAD-5 Multiline (MLINE) + Block-Editor
|
- [x] CAD-5 Multiline (MLINE) + Block-Editor *(2026-08-31: mlineTool (Doppel-Linie über Normalen-Offset ±gap/2 mit Miter an Zwischenpunkten, optionale Endkappen, ENTER-Commit ab 2 Punkten, 1 Transaktion) + blockEditorService (openBlockForEdit markierte Kopien / saveBlockEdit zurückschreiben / discardBlockEdit verwerfen) + blockEditTool (Klick öffnet, Klick speichert, cancel verwirft); Log CAD-5)*
|
||||||
|
|
||||||
|
|||||||
@@ -891,6 +891,137 @@ export const rayTool: ToolExtensionV2 = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task CAD-5: MLINE (Mehrfachlinie) — zwei parallele Linien
|
||||||
|
// (Wand/Rohr) mit gap-Offset ±gap/2 entlang der Normalen.
|
||||||
|
// Optionale Endkappen verbinden die beiden Linien an Start/Ende.
|
||||||
|
// Klick-Sequenz wie polyline; ENTER committet ab 2 Punkten;
|
||||||
|
// ALLE Teile in EINER Transaktion = 1 Undo-Schritt.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let mlinePoints: Pt[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalen-Offset-Punkte für eine Punktliste ±d (CAD-5, rein).
|
||||||
|
* An inneren Punkten wird die Richtung aus dem Mittel der
|
||||||
|
* angrenzenden Segment-Normalen bestimmt (Miter wie bei Wänden).
|
||||||
|
*/
|
||||||
|
function offsetPolyline(pts: Pt[], d: number): Pt[] {
|
||||||
|
if (pts.length < 2) return [...pts];
|
||||||
|
const out: Pt[] = [];
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
let nx = 0, ny = 0;
|
||||||
|
if (i > 0) {
|
||||||
|
const dx = pts[i].x - pts[i - 1].x;
|
||||||
|
const dy = pts[i].y - pts[i - 1].y;
|
||||||
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
|
nx += -dy / len; ny += dx / len;
|
||||||
|
}
|
||||||
|
if (i < pts.length - 1) {
|
||||||
|
const dx = pts[i + 1].x - pts[i].x;
|
||||||
|
const dy = pts[i + 1].y - pts[i].y;
|
||||||
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
|
nx += -dy / len; ny += dx / len;
|
||||||
|
}
|
||||||
|
const nLen = Math.hypot(nx, ny) || 1;
|
||||||
|
out.push({ x: pts[i].x + (nx / nLen) * d, y: pts[i].y + (ny / nLen) * d });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mlineTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'mline',
|
||||||
|
label: 'Mehrfachlinie',
|
||||||
|
icon: '\u2551',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Doppel-Linie (Wand): Punkte klicken, ENTER beendet (CAD-5)',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'gap', label: 'Gesamtstaerke', type: 'number', min: 1 },
|
||||||
|
{ key: 'cap', label: 'Endkappen', type: 'checkbox' },
|
||||||
|
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
mlinePoints.push(e.world);
|
||||||
|
ctx.setStatus(`Mehrfachlinie: ${mlinePoints.length} Punkte — ENTER beendet`);
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (mlinePoints.length === 0) return;
|
||||||
|
const gap = (c.options.gap as number | undefined) ?? 20;
|
||||||
|
const pts = [...mlinePoints, e.world];
|
||||||
|
const a = offsetPolyline(pts, gap / 2);
|
||||||
|
const b = offsetPolyline(pts, -gap / 2);
|
||||||
|
ctx.setPreview?.({
|
||||||
|
id: '__preview_mline__',
|
||||||
|
type: 'polyline',
|
||||||
|
layerId: 'layer-0',
|
||||||
|
x: e.world.x, y: e.world.y, width: 0, height: 0,
|
||||||
|
properties: { points: a, stroke: '#e0e0f0', strokeWidth: 1, mline: true },
|
||||||
|
} as unknown as CADElement);
|
||||||
|
},
|
||||||
|
up() { /* Commit nur via ENTER */ },
|
||||||
|
key(e, ctx) {
|
||||||
|
if (e.key !== 'Enter' && e.key !== 'ENTER') return false;
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (mlinePoints.length < 2) {
|
||||||
|
ctx.setStatus(`Mehrfachlinie benötigt mindestens 2 Punkte (aktuell ${mlinePoints.length})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!c.doc) return false;
|
||||||
|
const gap = Math.max(1, (c.options.gap as number | undefined) ?? 20);
|
||||||
|
const cap = (c.options.cap as boolean | undefined) ?? false;
|
||||||
|
const stroke = (c.options.strokeColor as string | undefined) ?? '#e0e0f0';
|
||||||
|
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
|
||||||
|
const a = offsetPolyline(mlinePoints, gap / 2);
|
||||||
|
const b = offsetPolyline(mlinePoints, -gap / 2);
|
||||||
|
const mkLine = (pts: Pt[]): CADElement => ({
|
||||||
|
id: uid('mline'),
|
||||||
|
type: 'polyline',
|
||||||
|
layerId,
|
||||||
|
x: pts.reduce((s, p) => s + p.x, 0) / pts.length,
|
||||||
|
y: pts.reduce((s, p) => s + p.y, 0) / pts.length,
|
||||||
|
width: Math.max(...pts.map((p) => p.x)) - Math.min(...pts.map((p) => p.x)),
|
||||||
|
height: Math.max(...pts.map((p) => p.y)) - Math.min(...pts.map((p) => p.y)),
|
||||||
|
properties: { points: pts, stroke, strokeWidth: 1, mline: true, gap },
|
||||||
|
} as unknown as CADElement);
|
||||||
|
const els: CADElement[] = [mkLine(a), mkLine(b)];
|
||||||
|
if (cap) {
|
||||||
|
const capAt = (i: number): CADElement => ({
|
||||||
|
id: uid('mlinecap'),
|
||||||
|
type: 'line',
|
||||||
|
layerId,
|
||||||
|
x: (a[i].x + b[i].x) / 2,
|
||||||
|
y: (a[i].y + b[i].y) / 2,
|
||||||
|
width: Math.abs(b[i].x - a[i].x),
|
||||||
|
height: Math.abs(b[i].y - a[i].y),
|
||||||
|
properties: {
|
||||||
|
x1: a[i].x, y1: a[i].y, x2: b[i].x, y2: b[i].y,
|
||||||
|
stroke, strokeWidth: 1, mlineCap: true, mline: true,
|
||||||
|
},
|
||||||
|
} as unknown as CADElement);
|
||||||
|
els.push(capAt(0), capAt(a.length - 1));
|
||||||
|
}
|
||||||
|
c.doc.transact(() => {
|
||||||
|
for (const el of els) c.doc!.addElement(el);
|
||||||
|
});
|
||||||
|
const count = mlinePoints.length;
|
||||||
|
mlinePoints = [];
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`Mehrfachlinie erstellt (${count} Punkte${cap ? ', mit Endkappen' : ''})`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
mlinePoints = [];
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Mehrfachlinie abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const coreDrawingPlugin: PluginV2 = {
|
export const coreDrawingPlugin: PluginV2 = {
|
||||||
manifest: {
|
manifest: {
|
||||||
id: 'core-drawing',
|
id: 'core-drawing',
|
||||||
@@ -901,5 +1032,5 @@ export const coreDrawingPlugin: PluginV2 = {
|
|||||||
category: 'tools',
|
category: 'tools',
|
||||||
enabledByDefault: true,
|
enabledByDefault: true,
|
||||||
},
|
},
|
||||||
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool],
|
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool, mlineTool],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import {
|
|||||||
extendElement,
|
extendElement,
|
||||||
filletElements,
|
filletElements,
|
||||||
} from '../../../tools/modification/geometry';
|
} from '../../../tools/modification/geometry';
|
||||||
|
import { openBlockForEdit, saveBlockEdit, discardBlockEdit } from '../../../services/blockEditorService';
|
||||||
|
import { BLOCK_EDIT_MARKER } from '../../../services/blockEditorService';
|
||||||
|
export { BLOCK_EDIT_MARKER };
|
||||||
import type { Pt } from '../../../tools/modification/geometry';
|
import type { Pt } from '../../../tools/modification/geometry';
|
||||||
|
|
||||||
const uid = (prefix: string): string =>
|
const uid = (prefix: string): string =>
|
||||||
@@ -1861,6 +1864,57 @@ export const measureTool: ToolExtensionV2 = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task CAD-5: Block-Editor — Blockdefinition in-place bearbeiten.
|
||||||
|
// Klick 1 auf Block-Instanz oeffnet (markierte Kopien erscheinen),
|
||||||
|
// Kopien normal bearbeiten, Klick 2 speichert zurueck in die
|
||||||
|
// Definition, cancel verwirft. (Service: blockEditorService)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const blockEditTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'block-edit',
|
||||||
|
label: 'Block bearbeiten',
|
||||||
|
icon: '\u2699',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['pro'],
|
||||||
|
description: 'Block-Instanz anklicken um die Definition zu bearbeiten (CAD-5)',
|
||||||
|
},
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc || !c.selection) return;
|
||||||
|
// Offene Sitzung? → speichern
|
||||||
|
if ((c.doc.getAllElements() as CADElement[]).some(
|
||||||
|
(el) => (el.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER],
|
||||||
|
)) {
|
||||||
|
const saved = saveBlockEdit(c.doc as never);
|
||||||
|
ctx.setStatus(saved ? `Block "${saved}" gespeichert` : 'Speichern fehlgeschlagen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hit = c.selection.hitTest(e.world.x, e.world.y, 8);
|
||||||
|
if (!hit || hit.type !== 'block_instance') {
|
||||||
|
ctx.setStatus('Block bearbeiten: Block-Instanz anklicken');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blockId = (hit.properties as Record<string, unknown>).blockId as string | undefined;
|
||||||
|
if (!blockId) {
|
||||||
|
ctx.setStatus('Block bearbeiten: Instanz ohne Block-Referenz');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opened = openBlockForEdit(c.doc as never, blockId);
|
||||||
|
ctx.setStatus(opened ? `Block "${blockId}" geöffnet — Elemente bearbeiten, erneut klicken speichert` : 'Block konnte nicht geöffnet werden');
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (c.doc) discardBlockEdit(c.doc as never);
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Block-Bearbeitung verworfen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const coreModifyPlugin: PluginV2 = {
|
export const coreModifyPlugin: PluginV2 = {
|
||||||
manifest: {
|
manifest: {
|
||||||
id: 'core-modify',
|
id: 'core-modify',
|
||||||
@@ -1871,5 +1925,5 @@ export const coreModifyPlugin: PluginV2 = {
|
|||||||
category: 'tools',
|
category: 'tools',
|
||||||
enabledByDefault: true,
|
enabledByDefault: true,
|
||||||
},
|
},
|
||||||
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool, arrayPathTool, divideTool, measureTool],
|
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool, arrayPathTool, divideTool, measureTool, blockEditTool],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Task CAD-5 – Block-Editor-Service.
|
||||||
|
*
|
||||||
|
* openBlockForEdit legt markierte KOPIEN der Block-Elemente ins
|
||||||
|
* Dokument (Original unangetastet); saveBlockEdit schreibt die
|
||||||
|
* bearbeiteten Kopien als neue Blockdefinition zurueck und
|
||||||
|
* raeumt die Edit-Kopien auf; discardBlockEdit verwirft alles.
|
||||||
|
* Marker: BLOCK_EDIT_MARKER (properties-Flag + __blockEdit-IDs).
|
||||||
|
*/
|
||||||
|
import type { CADElement, BlockDefinition } from '../types/cad.types';
|
||||||
|
import type { CADDocument } from '../kernel/document/CADDocument';
|
||||||
|
|
||||||
|
/** Marker-Flag in properties der Edit-Kopien. */
|
||||||
|
export const BLOCK_EDIT_MARKER = '__blockEdit';
|
||||||
|
|
||||||
|
/** Aktive Edit-Sitzung (Service-State). */
|
||||||
|
let editSession: { blockId: string; copyIds: string[] } | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oeffnet einen Block zur Bearbeitung: Kopien aller Elemente mit
|
||||||
|
* Edit-Marker werden ins Dokument gelegt (Original bleibt).
|
||||||
|
* @returns true wenn geoeffnet, false bei unbekannter ID oder
|
||||||
|
* bereits offener Sitzung.
|
||||||
|
*/
|
||||||
|
export function openBlockForEdit(doc: CADDocument, blockId: string): boolean {
|
||||||
|
if (editSession) return false;
|
||||||
|
const block = doc.getBlock(blockId);
|
||||||
|
if (!block) return false;
|
||||||
|
const copyIds: string[] = [];
|
||||||
|
doc.transact(() => {
|
||||||
|
for (const el of block.elements) {
|
||||||
|
const copy: CADElement = {
|
||||||
|
...el,
|
||||||
|
id: `__blockEdit_${blockId}_${copyIds.length}`,
|
||||||
|
properties: {
|
||||||
|
...(el.properties as Record<string, unknown>),
|
||||||
|
[BLOCK_EDIT_MARKER]: true,
|
||||||
|
},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
doc.addElement(copy);
|
||||||
|
copyIds.push(copy.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
editSession = { blockId, copyIds };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert die Bearbeitung: die markierten Kopien werden als
|
||||||
|
* neue Elementliste in die Blockdefinition geschrieben (Marker
|
||||||
|
* entfernt); Edit-Kopien werden aus dem Dokument geloescht.
|
||||||
|
* @returns blockId der gespeicherten Definition oder null.
|
||||||
|
*/
|
||||||
|
export function saveBlockEdit(doc: CADDocument): string | null {
|
||||||
|
if (!editSession) return null;
|
||||||
|
const { blockId, copyIds } = editSession;
|
||||||
|
const elements: CADElement[] = [];
|
||||||
|
for (const id of copyIds) {
|
||||||
|
const el = doc.getElement(id);
|
||||||
|
if (!el) continue;
|
||||||
|
const { [BLOCK_EDIT_MARKER]: _marker, ...cleanProps } =
|
||||||
|
el.properties as Record<string, unknown>;
|
||||||
|
elements.push({ ...el, id: uid('blk'), properties: cleanProps } as unknown as CADElement);
|
||||||
|
}
|
||||||
|
const block = doc.getBlock(blockId);
|
||||||
|
if (block) {
|
||||||
|
doc.addBlock({ ...block, elements });
|
||||||
|
}
|
||||||
|
doc.deleteElements(copyIds);
|
||||||
|
editSession = null;
|
||||||
|
return blockId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwirft die Bearbeitung: Edit-Kopien werden geloescht, die
|
||||||
|
* Blockdefinition bleibt unveraendert.
|
||||||
|
* @returns true wenn verworfen, false ohne offene Sitzung.
|
||||||
|
*/
|
||||||
|
export function discardBlockEdit(doc: CADDocument): boolean {
|
||||||
|
if (!editSession) return false;
|
||||||
|
doc.deleteElements(editSession.copyIds);
|
||||||
|
editSession = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist gerade eine Block-Bearbeitung offen? */
|
||||||
|
export function isBlockEditOpen(): boolean {
|
||||||
|
return editSession !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uidCounter = 0;
|
||||||
|
function uid(prefix: string): string {
|
||||||
|
return `${prefix}_${Date.now().toString(36)}_${uidCounter++}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* Task CAD-5 - MLINE (Mehrfachlinie) + Block-Editor.
|
||||||
|
*
|
||||||
|
* MLINE: Zwei parallele Linien (Wand/Rohr) mit gap-Offset ±gap/2
|
||||||
|
* entlang der Normalen, optionale Endkappen, ENTER-Commit ab
|
||||||
|
* 2 Punkten, ALLES in EINER Transaktion.
|
||||||
|
* Block-Editor: openBlockForEdit (markierte Kopien), saveBlockEdit
|
||||||
|
* (zurueckschreiben + aufraeumen), discard, blockEditTool-Flow.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mlineTool } from '../src/plugins/builtin/core-drawing';
|
||||||
|
import { blockEditTool, BLOCK_EDIT_MARKER } from '../src/plugins/builtin/core-modify';
|
||||||
|
import {
|
||||||
|
openBlockForEdit,
|
||||||
|
saveBlockEdit,
|
||||||
|
discardBlockEdit,
|
||||||
|
} from '../src/services/blockEditorService';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import type { CADElement, Pt, BlockDefinition } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function mkCtx(options: Record<string, unknown> = {}) {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const previews: (CADElement | null)[] = [];
|
||||||
|
const ctx: any = {
|
||||||
|
doc,
|
||||||
|
options,
|
||||||
|
setStatus: (m: string) => statuses.push(m),
|
||||||
|
setPreview: (el: CADElement | null) => previews.push(el),
|
||||||
|
};
|
||||||
|
return { ctx, doc, statuses, previews };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pe(x: number, y: number): { world: Pt } {
|
||||||
|
return { world: { x, y } } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
function key(k: string): { key: string } {
|
||||||
|
return { key: k } as never;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCK: BlockDefinition = {
|
||||||
|
id: 'blk-1', name: 'Muster', description: '', category: 'test',
|
||||||
|
elements: [
|
||||||
|
{ id: 'e1', type: 'rect', layerId: 'l', x: 50, y: 25, width: 100, height: 50, properties: {} },
|
||||||
|
{ id: 'e2', type: 'line', layerId: 'l', x: 50, y: 50, width: 100, height: 0, properties: { x1: 0, y1: 50, x2: 100, y2: 50 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Service-State-Leak verhindern: offene Edit-Sitzungen immer verwerfen
|
||||||
|
// (Dummy-Doc: deleteElements ignoriert fehlende IDs still)
|
||||||
|
const { ydoc: y2 } = createInMemoryDoc();
|
||||||
|
discardBlockEdit(new CADDocument(y2));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mlineTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||||
|
blockEditTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── MLINE ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CAD-5: mlineTool', () => {
|
||||||
|
it('horizontale Mittellinie: 2 parallele Linien mit gap/2 Abstand', () => {
|
||||||
|
const { ctx, doc } = mkCtx({ gap: 20 });
|
||||||
|
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
mlineTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||||
|
const lines = doc.getAllElements().filter((e) => e.type === 'polyline');
|
||||||
|
expect(lines).toHaveLength(2);
|
||||||
|
const ys = lines.map((l) => (l.properties.points as Pt[])[0].y).sort();
|
||||||
|
expect(ys[0]).toBeCloseTo(-10, 5);
|
||||||
|
expect(ys[1]).toBeCloseTo(10, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gap-Option steuert den Abstand (gap=10 → ±5)', () => {
|
||||||
|
const { ctx, doc } = mkCtx({ gap: 10 });
|
||||||
|
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
mlineTool.handlers.down!(pe(50, 0), ctx);
|
||||||
|
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||||
|
const lines = doc.getAllElements().filter((e) => e.type === 'polyline');
|
||||||
|
const ys = lines.map((l) => (l.properties.points as Pt[])[0].y).sort();
|
||||||
|
expect(ys[0]).toBeCloseTo(-5, 5);
|
||||||
|
expect(ys[1]).toBeCloseTo(5, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Endkappen-Option: 2 zusaetzliche Linien an Start und Ende', () => {
|
||||||
|
const { ctx, doc } = mkCtx({ gap: 20, cap: true });
|
||||||
|
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
mlineTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||||
|
const caps = doc.getAllElements().filter((e) => e.properties.mlineCap);
|
||||||
|
expect(caps).toHaveLength(2);
|
||||||
|
// Startkappe verbindet beide Linien (y=±10) bei x=0 — Zuordnung a/b
|
||||||
|
// ist Vorzeichenkonvention, daher reihenfolge-agnostisch pruefen:
|
||||||
|
const startCap = caps.find((c) => (c.properties.x1 as number) === 0);
|
||||||
|
expect(startCap).toBeDefined();
|
||||||
|
const ys = [startCap!.properties.y1, startCap!.properties.y2].sort((p, q) => (p as number) - (q as number)) as number[];
|
||||||
|
expect(ys[0]).toBeCloseTo(-10, 5);
|
||||||
|
expect(ys[1]).toBeCloseTo(10, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne cap: nur 2 Linien, keine Kappen', () => {
|
||||||
|
const { ctx, doc } = mkCtx({ gap: 20 });
|
||||||
|
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
mlineTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||||
|
expect(doc.getAllElements().filter((e) => e.properties.mlineCap)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('EINE Transaktion: 1 Undo entfernt alle MLINE-Teile', () => {
|
||||||
|
const { ctx, doc } = mkCtx({ gap: 20, cap: true });
|
||||||
|
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
mlineTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||||
|
expect(doc.getAllElements().length).toBe(4); // 2 Linien + 2 Kappen
|
||||||
|
doc.undo();
|
||||||
|
expect(doc.getAllElements()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ENTER mit weniger als 2 Punkten committet NICHT', () => {
|
||||||
|
const { ctx, doc, statuses } = mkCtx({ gap: 20 });
|
||||||
|
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||||
|
expect(doc.getAllElements()).toHaveLength(0);
|
||||||
|
expect(statuses[statuses.length - 1]).toContain('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('L-foermiger Pfad: beide Linien folgen der Geometrie', () => {
|
||||||
|
const { ctx, doc } = mkCtx({ gap: 20 });
|
||||||
|
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
mlineTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
mlineTool.handlers.down!(pe(100, 100), ctx);
|
||||||
|
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||||
|
const lines = doc.getAllElements().filter((e) => e.type === 'polyline');
|
||||||
|
expect(lines).toHaveLength(2);
|
||||||
|
for (const l of lines) {
|
||||||
|
const pts = l.properties.points as Pt[];
|
||||||
|
expect(pts).toHaveLength(3); // gleiche Punktanzahl wie Mittellinie
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Metadaten: mline:true und gap in properties', () => {
|
||||||
|
const { ctx, doc } = mkCtx({ gap: 20 });
|
||||||
|
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
mlineTool.handlers.down!(pe(100, 0), ctx);
|
||||||
|
mlineTool.handlers.key!(key('Enter'), ctx);
|
||||||
|
const line = doc.getAllElements().find((e) => e.properties.mline)!;
|
||||||
|
expect(line.properties.mline).toBe(true);
|
||||||
|
expect(line.properties.gap).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel resettet die Sitzung', () => {
|
||||||
|
const { ctx, doc } = mkCtx({ gap: 20 });
|
||||||
|
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
mlineTool.handlers.cancel?.(ctx);
|
||||||
|
mlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
mlineTool.handlers.key!(key('Enter'), ctx); // nur 1 Punkt der NEUEN Sitzung
|
||||||
|
expect(doc.getAllElements()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Manifest: id=mline, Optionen gap und cap', () => {
|
||||||
|
expect(mlineTool.manifest.id).toBe('mline');
|
||||||
|
const keys = (mlineTool.optionsSchema ?? []).map((f) => f.key);
|
||||||
|
expect(keys).toContain('gap');
|
||||||
|
expect(keys).toContain('cap');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Block-Editor Service ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CAD-5: blockEditorService', () => {
|
||||||
|
function mkDoc(): CADDocument {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
doc.addBlock(BLOCK);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('openBlockForEdit erzeugt markierte Kopien im Dokument', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
expect(openBlockForEdit(doc, 'blk-1')).toBe(true);
|
||||||
|
const copies = doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER]);
|
||||||
|
expect(copies).toHaveLength(2);
|
||||||
|
expect(doc.getBlock('blk-1')!.elements).toHaveLength(2); // Original unangetastet
|
||||||
|
});
|
||||||
|
|
||||||
|
it('openBlockForEdit mit unbekannter ID: false', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
expect(openBlockForEdit(doc, 'gibts-nicht')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveBlockEdit schreibt bearbeitete Elemente zurueck und raeumt auf', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
openBlockForEdit(doc, 'blk-1');
|
||||||
|
// Eine Kopie bearbeiten: rect verbreitern
|
||||||
|
const copies = doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER]);
|
||||||
|
const rectCopy = copies.find((c) => c.type === 'rect')!;
|
||||||
|
doc.updateElement(rectCopy.id, { width: 200 });
|
||||||
|
const saved = saveBlockEdit(doc);
|
||||||
|
expect(saved).toBe('blk-1');
|
||||||
|
// Blockdefinition hat die neue Breite
|
||||||
|
const savedRect = doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!;
|
||||||
|
expect(savedRect.width).toBe(200);
|
||||||
|
// Edit-Kopien sind aus dem Dokument entfernt
|
||||||
|
const remaining = doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER]);
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveBlockEdit ohne offene Sitzung: null', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
expect(saveBlockEdit(doc)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discardBlockEdit verwirft Aenderungen (Block unveraendert, Kopien weg)', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
openBlockForEdit(doc, 'blk-1');
|
||||||
|
const copies = doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER]);
|
||||||
|
doc.updateElement(copies[0].id, { width: 999 });
|
||||||
|
expect(discardBlockEdit(doc)).toBe(true);
|
||||||
|
expect(doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!.width).toBe(100);
|
||||||
|
expect(doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER])).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveBlockEdit entfernt Marker aus den gespeicherten Elementen', () => {
|
||||||
|
const doc = mkDoc();
|
||||||
|
openBlockForEdit(doc, 'blk-1');
|
||||||
|
saveBlockEdit(doc);
|
||||||
|
for (const el of doc.getBlock('blk-1')!.elements) {
|
||||||
|
expect((el.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER]).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── blockEditTool ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CAD-5: blockEditTool', () => {
|
||||||
|
function mkToolCtx() {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
doc.addBlock(BLOCK);
|
||||||
|
doc.addElement({
|
||||||
|
id: 'inst-1', type: 'block_instance', layerId: 'l',
|
||||||
|
x: 0, y: 0, width: 0, height: 0,
|
||||||
|
properties: { blockId: 'blk-1' },
|
||||||
|
} as unknown as CADElement);
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const ctx: any = {
|
||||||
|
doc,
|
||||||
|
options: {},
|
||||||
|
setStatus: (m: string) => statuses.push(m),
|
||||||
|
setPreview: (_el: CADElement | null) => {},
|
||||||
|
selection: {
|
||||||
|
getIds: () => [] as string[],
|
||||||
|
setIds: (_ids: string[]) => {},
|
||||||
|
hitTest: (_x: number, _y: number, _tol: number) => doc.getElement('inst-1'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ctx, doc, statuses };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('erster Klick auf Instanz oeffnet den Block-Editor (Kopien erscheinen)', () => {
|
||||||
|
const { ctx, doc, statuses } = mkToolCtx();
|
||||||
|
blockEditTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
expect(doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER])).toHaveLength(2);
|
||||||
|
expect(statuses[statuses.length - 1]).toContain('Block');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zweiter Klick speichert (Block aktualisiert, Kopien weg)', () => {
|
||||||
|
const { ctx, doc } = mkToolCtx();
|
||||||
|
blockEditTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
// Kopie bearbeiten
|
||||||
|
const copy = doc.getAllElements().find((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER] && e.type === 'rect')!;
|
||||||
|
doc.updateElement(copy.id, { width: 150 });
|
||||||
|
blockEditTool.handlers.down!(pe(0, 0), ctx); // speichern
|
||||||
|
expect(doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!.width).toBe(150);
|
||||||
|
expect(doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER])).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel verwirft die Bearbeitung', () => {
|
||||||
|
const { ctx, doc } = mkToolCtx();
|
||||||
|
blockEditTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
const copy = doc.getAllElements().find((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER] && e.type === 'rect')!;
|
||||||
|
doc.updateElement(copy.id, { width: 999 });
|
||||||
|
blockEditTool.handlers.cancel?.(ctx);
|
||||||
|
expect(doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!.width).toBe(100);
|
||||||
|
expect(doc.getAllElements().filter((e) => (e.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER])).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Klick ohne Instanz-Treffer: Status erklaert, nichts passiert', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
doc.addBlock(BLOCK);
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const ctx: any = {
|
||||||
|
doc,
|
||||||
|
options: {},
|
||||||
|
setStatus: (m: string) => statuses.push(m),
|
||||||
|
setPreview: () => {},
|
||||||
|
selection: { getIds: () => [], setIds: () => {}, hitTest: () => null },
|
||||||
|
};
|
||||||
|
blockEditTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
expect(doc.getAllElements()).toHaveLength(0);
|
||||||
|
expect(statuses[statuses.length - 1]).toContain('Block-Instanz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Manifest: id=block-edit', () => {
|
||||||
|
expect(blockEditTool.manifest.id).toBe('block-edit');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user