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:
+41
-8
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user