2D: Kreis/Bogen-Zeichenelemente vollständig anwählbar und editierbar

Seit der Umstellung auf echte SVG-Kreis-/Bogen-Primitive (drawingCircle/
drawingArc statt 64-Ecken-Polygon-Annäherung) wurde die Interaktionsschicht
nie nachgezogen — betraf drei unabhängige Stellen mit derselben Ursache:

- Einzelklick: pickDrawing() testete nur kind==="line", fiel sonst auf
  Text-/Bild-Bbox zurück. Neuer distToDrawingRing()-Zweig: Ring-Abstand in
  Bildschirm-Pixeln (zoom-unabhängig wie die Linien-Toleranz), bei gefüllten
  Kreisen zählt auch innerhalb als Treffer; Bögen zusätzlich auf ihre
  Winkel-Spanne begrenzt. grabbedBody() (Körper-Verschieben) profitiert
  automatisch mit, da es denselben Picker nutzt.
- Marquee (Rahmenauswahl): marqueeHitDrawings() sammelte nur line/polygon —
  drawingCircle/drawingArc UND drawingText/drawingImage fehlten komplett
  (Text/Bild waren nie per Rahmen erfassbar, unabhängig von der Kreis-Frage).
  Kreis/Bogen werden für den Punkt-Test auf Ring-Stützpunkte abgetastet
  (Bogen nur innerhalb seiner Winkel-Spanne, nicht als Vollkreis), Text/Bild
  über ihre Bounding-Box.
- Griffe: drawingVertices() hatte keinen Fall für circle/arc → leeres Array,
  selbst nach Fix von Klick+Marquee also unverschiebbar/nicht editierbar.
  Kreis bekommt einen Radius-Griff (Ost), Bogen Start-/End-Winkel-Griffe plus
  einen Radius-Griff auf halbem Bogen — moveGrip() entsprechend erweitert.

