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
+41 -8
View File
@@ -900,23 +900,56 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return null;
};
// Abstand (Bildschirm-Pixel) eines Klicks vom Ring eines Kreis/Bogen-2D-
// Elements — bei gefüllten Kreisen zählt auch INNERHALB als Treffer (d=0).
// Bögen zusätzlich auf ihre Winkel-Spanne begrenzt (CCW, Modell-Radiant, s.
// toRenderScene.ts für dieselbe Normalisierung).
const distToDrawingRing = (
clientX: number,
clientY: number,
p: Extract<Primitive, { kind: "drawingCircle" | "drawingArc" }>,
): number => {
const m = rawModelAt(clientX, clientY);
const distFromCenter = Math.hypot(m.x - p.center.x, m.y - p.center.y);
if (p.kind === "drawingArc") {
const TAU = Math.PI * 2;
const sweep = (((p.a1 - p.a0) % TAU) + TAU) % TAU || TAU;
const ang = Math.atan2(m.y - p.center.y, m.x - p.center.x);
const rel = (((ang - p.a0) % TAU) + TAU) % TAU;
// Kleiner Winkel-Toleranzsaum (in Bogenmass, radiusabhängig), damit die
// Enden eines kurzen Bogens nicht unerreichbar schmal werden.
const angTolRad = distFromCenter > 1e-6 ? LINE_PICK_PX / (currentPxPerMeter() * Math.max(distFromCenter, 0.05)) : Infinity;
if (rel > sweep + angTolRad && TAU - rel > angTolRad) return Infinity;
}
const filled = p.kind === "drawingCircle" && p.fill !== "none";
const dMeters = filled ? Math.max(0, distFromCenter - p.r) : Math.abs(distFromCenter - p.r);
return dMeters * currentPxPerMeter();
};
// Nächstgelegenes 2D-Zeichenelement (Drawing2D) zum Cursor per Linien-Nähe in
// Bildschirm-Pixeln (zoom-unabhängige Toleranz). Liefert die drawingId oder null.
const pickDrawing = (clientX: number, clientY: number): string | null => {
let bestId: string | null = null;
let bestDist = LINE_PICK_PX;
for (const p of plan.primitives) {
if (p.kind !== "line" || !p.drawingId) continue;
const a = vbToClient(toScreen(p.a).x, toScreen(p.a).y);
const b = vbToClient(toScreen(p.b).x, toScreen(p.b).y);
const d = pointSegDistPx({ x: clientX, y: clientY }, a, b);
if (d < bestDist) {
bestDist = d;
bestId = p.drawingId;
if (p.kind === "line" && p.drawingId) {
const a = vbToClient(toScreen(p.a).x, toScreen(p.a).y);
const b = vbToClient(toScreen(p.b).x, toScreen(p.b).y);
const d = pointSegDistPx({ x: clientX, y: clientY }, a, b);
if (d < bestDist) {
bestDist = d;
bestId = p.drawingId;
}
} else if ((p.kind === "drawingCircle" || p.kind === "drawingArc") && p.drawingId) {
const d = distToDrawingRing(clientX, clientY, p);
if (d < bestDist) {
bestDist = d;
bestId = p.drawingId;
}
}
}
if (bestId) return bestId;
// Kein Linien-Treffer: Text- dann Bild-Elemente prüfen (eigene Bbox-Logik).
// Kein Linien-/Kreis-/Bogen-Treffer: Text- dann Bild-Elemente prüfen (eigene Bbox-Logik).
return pickDrawingText(clientX, clientY) ?? pickDrawingImage(clientX, clientY);
};
+102
View File
@@ -0,0 +1,102 @@
/**
* `marqueeHitDrawings` — Rahmenauswahl von 2D-Zeichenelementen. Vorher fehlten
* drawingCircle/drawingArc/drawingText/drawingImage komplett (nur line/polygon
* abgedeckt) — Regressionsschutz für den Nachzieh-Fix (2D-Vervollständigung,
* Fund A).
*/
import { describe, it, expect } from "vitest";
import { marqueeHitDrawings, type ModelRect } from "./geometry";
import type { Primitive } from "../generatePlan";
const encloseRect: ModelRect = { minX: -10, minY: -10, maxX: 10, maxY: 10 };
const missRect: ModelRect = { minX: 20, minY: 20, maxX: 30, maxY: 30 };
describe("marqueeHitDrawings — Kreis/Bogen/Text/Bild", () => {
it("Kreis vollständig im Rechteck → window-Treffer", () => {
const prims: Primitive[] = [
{
kind: "drawingCircle",
center: { x: 0, y: 0 },
r: 2,
fill: "none",
stroke: "#000",
weightMm: 0.25,
drawingId: "c1",
},
];
expect(marqueeHitDrawings(prims, encloseRect, false)).toEqual(["c1"]);
expect(marqueeHitDrawings(prims, missRect, false)).toEqual([]);
});
it("Bogen wird nur auf seiner Winkel-Spanne abgetastet (nicht als Vollkreis)", () => {
// Viertelbogen 0..PI/2 bei r=2 um den Ursprung: liegt komplett im 1. Quadranten.
const prims: Primitive[] = [
{
kind: "drawingArc",
center: { x: 0, y: 0 },
r: 2,
a0: 0,
a1: Math.PI / 2,
stroke: "#000",
weightMm: 0.25,
drawingId: "a1",
},
];
// Rechteck deckt NUR den 1. Quadranten ab — ein Vollkreis-Sampling würde
// hier fälschlich als "nicht enclosed" durchfallen (Punkte im 3. Quadranten
// wären ausserhalb), der Viertelbogen muss aber als vollständig erfasst gelten.
const q1Rect: ModelRect = { minX: -0.1, minY: -0.1, maxX: 2.1, maxY: 2.1 };
expect(marqueeHitDrawings(prims, q1Rect, false)).toEqual(["a1"]);
});
it("Text-Bbox wird erfasst (vorher: gar nicht per Marquee erfassbar)", () => {
const prims: Primitive[] = [
{
kind: "drawingText",
at: { x: 1, y: 1 },
text: "Hallo",
heightM: 0.3,
angle: 0,
color: "#000",
drawingId: "t1",
},
];
expect(marqueeHitDrawings(prims, encloseRect, false)).toEqual(["t1"]);
expect(marqueeHitDrawings(prims, missRect, false)).toEqual([]);
});
it("Bild-Bbox wird erfasst (vorher: gar nicht per Marquee erfassbar)", () => {
const prims: Primitive[] = [
{
kind: "drawingImage",
min: { x: -1, y: -1 },
max: { x: 1, y: 1 },
src: "data:image/png;base64,",
opacity: 1,
drawingId: "i1",
},
];
expect(marqueeHitDrawings(prims, encloseRect, false)).toEqual(["i1"]);
expect(marqueeHitDrawings(prims, missRect, false)).toEqual([]);
});
it("crossing-Modus: Kreis, der das Rechteck nur teilweise überlappt, zählt bereits; window-Modus nicht", () => {
// Zentrum ausserhalb, Radius reicht bei Winkel 0 klar ins Rechteck hinein
// (x=15+6=21, innerhalb [10,20]) — aber nicht der ganze Ring liegt drin.
const prims: Primitive[] = [
{
kind: "drawingCircle",
center: { x: 15, y: 0 },
r: 6,
fill: "none",
stroke: "#000",
weightMm: 0.25,
drawingId: "c2",
},
];
const rightRect: ModelRect = { minX: 10, minY: -10, maxX: 20, maxY: 10 };
expect(marqueeHitDrawings(prims, rightRect, true)).toEqual(["c2"]);
expect(marqueeHitDrawings(prims, rightRect, false)).toEqual([]);
});
});
+39
View File
@@ -246,6 +246,34 @@ export function marqueeHit(
* den Linien-/Polygon-Primitiven; crossing = irgendein Punkt/Kante im Rechteck,
* window = alle Punkte vollständig eingeschlossen.
*/
/** Punkte auf einem Kreis-/Bogen-Ring (Modell-Meter), für Marquee-Zwecke abgetastet. */
function ringSamplePoints(center: Vec2, r: number, a0?: number, a1?: number): Vec2[] {
const TAU = Math.PI * 2;
const full = a0 === undefined || a1 === undefined;
const start = full ? 0 : a0;
const sweep = full ? TAU : (((a1 - a0) % TAU) + TAU) % TAU || TAU;
const steps = full ? 32 : Math.max(4, Math.round((sweep / TAU) * 32));
const pts: Vec2[] = [];
for (let i = 0; i <= steps; i++) {
const a = start + (sweep * i) / steps;
pts.push({ x: center.x + r * Math.cos(a), y: center.y + r * Math.sin(a) });
}
return pts;
}
/** Bounding-Box-Eckpunkte eines `drawingText`-Primitivs, analog zur Einzelklick-Bbox in PlanView.tsx. */
function drawingTextBboxPoints(p: Extract<Primitive, { kind: "drawingText" }>): Vec2[] {
const singleW = Math.max(1, p.text.length) * p.heightM * 0.6;
const w = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth : singleW;
const lines = p.wrapWidth && p.wrapWidth > 0 ? Math.max(1, Math.ceil(singleW / p.wrapWidth)) : 1;
const pad = p.heightM * 0.6;
const minX = p.at.x - pad;
const maxX = p.at.x + w + pad;
const minY = p.at.y - lines * p.heightM * 1.25 - pad;
const maxY = p.at.y + p.heightM * 0.4 + pad;
return [{ x: minX, y: minY }, { x: maxX, y: minY }, { x: maxX, y: maxY }, { x: minX, y: maxY }];
}
export function marqueeHitDrawings(
prims: Primitive[],
rect: ModelRect,
@@ -260,6 +288,17 @@ export function marqueeHitDrawings(
for (const p of prims) {
if (p.kind === "line" && p.drawingId) collect(p.drawingId, [p.a, p.b]);
else if (p.kind === "polygon" && p.drawingId) collect(p.drawingId, p.pts);
else if (p.kind === "drawingCircle" && p.drawingId) collect(p.drawingId, ringSamplePoints(p.center, p.r));
else if (p.kind === "drawingArc" && p.drawingId) collect(p.drawingId, ringSamplePoints(p.center, p.r, p.a0, p.a1));
else if (p.kind === "drawingText" && p.drawingId) collect(p.drawingId, drawingTextBboxPoints(p));
else if (p.kind === "drawingImage" && p.drawingId) {
collect(p.drawingId, [
p.min,
{ x: p.max.x, y: p.min.y },
p.max,
{ x: p.min.x, y: p.max.y },
]);
}
}
const out: string[] = [];
for (const [id, pts] of byDrawing) {
@@ -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;
}),
};