2D: Ellipse, Spline und Bezier als neue Zeichenwerkzeuge

Ellipse (zwei Bounding-Box-Ecken, achsparallel), Spline (Wegpunkte, geglättet
via Catmull-Rom, wie Polylinie mit rundem statt geradem Verlauf) und Bezier
(klassische manuelle Kontrollpunkt-Kette: Anker/Griff/Griff je Segment).

Neue Drawing2DGeom-Formen + Mathe-Helfer (tools/curves.ts). Rendering
tessellliert zu Linien-/Polygon-Primitiven (wie Polylinie/Rechteck) statt
eigenem SVG-Primitiv — dadurch funktionieren DXF-/Print-SVG-Export und
Hit-Testing ohne Änderung mit. Griff-Editieren (2D-PlanView + 3D-Viewport),
Verschieben/Skalieren, Snapping (Ellipse-Mittelpunkt, Bezier/Spline-Konturen)
und Georef-Rebase entsprechend erweitert.
This commit is contained in:
2026-08-22 01:45:32 +02:00
parent c8ac5a3d89
commit 121cb5a372
27 changed files with 1299 additions and 7 deletions
+50 -2
View File
@@ -1407,6 +1407,28 @@ function resizeGeom(
};
return { ...g, center, r };
}
case "ellipse": {
// bbox: rotation=0 angenommen (das Werkzeug erzeugt nur achsparallele
// Ellipsen); rx/ry unabhängig skaliert, Mittelpunkt um den Anker verschoben.
const box = {
minX: g.center.x - g.rx,
minY: g.center.y - g.ry,
maxX: g.center.x + g.rx,
maxY: g.center.y + g.ry,
};
const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
const center: Vec2 = {
x: ax + (g.center.x - ax) * sx,
y: ay + (g.center.y - ay) * sy,
};
return { ...g, center, rx: Math.max(1e-3, g.rx * sx), ry: Math.max(1e-3, g.ry * sy) };
}
case "bezier":
case "spline": {
const { ax, ay, sx, sy } = scaleAround(bbox2(g.pts), w, h, anchor);
const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy });
return { ...g, pts: g.pts.map(f) };
}
case "text": {
// Text: nur die Einfügeposition um den Anker schieben (Höhe bleibt).
const box = { minX: g.at.x, minY: g.at.y, maxX: g.at.x, maxY: g.at.y };
@@ -1461,6 +1483,17 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
{ x: g.center.x + g.r * Math.cos(mid), y: g.center.y + g.r * Math.sin(mid) },
];
}
// Ellipse: zwei Griffe (Index 0 = rx-Achse, Index 1 = ry-Achse), gedreht um
// `rotation` (0 beim heutigen Werkzeug) — siehe `moveGrip`.
if (g.shape === "ellipse") {
const c = Math.cos(g.rotation), s = Math.sin(g.rotation);
return [
{ x: g.center.x + g.rx * c, y: g.center.y + g.rx * s },
{ x: g.center.x - g.ry * s, y: g.center.y + g.ry * c },
];
}
// Bezier/Spline: alle Kontroll-/Wegpunkte als Griffe (wie polyline).
if (g.shape === "bezier" || g.shape === "spline") return [...g.pts];
// Text: EIN Griff am Ankerpunkt (zum Verschieben; siehe moveGrip).
if (g.shape === "text") return [g.at];
return [];
@@ -1518,7 +1551,7 @@ function moveGrip(
if (g.shape === "line") {
return { ...d, geom: index === 0 ? { ...g, a: pt } : { ...g, b: pt } };
}
if (g.shape === "polyline") {
if (g.shape === "polyline" || g.shape === "bezier" || g.shape === "spline") {
const pts = g.pts.map((p, i) => (i === index ? pt : p));
return { ...d, geom: { ...g, pts } };
}
@@ -1551,6 +1584,17 @@ function moveGrip(
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 } };
}
// Ellipse: Griff 0 (rx-Achse) bzw. 1 (ry-Achse) — der gezogene Punkt wird
// ins lokale (gedrehte) Achsensystem projiziert, nur die jeweils
// betroffene Halbachse ändert sich.
if (g.shape === "ellipse") {
const c = Math.cos(g.rotation), s = Math.sin(g.rotation);
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)) } };
}
return d;
}),
};
@@ -1590,7 +1634,11 @@ function moveElementBy(
return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
case "circle":
case "arc":
case "ellipse":
return { ...d, geom: { ...g, center: mv(g.center) } };
case "bezier":
case "spline":
return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "text":
return { ...d, geom: { ...g, at: mv(g.at) } };
default:
@@ -1723,7 +1771,7 @@ function moveEdge(
// Linie hat nur eine Kante [0,1] → das ganze Segment verschieben.
return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
}
if (g.shape === "polyline") {
if (g.shape === "polyline" || g.shape === "spline") {
const pts = movePolygonEdge(g.pts, aIndex, bIndex, delta, !!g.closed);
return { ...d, geom: { ...g, pts } };
}
+17 -1
View File
@@ -494,7 +494,9 @@ function isClosedGeom(g: Drawing2DGeom): boolean {
return (
g.shape === "rect" ||
g.shape === "circle" ||
(g.shape === "polyline" && g.closed)
g.shape === "ellipse" ||
(g.shape === "polyline" && g.closed) ||
(g.shape === "spline" && g.closed)
);
}
@@ -528,6 +530,20 @@ function geomBBox(g: Drawing2DGeom): SelectionBBox {
acc(g.center.x - g.r, g.center.y - g.r);
acc(g.center.x + g.r, g.center.y + g.r);
break;
case "ellipse": {
// Präzise Bounding-Box unter Berücksichtigung der Drehung (rotation=0
// beim heutigen Werkzeug, Feld existiert für spätere Quellen).
const c = Math.cos(g.rotation), s = Math.sin(g.rotation);
const hw = Math.hypot(g.rx * c, g.ry * s);
const hh = Math.hypot(g.rx * s, g.ry * c);
acc(g.center.x - hw, g.center.y - hh);
acc(g.center.x + hw, g.center.y + hh);
break;
}
case "bezier":
case "spline":
for (const p of g.pts) acc(p.x, p.y);
break;
case "text":
acc(g.at.x, g.at.y);
break;
+5 -2
View File
@@ -290,6 +290,8 @@ export function useGripEditing(opts: {
if (g.shape === "rect" || g.shape === "image") out.push(...ringEdges(verts, owner));
else if (g.shape === "polyline" && g.closed) out.push(...ringEdges(verts, owner));
else if (g.shape === "polyline" && !g.closed) out.push(...openEdges(verts, owner));
else if (g.shape === "spline" && g.closed) out.push(...ringEdges(verts, owner));
else if (g.shape === "spline" && !g.closed) out.push(...openEdges(verts, owner));
else if (g.shape === "line") out.push(...openEdges(verts, owner));
}
if (effectiveDrawingIds.length === 0) {
@@ -462,7 +464,7 @@ export function useGripEditing(opts: {
? project.drawings2d.find((d) => d.id === owner.drawingId)
: selectedDrawing;
const g = ownerDrawing?.geom;
if (g && (g.shape === "circle" || g.shape === "arc")) return g.center;
if (g && (g.shape === "circle" || g.shape === "arc" || g.shape === "ellipse")) return g.center;
const refIndex = neighborGripIndex(index);
return refIndex != null ? grips[refIndex] : null;
};
@@ -505,7 +507,8 @@ export function useGripEditing(opts: {
const d = project.drawings2d.find((x) => x.id === owner.drawingId);
const g = d?.geom;
if (!g) return none;
if (g.shape === "polyline") return ringNeighborPts(g.pts, owner.localIndex, !!g.closed);
if (g.shape === "polyline" || g.shape === "spline")
return ringNeighborPts(g.pts, owner.localIndex, !!g.closed);
if (g.shape === "rect" || g.shape === "image") {
const corners = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
return ringNeighborPts(corners, owner.localIndex, true);
+8
View File
@@ -49,6 +49,11 @@ function drawingsBBox(
else if (g.shape === "circle" || g.shape === "arc") {
acc(g.center.x - g.r, g.center.y - g.r);
acc(g.center.x + g.r, g.center.y + g.r);
} else if (g.shape === "ellipse") {
acc(g.center.x - g.rx, g.center.y - g.ry);
acc(g.center.x + g.rx, g.center.y + g.ry);
} else if (g.shape === "bezier" || g.shape === "spline") {
for (const p of g.pts) acc(p.x, p.y);
} else if (g.shape === "text") acc(g.at.x, g.at.y);
}
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
@@ -64,6 +69,9 @@ function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D {
case "rect": return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
case "circle": return { ...d, geom: { ...g, center: mv(g.center) } };
case "arc": return { ...d, geom: { ...g, center: mv(g.center) } };
case "ellipse": return { ...d, geom: { ...g, center: mv(g.center) } };
case "bezier": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "spline": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "text": return { ...d, geom: { ...g, at: mv(g.at) } };
default: return d;
}