10 neue Tests (drawingVertices/moveGripOf für Kreis/Bogen, marqueeHitDrawings
für alle vier zuvor fehlenden Primitive-Arten). tsc -b / vitest run
(901/901) / npm run build grün. Der Einzelklick-Pfad selbst (PlanView.tsx-
Closures) ist nicht automatisiert testbar — visuell in der Tauri-App prüfen.
This commit is contained in:
2026-08-20 20:31:46 +02:00
parent 2154e1ca6c
commit 1c3fb289d0
5 changed files with 297 additions and 10 deletions
@@ -0,0 +1,85 @@
/**
* Editier-Griffe für Kreis/Bogen-2D-Elemente (`drawingVertices`/`moveGripOf`
* in `projectSlice.ts`). Vorher gab es dafür keine Griffe (leeres Array) —
* Regressionsschutz für den Nachzieh-Fix (2D-Vervollständigung, Fund A).
*/
import { describe, it, expect, beforeEach } from "vitest";
import { getState, setState } from "./appStore";
import { sampleProject } from "../model/sampleProject";
import { drawingVertices } from "./projectSlice";
import type { Drawing2D } from "../model/types";
const circleDrawing: Drawing2D = {
id: "d-circle",
type: "drawing2d",
levelId: "eg",
categoryCode: "00",
geom: { shape: "circle", center: { x: 2, y: 3 }, r: 1 },
};
const arcDrawing: Drawing2D = {
id: "d-arc",
type: "drawing2d",
levelId: "eg",
categoryCode: "00",
geom: { shape: "arc", center: { x: 0, y: 0 }, r: 2, a0: 0, a1: Math.PI / 2 },
};
beforeEach(() => {
setState({
project: {
...sampleProject,
drawings2d: [circleDrawing, arcDrawing],
},
undoStack: [],
redoStack: [],
canUndo: false,
canRedo: false,
});
});
describe("drawingVertices — Kreis/Bogen", () => {
it("Kreis liefert einen Radius-Griff auf der Ost-Seite", () => {
const v = drawingVertices(circleDrawing);
expect(v).toEqual([{ x: 3, y: 3 }]);
});
it("Bogen liefert Start-/End-/Mittel-Winkel-Griffe", () => {
const v = drawingVertices(arcDrawing);
expect(v.length).toBe(3);
expect(v[0]).toEqual({ x: 2, y: 0 }); // a0 = 0
expect(v[1].x).toBeCloseTo(0, 9); // a1 = PI/2
expect(v[1].y).toBeCloseTo(2, 9);
});
});
describe("moveGripOf — Kreis/Bogen", () => {
it("Kreis-Griff ziehen ändert nur den Radius, nicht das Zentrum", () => {
getState().moveGripOf("d-circle", null, 0, { x: 5, y: 3 }); // 3m vom Zentrum
const g = getState().project.drawings2d.find((d) => d.id === "d-circle")!.geom;
expect(g).toMatchObject({ shape: "circle", center: { x: 2, y: 3 }, r: 3 });
});
it("Bogen-Start-Griff (Index 0) ändert a0, nicht r/a1", () => {
getState().moveGripOf("d-arc", null, 0, { x: 0, y: 2 }); // 90° = PI/2
const g = getState().project.drawings2d.find((d) => d.id === "d-arc")!.geom;
expect(g.shape).toBe("arc");
if (g.shape === "arc") {
expect(g.a0).toBeCloseTo(Math.PI / 2, 9);
expect(g.a1).toBeCloseTo(Math.PI / 2, 9); // unverändert
expect(g.r).toBeCloseTo(2, 9); // unverändert
}
});
it("Bogen-Radius-Griff (Index 2) ändert nur r, nicht a0/a1", () => {
getState().moveGripOf("d-arc", null, 2, { x: 4, y: 0 }); // gleiche Richtung, doppelter Radius
const g = getState().project.drawings2d.find((d) => d.id === "d-arc")!.geom;
expect(g.shape).toBe("arc");
if (g.shape === "arc") {
expect(g.a0).toBeCloseTo(0, 9);
expect(g.a1).toBeCloseTo(Math.PI / 2, 9);
expect(g.r).toBeCloseTo(4, 9);
}
});
});
+30 -2
View File
@@ -1438,8 +1438,10 @@ function resizeGeom(
// ── Editier-Griffe (verschoben aus App.tsx, 1:1) ─────────────────────────────
/**
* Editier-Griffe (Eckpunkte) eines 2D-Zeichenelements in Modell-Metern. Phase 4
* unterstützt line/polyline/rect; circle/arc/text bekommen (noch) keine Griffe.
* Editier-Griffe (Eckpunkte) eines 2D-Zeichenelements in Modell-Metern.
* line/polyline/rect: Eckpunkte. circle: EIN Radius-Griff (Ost, Index 0).
* arc: Start-/End-Winkel-Griff (Index 0/1, ändern a0/a1) + ein Radius-Griff
* auf halbem Bogen (Index 2, ändert r) — siehe {@link moveGrip}.
*/
export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
const g = d.geom;
@@ -1453,6 +1455,17 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
{ x: g.min.x, y: g.max.y },
];
}
if (g.shape === "circle") {
return [{ x: g.center.x + g.r, y: g.center.y }];
}
if (g.shape === "arc") {
const mid = (g.a0 + g.a1) / 2;
return [
{ x: g.center.x + g.r * Math.cos(g.a0), y: g.center.y + g.r * Math.sin(g.a0) },
{ x: g.center.x + g.r * Math.cos(g.a1), y: g.center.y + g.r * Math.sin(g.a1) },
{ x: g.center.x + g.r * Math.cos(mid), y: g.center.y + g.r * Math.sin(mid) },
];
}
// Text: EIN Griff am Ankerpunkt (zum Verschieben; siehe moveGrip).
if (g.shape === "text") return [g.at];
return [];
@@ -1528,6 +1541,21 @@ function moveGrip(
}
// Text: der einzige Griff (Index 0) sitzt am Ankerpunkt → verschiebt ihn.
if (g.shape === "text") return { ...d, geom: { ...g, at: pt } };
// Kreis: der einzige Griff (Index 0) ändert nur den Radius.
if (g.shape === "circle") {
const r = Math.max(1e-3, Math.hypot(pt.x - g.center.x, pt.y - g.center.y));
return { ...d, geom: { ...g, r } };
}
// Bogen: Griff 0/1 (Start-/End-Winkel, s. drawingVertices) ändert a0/a1;
// Griff 2 (Bogenmitte) ändert nur den Radius (Winkel bleiben fest).
if (g.shape === "arc") {
if (index === 2) {
const r = Math.max(1e-3, Math.hypot(pt.x - g.center.x, pt.y - g.center.y));
return { ...d, geom: { ...g, r } };
}
const ang = Math.atan2(pt.y - g.center.y, pt.x - g.center.x);
return { ...d, geom: index === 0 ? { ...g, a0: ang } : { ...g, a1: ang } };
}
return d;
}),
};