diff --git a/src/App.tsx b/src/App.tsx index 88fc7a7..098491f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1293,6 +1293,7 @@ export default function App() { // Auswahl — siehe state/useGripEditing.ts. const { grips, + gripHandleLines, edgeGrips, gripHandlers, gripEdit, @@ -2800,6 +2801,7 @@ export default function App() { toolHandlers={toolHandlers} hudFields={hudFields} grips={grips} + gripHandleLines={gripHandleLines} edgeGrips={edgeGrips} selectedDrawingId={selectedDrawingId} selectedDrawingIds={selectedDrawingIds} diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index a1f7ac4..baabde8 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -382,6 +382,12 @@ export interface PlanViewProps { * Auswahl-Werkzeug sichtbar/aktiv. */ grips?: Vec2[]; + /** + * Bezier-Hilfslinien (Anker → Kontrollpunkt-Griff) des selektierten + * Elements — gestrichelt gerendert, damit sichtbar bleibt, welcher Griff + * welchen Kurvenabschnitt steuert (s. `useGripEditing.ts`). + */ + gripHandleLines?: { a: Vec2; b: Vec2 }[]; /** * Kanten-/Seiten-Griffe (Edge Grips) des selektierten Elements: je Seite ein * dreieckiger Anfasser am Kanten-Mittelpunkt, dessen Spitze nach AUSSEN zeigt. @@ -499,6 +505,7 @@ export const PlanView = forwardRef( showRulers = true, hudFields, grips, + gripHandleLines, edgeGrips, hairline, selectedDrawingId, @@ -2581,6 +2588,21 @@ export const PlanView = forwardRef( pointerEvents="none" /> )} + {/* Bezier-Hilfslinien (Anker → Griff), gestrichelt — VOR den Griff- + Quadraten gezeichnet, damit die Griffe oben liegen (anklickbar). */} + {gripsActive && + gripHandleLines && + gripHandleLines.map((l, i) => ( + + ))} {/* Editier-Griffe des selektierten Elements (Wand-Enden / 2D-Vertices): ziehbare Quadrate. Bildschirmkonstante Größe über die meet-Skala. */} {gripsActive && diff --git a/src/state/projectSlice.drawingGrips.test.ts b/src/state/projectSlice.drawingGrips.test.ts index 4a62df7..d3ad0e1 100644 --- a/src/state/projectSlice.drawingGrips.test.ts +++ b/src/state/projectSlice.drawingGrips.test.ts @@ -40,9 +40,9 @@ beforeEach(() => { }); describe("drawingVertices — Kreis/Bogen", () => { - it("Kreis liefert einen Radius-Griff auf der Ost-Seite", () => { + it("Kreis liefert zwei Griffe (Ost/Nord) — wie eine Ellipse", () => { const v = drawingVertices(circleDrawing); - expect(v).toEqual([{ x: 3, y: 3 }]); + expect(v).toEqual([{ x: 3, y: 3 }, { x: 2, y: 4 }]); }); it("Bogen liefert Start-/End-/Mittel-Winkel-Griffe", () => { @@ -55,8 +55,21 @@ describe("drawingVertices — Kreis/Bogen", () => { }); 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 + it("Kreis-Ost-Griff auf denselben Radius gezogen bleibt ein Kreis", () => { + getState().moveGripOf("d-circle", null, 0, { x: 3, y: 3 }); // weiterhin 1m Ost + const g = getState().project.drawings2d.find((d) => d.id === "d-circle")!.geom; + expect(g).toMatchObject({ shape: "circle", center: { x: 2, y: 3 }, r: 1 }); + }); + + it("Kreis-Ost-Griff auf einen ANDEREN Radius gezogen wird zur Ellipse (Nutzer-Wunsch: Kreis kann beim Bearbeiten zur Ellipse werden)", () => { + getState().moveGripOf("d-circle", null, 0, { x: 5, y: 3 }); // 3m Ost statt 1m + const g = getState().project.drawings2d.find((d) => d.id === "d-circle")!.geom; + expect(g).toMatchObject({ shape: "ellipse", center: { x: 2, y: 3 }, rx: 3, ry: 1, rotation: 0 }); + }); + + it("Ellipse-Griff zurück auf (fast) gleiche Halbachsen gezogen wird wieder zum Kreis", () => { + getState().moveGripOf("d-circle", null, 0, { x: 5, y: 3 }); // → Ellipse rx=3, ry=1 + getState().moveGripOf("d-circle", null, 1, { x: 2, y: 6 }); // Nord-Griff auf ry=3 const g = getState().project.drawings2d.find((d) => d.id === "d-circle")!.geom; expect(g).toMatchObject({ shape: "circle", center: { x: 2, y: 3 }, r: 3 }); }); diff --git a/src/state/projectSlice.ts b/src/state/projectSlice.ts index ce97cfe..fbc2c96 100644 --- a/src/state/projectSlice.ts +++ b/src/state/projectSlice.ts @@ -1452,6 +1452,24 @@ function resizeGeom( } } +/** + * Kreis, solange rx≈ry (relative Toleranz 0.5%) — sonst Ellipse (rotation 0). + * Trägt den Kreis-zu-Ellipse-Formwandel beim Griff-Ziehen (Nutzer-Wunsch: + * „Kreis wird wie bisher erstellt, kann beim Bearbeiten zur Ellipse werden"); + * symmetrisch nutzbar auch für die Rückrichtung (Ellipse → Kreis, wenn man + * die Halbachsen wieder aneinander annähert). + */ +function ellipseOrCircle( + center: Vec2, + rx: number, + ry: number, +): import("../model/types").Drawing2DGeom { + if (Math.abs(rx - ry) <= 0.005 * Math.max(rx, ry)) { + return { shape: "circle", center, r: (rx + ry) / 2 }; + } + return { shape: "ellipse", center, rx, ry, rotation: 0 }; +} + // ── Editier-Griffe (verschoben aus App.tsx, 1:1) ───────────────────────────── /** @@ -1472,8 +1490,16 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] { { x: g.min.x, y: g.max.y }, ]; } + // Kreis: zwei Griffe (Ost/Nord, wie bei der Ellipse) — zieht man EINEN der + // beiden unabhängig, wird der Kreis zur Ellipse (s. `moveGrip`). So bleibt + // ein Kreis ein Kreis, solange man ihn nicht gezielt verzerrt (Nutzer- + // Wunsch: „Kreis erstellen wie bisher, beim Bearbeiten kann er zur Ellipse + // werden"). if (g.shape === "circle") { - return [{ x: g.center.x + g.r, y: g.center.y }]; + return [ + { x: g.center.x + g.r, y: g.center.y }, + { x: g.center.x, y: g.center.y + g.r }, + ]; } if (g.shape === "arc") { const mid = (g.a0 + g.a1) / 2; @@ -1569,10 +1595,14 @@ 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. + // Kreis: Griff 0 (Ost) bzw. 1 (Nord) — zieht man die beiden Achsen + // unabhängig auseinander, wird der Kreis zur Ellipse (rotation 0); + // bleiben rx/ry (fast) gleich, bleibt es ein Kreis (s. `ellipseOrCircle`). 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 } }; + const dx = pt.x - g.center.x, dy = pt.y - g.center.y; + const rx = index === 0 ? Math.max(1e-3, Math.abs(dx)) : g.r; + const ry = index === 0 ? g.r : Math.max(1e-3, Math.abs(dy)); + return { ...d, geom: ellipseOrCircle(g.center, rx, ry) }; } // Bogen: Griff 0/1 (Start-/End-Winkel, s. drawingVertices) ändert a0/a1; // Griff 2 (Bogenmitte) ändert nur den Radius (Winkel bleiben fest). @@ -1592,8 +1622,13 @@ function moveGrip( const dx = pt.x - g.center.x, dy = pt.y - g.center.y; const lx = dx * c + dy * s; const ly = -dx * s + dy * c; - if (index === 0) return { ...d, geom: { ...g, rx: Math.max(1e-3, Math.abs(lx)) } }; - return { ...d, geom: { ...g, ry: Math.max(1e-3, Math.abs(ly)) } }; + const rx = index === 0 ? Math.max(1e-3, Math.abs(lx)) : g.rx; + const ry = index === 0 ? g.ry : Math.max(1e-3, Math.abs(ly)); + // Nur bei rotation 0 kann eine (fast) gleichseitige Ellipse wieder + // zum Kreis kollabieren — eine gedrehte Ellipse mit rx≈ry ist optisch + // bereits ein Kreis, „Drehung" wäre dann aber wieder bedeutungslos. + if (g.rotation === 0) return { ...d, geom: ellipseOrCircle(g.center, rx, ry) }; + return { ...d, geom: { ...g, rx, ry } }; } return d; }), diff --git a/src/state/useGripEditing.ts b/src/state/useGripEditing.ts index dbbd1b2..6363292 100644 --- a/src/state/useGripEditing.ts +++ b/src/state/useGripEditing.ts @@ -202,6 +202,26 @@ export function useGripEditing(opts: { return out; })(); + // Bezier-Hilfslinien (Anker → Kontrollpunkt-Griff), gestrichelt gerendert, + // damit sichtbar/nachvollziehbar ist, welcher Griff welchen Kurvenabschnitt + // steuert (Nutzer-Wunsch: „Hilfslinien sollten schon sichtbar sein um sie + // zu steuern" — wie bei Illustrator/Inkscape üblich). `pts` folgt dem Muster + // Anker·Griff·Griff·Anker·… (s. `Drawing2DGeom["bezier"]`); jeder Anker + // bekommt eine Linie zu seinem/seinen benachbarten Griff(en). + const gripHandleLines: { a: Vec2; b: Vec2 }[] = (() => { + const out: { a: Vec2; b: Vec2 }[] = []; + for (const id of effectiveDrawingIds) { + const d = project.drawings2d.find((x) => x.id === id); + const g = d?.geom; + if (!g || g.shape !== "bezier") continue; + for (let i = 0; i + 3 < g.pts.length; i += 3) { + out.push({ a: g.pts[i], b: g.pts[i + 1] }); // Anker → Griff 1 + out.push({ a: g.pts[i + 3], b: g.pts[i + 2] }); // nächster Anker → Griff 2 + } + } + return out; + })(); + // Nachbar-Griff-Index INNERHALB desselben Elements (für Anker-Auflösung). // null = kein Nachbar möglich (Einzelgriff-Element wie Kreis/Öffnung/Treppe). const neighborGripIndex = (index: number): number | null => { @@ -1048,6 +1068,7 @@ export function useGripEditing(opts: { return { grips, + gripHandleLines, edgeGrips, gripHandlers, gripEdit, diff --git a/src/styles.css b/src/styles.css index 488b1f0..678a45e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2143,6 +2143,16 @@ body { vector-effect: non-scaling-stroke; } +/* Bezier-Hilfslinien (Anker → Kontrollpunkt-Griff): dünn, gestrichelt, deutlich + zurückhaltender als der Kurven-Umriss selbst — reine Steuerungshilfe. */ +.plan-svg .plan-grip-line { + stroke: var(--accent-light); + stroke-width: 1; + stroke-dasharray: 3 3; + vector-effect: non-scaling-stroke; + fill: none; +} + /* Kanten-/Seiten-Griffe (Edge Grips): dreieckiger Anfasser je Seite, Spitze nach außen. Gleiche Akzent-/Rand-Farben wie die Eckpunkt-Griffe. */ .plan-svg .plan-edge-grip { diff --git a/src/views/viewportContent.tsx b/src/views/viewportContent.tsx index 4db2a33..d13835b 100644 --- a/src/views/viewportContent.tsx +++ b/src/views/viewportContent.tsx @@ -117,6 +117,7 @@ export function Content({ toolHandlers, hudFields, grips, + gripHandleLines, edgeGrips, selectedDrawingId, selectedDrawingIds, @@ -218,6 +219,7 @@ export function Content({ toolHandlers: ToolHandlers; hudFields: HudFieldsState | null; grips: Vec2[]; + gripHandleLines: { a: Vec2; b: Vec2 }[]; edgeGrips: EdgeGrip[]; selectedDrawingId: string | null; selectedDrawingIds?: string[]; @@ -328,6 +330,7 @@ export function Content({ toolHandlers={toolHandlers} hudFields={hudFields} grips={grips} + gripHandleLines={gripHandleLines} edgeGrips={edgeGrips} selectedDrawingId={selectedDrawingId} selectedDrawingIds={selectedDrawingIds} @@ -393,6 +396,7 @@ export function Content({ toolHandlers={toolHandlers} hudFields={hudFields} grips={grips} + gripHandleLines={gripHandleLines} edgeGrips={edgeGrips} selectedDrawingId={selectedDrawingId} selectedDrawingIds={selectedDrawingIds} @@ -500,6 +504,7 @@ export function Content({ toolHandlers={toolHandlers} hudFields={hudFields} grips={grips} + gripHandleLines={gripHandleLines} edgeGrips={edgeGrips} selectedDrawingId={selectedDrawingId} selectedDrawingIds={selectedDrawingIds} @@ -555,6 +560,7 @@ function LevelPlanView({ toolHandlers, hudFields, grips, + gripHandleLines, edgeGrips, selectedDrawingId, selectedDrawingIds, @@ -607,6 +613,7 @@ function LevelPlanView({ toolHandlers: ToolHandlers; hudFields: HudFieldsState | null; grips: Vec2[]; + gripHandleLines: { a: Vec2; b: Vec2 }[]; edgeGrips: EdgeGrip[]; selectedDrawingId: string | null; selectedDrawingIds?: string[]; @@ -688,6 +695,7 @@ function LevelPlanView({ toolHandlers={toolHandlers} hudFields={hudFields} grips={grips} + gripHandleLines={gripHandleLines} edgeGrips={edgeGrips} hairline={hairline} marqueeColor={marqueeColor} @@ -752,6 +760,7 @@ function SectionPlanView({ toolHandlers, hudFields, grips, + gripHandleLines, edgeGrips, selectedDrawingId, selectedDrawingIds, @@ -795,6 +804,7 @@ function SectionPlanView({ toolHandlers: ToolHandlers; hudFields: HudFieldsState | null; grips: Vec2[]; + gripHandleLines: { a: Vec2; b: Vec2 }[]; edgeGrips: EdgeGrip[]; selectedDrawingId: string | null; selectedDrawingIds?: string[]; @@ -906,6 +916,7 @@ function SectionPlanView({ toolHandlers={toolHandlers} hudFields={hudFields} grips={grips} + gripHandleLines={gripHandleLines} edgeGrips={edgeGrips} hairline={hairline} marqueeColor={marqueeColor}