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
+3
View File
@@ -219,6 +219,9 @@ const TOOL_COMMAND: Partial<Record<ToolId, string>> = {
rect: "rect",
circle: "circle",
arc: "arc",
ellipse: "ellipse",
spline: "spline",
bezier: "bezier",
text: "text",
textbox: "textbox",
};
+96
View File
@@ -0,0 +1,96 @@
/**
* `bezierCommand` — manuelles Kontrollpunkt-Werkzeug: Anker → Griff 1 →
* Griff 2 → Endpunkt je Segment, „Fertig" committet als `shape:"bezier"`.
*/
import { describe, it, expect } from "vitest";
import { bezierCommand } from "./bezier";
import type { CommandContext, Project } from "../types";
function project(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
function makeCtx(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("bezierCommand", () => {
it("ein Segment (4 Klicks) + Fertig → shape:bezier mit 4 Kontrollpunkten", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = bezierCommand.onInput(bezierCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 1, y: 3 } }, ctx);
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 3, y: 3 } }, ctx);
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
const [, result] = bezierCommand.onConfirm(state, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("bezier");
if (g.shape === "bezier") {
expect(g.pts).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 3 },
{ x: 3, y: 3 },
{ x: 4, y: 0 },
]);
}
});
it("zwei Segmente hängen am gemeinsamen Anker (7 Punkte)", () => {
const p = project();
const ctx = makeCtx(p);
const clicks = [
{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 1 }, { x: 3, y: 0 },
{ x: 4, y: -1 }, { x: 5, y: -1 }, { x: 6, y: 0 },
];
let [state] = bezierCommand.onInput(bezierCommand.init(), { kind: "point", point: clicks[0] }, ctx);
for (let i = 1; i < clicks.length; i++) {
[state] = bezierCommand.onInput(state, { kind: "point", point: clicks[i] }, ctx);
}
const [, result] = bezierCommand.onInput(state, { kind: "option", id: "finish" }, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("bezier");
if (g.shape === "bezier") expect(g.pts).toEqual(clicks);
});
it("weniger als ein volles Segment: Enter bricht ohne commit ab", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = bezierCommand.onInput(bezierCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 1, y: 1 } }, ctx);
const [, result] = bezierCommand.onConfirm(state, ctx);
expect(result.commit).toBeUndefined();
});
});
+157
View File
@@ -0,0 +1,157 @@
// Bezier — klassisches Kontrollpunkt-Werkzeug (manuell gesetzte Griffe, kein
// Auto-Glätten wie `spline`). Jedes Segment braucht 3 Klicks nach dem Anker:
// 1) „Startpunkt:" → Punkt (erster Anker)
// 2) „Griff 1:" → Punkt (Kontrollpunkt 1 dieses Segments)
// 3) „Griff 2:" → Punkt (Kontrollpunkt 2 dieses Segments)
// 4) „Endpunkt:" → Punkt (Segment-Ende, = Anker des nächsten Segments)
// Nach ≥1 fertigem Segment kann „Fertig" (Enter) den Zug abschliessen; jeder
// weitere Anker startet automatisch ein neues Segment (Schritte 2–4).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { sampleBezierChain } from "../../tools/curves";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
interface BezierStart extends CommandState {
phase: "start";
}
interface BezierC1 extends CommandState {
phase: "c1";
pts: Vec2[]; // abgeschlossene Punkte (Anker·Griff·Griff·Anker·…)
anchor: Vec2; // letzter Anker (= pts[pts.length-1])
cursor: Vec2 | null;
}
interface BezierC2 extends CommandState {
phase: "c2";
pts: Vec2[];
anchor: Vec2;
c1: Vec2;
cursor: Vec2 | null;
}
interface BezierEnd extends CommandState {
phase: "end";
pts: Vec2[];
anchor: Vec2;
c1: Vec2;
c2: Vec2;
cursor: Vec2 | null;
}
type BezierState = BezierStart | BezierC1 | BezierC2 | BezierEnd;
const FINISH: CmdOption = { id: "finish", labelKey: "cmd.bezier.finish" };
/** Vorschau: abgeschlossene Kurve (tessellierte Segmente) + Gummiband-Linien des laufenden Segments. */
function bezierDraft(pts: Vec2[], rubber: Vec2[]): ToolDraft {
const preview: DraftShape[] = [];
if (pts.length >= 4) preview.push({ kind: "poly", pts: sampleBezierChain(pts), closed: false });
for (let i = 0; i < rubber.length - 1; i++) preview.push({ kind: "line", a: rubber[i], b: rubber[i + 1] });
return { preview, vertices: pts.length > 0 ? [pts[0]] : [] };
}
function appendBezier(p: Project, pts: Vec2[], ctx: CommandContext): Project {
if (pts.length < 4) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "bezier", pts },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as BezierStart,
{ draft: null, done: true },
];
const finish = (pts: Vec2[], ctx: CommandContext): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as BezierStart,
{ draft: null, done: true, commit: (p) => appendBezier(p, pts, ctx) },
];
export const bezierCommand: Command = {
name: "bezier",
labelKey: "cmd.bezier.label",
prompt: (s) => {
const phase = (s as BezierState).phase;
return phase === "c1"
? "cmd.bezier.c1"
: phase === "c2"
? "cmd.bezier.c2"
: phase === "end"
? "cmd.bezier.end"
: "cmd.bezier.start";
},
accepts: () => ["point", "option"],
options: (s) => ((s as BezierState).phase === "c1" && (s as BezierC1).pts.length >= 4 ? [FINISH] : []),
init: (): BezierStart => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as BezierState;
if (input.kind === "option") {
if (input.id === "finish" && s.phase === "c1" && s.pts.length >= 4) return finish(s.pts, ctx);
return [s, { draft: null }];
}
if (input.kind !== "point") return [s, { draft: null }];
const pt = input.point;
if (s.phase === "start") {
const ns: BezierC1 = { phase: "c1", pts: [pt], anchor: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: bezierDraft(ns.pts, [pt]) }];
}
if (s.phase === "c1") {
const ns: BezierC2 = { phase: "c2", pts: s.pts, anchor: s.anchor, c1: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: bezierDraft(ns.pts, [s.anchor, pt]) }];
}
if (s.phase === "c2") {
const ns: BezierEnd = {
phase: "end",
pts: s.pts,
anchor: s.anchor,
c1: s.c1,
c2: pt,
cursor: pt,
lastPoint: pt,
};
return [ns, { draft: bezierDraft(ns.pts, [s.anchor, s.c1, pt]) }];
}
// phase "end": Segment abschliessen, direkt das nächste Segment beginnen.
const pts = [...s.pts, s.c1, s.c2, pt];
const ns: BezierC1 = { phase: "c1", pts, anchor: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: bezierDraft(pts, [pt]) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as BezierState;
if (s.phase === "start") return [s, { draft: null }];
if (s.phase === "c1") {
const ns: BezierC1 = { ...s, cursor: point };
return [ns, { draft: bezierDraft(s.pts, [s.anchor, point]) }];
}
if (s.phase === "c2") {
const ns: BezierC2 = { ...s, cursor: point };
return [ns, { draft: bezierDraft(s.pts, [s.anchor, s.c1, point]) }];
}
const ns: BezierEnd = { ...s, cursor: point };
// Endschritt: die tatsächliche Bezier-Kurve bis zum Cursor live anzeigen.
return [ns, { draft: bezierDraft([...s.pts, s.c1, s.c2, point], []) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as BezierState;
if (s.phase === "c1" && s.pts.length >= 4) return finish(s.pts, ctx);
return idle();
},
onCancel: (): [CommandState, CommandResult] => idle(),
};
+88
View File
@@ -0,0 +1,88 @@
/**
* `ellipseCommand` — zwei Bounding-Box-Ecken → achsparallele Ellipse
* (shape:"ellipse", rotation 0).
*/
import { describe, it, expect } from "vitest";
import { ellipseCommand } from "./ellipse";
import type { CommandContext, Project } from "../types";
function project(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
function makeCtx(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("ellipseCommand", () => {
it("zwei Ecken → Zentrum + Halbachsen aus der Bounding-Box, rotation 0", () => {
const p = project();
const ctx = makeCtx(p);
const [afterCorner1] = ellipseCommand.onInput(
ellipseCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
const [, result] = ellipseCommand.onInput(
afterCorner1,
{ kind: "point", point: { x: 8, y: 4 } },
ctx,
);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("ellipse");
if (g.shape === "ellipse") {
expect(g.center).toEqual({ x: 4, y: 2 });
expect(g.rx).toBeCloseTo(4, 6);
expect(g.ry).toBeCloseTo(2, 6);
expect(g.rotation).toBe(0);
}
});
it("entartete Bbox (Punkt=Punkt) committet nichts", () => {
const p = project();
const ctx = makeCtx(p);
const [afterCorner1] = ellipseCommand.onInput(
ellipseCommand.init(),
{ kind: "point", point: { x: 5, y: 5 } },
ctx,
);
const [, result] = ellipseCommand.onInput(
afterCorner1,
{ kind: "point", point: { x: 5, y: 5 } },
ctx,
);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(0);
});
});
+106
View File
@@ -0,0 +1,106 @@
// Ellipse — zwei gegenüberliegende Ecken einer Bounding-Box (achsparallel,
// wie das 2-Punkt-Rechteck). Schritte:
// 1) „Erste Ecke:" → Punkt
// 2) „Gegenüberliegende Ecke:" → Punkt → commit (shape:"ellipse", rotation 0)
//
// Bewusst nur die einfache Bounding-Box-Methode (kein Dreh-Winkel über das
// Werkzeug) — `rotation` bleibt 0, das Feld existiert im Modell für spätere
// Dreh-Bearbeitung/andere Quellen (s. `model/types.ts`).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
import { ellipsePts } from "../../tools/curves";
const EPS = 1e-6;
interface EllipseIdle extends CommandState {
phase: "corner1";
}
interface EllipseDrawing extends CommandState {
phase: "corner2";
p0: Vec2;
cursor: Vec2 | null;
}
type EllipseState = EllipseIdle | EllipseDrawing;
/** Zentrum + Halbachsen aus zwei gegenüberliegenden Bounding-Box-Ecken. */
function ellipseFromCorners(p0: Vec2, p1: Vec2): { center: Vec2; rx: number; ry: number } {
return {
center: { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 },
rx: Math.abs(p1.x - p0.x) / 2,
ry: Math.abs(p1.y - p0.y) / 2,
};
}
/** Vorschau (tessellierte Ellipse) + HUD (Breite×Höhe). */
function ellipseDraft(p0: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [p0] };
const { center, rx, ry } = ellipseFromCorners(p0, cursor);
if (rx < EPS || ry < EPS) return { preview: [], vertices: [p0] };
const preview: DraftShape[] = [{ kind: "poly", pts: ellipsePts(center, rx, ry, 0), closed: true }];
return {
preview,
vertices: [p0],
hud: { at: cursor, text: `${(rx * 2).toFixed(3)}×${(ry * 2).toFixed(3)}m` },
};
}
function appendEllipse(p: Project, center: Vec2, rx: number, ry: number, ctx: CommandContext): Project {
if (rx < EPS || ry < EPS) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "ellipse", center, rx, ry, rotation: 0 },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true },
];
export const ellipseCommand: Command = {
name: "ellipse",
labelKey: "cmd.ellipse.label",
prompt: (s) => ((s as EllipseState).phase === "corner2" ? "cmd.ellipse.corner2" : "cmd.ellipse.corner1"),
accepts: () => ["point"],
options: () => [],
init: (): EllipseIdle => ({ phase: "corner1", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as EllipseState;
if (input.kind !== "point") return [s, { draft: null }];
if (s.phase !== "corner2") {
const ns: EllipseDrawing = { phase: "corner2", p0: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: ellipseDraft(input.point, null) }];
}
const { center, rx, ry } = ellipseFromCorners(s.p0, input.point);
return [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendEllipse(p, center, rx, ry, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as EllipseState;
if (s.phase !== "corner2") return [s, { draft: null }];
const ns: EllipseDrawing = { ...s, cursor: point };
return [ns, { draft: ellipseDraft(s.p0, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+77
View File
@@ -0,0 +1,77 @@
/**
* `splineCommand` — Wegpunkte klicken (wie polyline), Enter/„Schliessen"
* committet als `shape:"spline"` mit den ROHEN Wegpunkten (Tessellierung
* passiert erst beim Rendern/Snapping, s. `tools/curves.ts`).
*/
import { describe, it, expect } from "vitest";
import { splineCommand } from "./spline";
import type { CommandContext, Project } from "../types";
function project(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
function makeCtx(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("splineCommand", () => {
it("drei Punkte + Enter → offene Spline mit den drei Wegpunkten", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = splineCommand.onInput(splineCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 2, y: 3 } }, ctx);
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 5, y: 1 } }, ctx);
const [, result] = splineCommand.onConfirm(state, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("spline");
if (g.shape === "spline") {
expect(g.closed).toBe(false);
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 5, y: 1 }]);
}
});
it("Schliessen-Option committet als Ring (closed: true)", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = splineCommand.onInput(splineCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 4, y: 4 } }, ctx);
const [, result] = splineCommand.onInput(state, { kind: "option", id: "close" }, ctx);
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("spline");
if (g.shape === "spline") expect(g.closed).toBe(true);
});
});
+160
View File
@@ -0,0 +1,160 @@
// Spline — glatte Kurve DURCH mehrere Wegpunkte (uniformer Catmull-Rom),
// Bedienung 1:1 wie `polyline` (s. dort), nur dass die Vorschau/das Ergebnis
// zwischen den Punkten rundet statt gerade Kanten zu ziehen. Schritte:
// 1) „Startpunkt:" → Punkt
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … (Enter beendet offen,
// „Schliessen" schließt; Klick auf den Startpunkt schließt ebenfalls)
import type { Drawing2D } from "../../model/types";
import { segmentHud, uniqueId } from "../../tools/types";
import { sampleCatmullRom } from "../../tools/curves";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Zug
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
interface SplineIdle extends CommandState {
phase: "start";
closed: boolean;
}
interface SplineDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
closed: boolean;
}
type SplineState = SplineIdle | SplineDrawing;
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
const closedOption = (closed: boolean): CmdOption => ({
id: "mode",
labelKey: "cmd.polyline.closedOpt",
value: closed ? "on" : "off",
});
/** Vorschau (geglätteter Zug + Gummiband zum Cursor) + HUD. */
function splineDraft(points: Vec2[], cursor: Vec2 | null, closed = false): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const showClosed = closed && pts.length >= 3;
const curve = sampleCatmullRom(pts, showClosed);
const preview: DraftShape[] = [{ kind: "poly", pts: curve, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
return draft;
}
function appendSpline(p: Project, pts: Vec2[], closed: boolean, ctx: CommandContext): Project {
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "spline", pts, closed },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (closed = false): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null, closed } as SplineIdle,
{ draft: null, done: true },
];
export const splineCommand: Command = {
name: "spline",
labelKey: "cmd.spline.label",
prompt: (s) => ((s as SplineState).phase === "next" ? "cmd.spline.next" : "cmd.spline.start"),
accepts: () => ["point", "option"],
options: (s) => {
const ps = s as SplineState;
const toggle = closedOption(ps.closed);
if (ps.phase !== "next") return [toggle];
return ps.points.length >= 2 ? [CLOSE, toggle, UNDO] : [toggle, UNDO];
},
init: (): SplineIdle => ({ phase: "start", lastPoint: null, closed: false }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as SplineState;
if (input.kind === "option") {
if (input.id === "mode") {
const ns = { ...s, closed: !s.closed } as SplineState;
return [ns, { draft: s.phase === "next" ? splineDraft(s.points, s.cursor, ns.closed) : null }];
}
if (s.phase !== "next") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0)
return [{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle, { draft: null }];
const ns: SplineDrawing = {
phase: "next",
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
closed: s.closed,
};
return [ns, { draft: splineDraft(pts, s.cursor, s.closed) }];
}
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle,
{ draft: null, done: true, commit: (p) => appendSpline(p, pts, true, ctx) },
];
}
return [s, { draft: splineDraft(s.points, s.cursor, s.closed) }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "next" ? splineDraft(s.points, s.cursor, s.closed) : null }];
}
const pt = input.point;
if (s.phase !== "next") {
const ns: SplineDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt, closed: s.closed };
return [ns, { draft: splineDraft([pt], pt, s.closed) }];
}
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle,
{ draft: null, done: true, commit: (p) => appendSpline(p, pts, true, ctx) },
];
}
const points = [...s.points, pt];
const ns: SplineDrawing = { phase: "next", points, cursor: pt, lastPoint: pt, closed: s.closed };
return [ns, { draft: splineDraft(points, pt, s.closed) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as SplineState;
if (s.phase !== "next") return [s, { draft: null }];
const ns: SplineDrawing = { ...s, cursor: point };
return [ns, { draft: splineDraft(s.points, point, s.closed) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as SplineState;
if (s.phase === "next" && s.points.length >= 2) {
const pts = s.points;
const close = s.closed && pts.length >= 3;
return [
{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle,
{ draft: null, done: true, commit: (p) => appendSpline(p, pts, close, ctx) },
];
}
return idle(s.closed);
},
onCancel: (state): [CommandState, CommandResult] => idle((state as SplineState).closed),
};
+9
View File
@@ -17,6 +17,9 @@ import { roomCommand } from "./cmds/room";
import { rectCommand } from "./cmds/rect";
import { circleCommand } from "./cmds/circle";
import { arcCommand } from "./cmds/arc";
import { ellipseCommand } from "./cmds/ellipse";
import { splineCommand } from "./cmds/spline";
import { bezierCommand } from "./cmds/bezier";
import { textCommand } from "./cmds/text";
import { textboxCommand } from "./cmds/textbox";
import { moveCommand } from "./cmds/move";
@@ -53,6 +56,9 @@ export const COMMANDS: Record<string, Command> = {
rect: rectCommand,
circle: circleCommand,
arc: arcCommand,
ellipse: ellipseCommand,
spline: splineCommand,
bezier: bezierCommand,
text: textCommand,
textbox: textboxCommand,
move: moveCommand,
@@ -104,6 +110,9 @@ export const ALIASES: Record<string, string> = {
c: "circle",
a: "arc",
bogen: "arc",
el: "ellipse",
sp: "spline",
bz: "bezier",
tx: "text",
txt: "text",
beschriftung: "text",
+18
View File
@@ -178,6 +178,12 @@ export const de = {
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
"tool.arc": "Bogen",
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
"tool.ellipse": "Ellipse",
"tool.ellipse.hint": "Ellipse: zwei gegenüberliegende Ecken der Bounding-Box",
"tool.spline": "Spline",
"tool.spline.hint": "Spline: Wegpunkte klicken, Enter beendet (glatte Kurve durch die Punkte)",
"tool.bezier": "Bezier",
"tool.bezier.hint": "Bezier: Anker, Griff 1, Griff 2, Endpunkt — je Segment; Enter beendet",
"tool.text": "Text",
"tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen",
"tool.textbox": "Textspalte",
@@ -1142,6 +1148,18 @@ export const de = {
"cmd.arc.center": "Mittelpunkt:",
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
"cmd.arc.end": "Endpunkt (Endwinkel):",
"cmd.ellipse.label": "Ellipse",
"cmd.ellipse.corner1": "Erste Ecke:",
"cmd.ellipse.corner2": "Gegenüberliegende Ecke:",
"cmd.spline.label": "Spline",
"cmd.spline.start": "Startpunkt:",
"cmd.spline.next": "Nächster Punkt ( Schliessen Zurück ):",
"cmd.bezier.label": "Bezier",
"cmd.bezier.start": "Startpunkt:",
"cmd.bezier.c1": "Griff 1:",
"cmd.bezier.c2": "Griff 2:",
"cmd.bezier.end": "Endpunkt ( Fertig ):",
"cmd.bezier.finish": "Fertig",
"cmd.text.label": "Text",
"cmd.text.point": "Ankerpunkt:",
"cmd.textbox.label": "Textspalte",
+18
View File
@@ -176,6 +176,12 @@ export const en: Record<TranslationKey, string> = {
"tool.circle.hint": "Circle: set center, then radius",
"tool.arc": "Arc",
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
"tool.ellipse": "Ellipse",
"tool.ellipse.hint": "Ellipse: two opposite corners of the bounding box",
"tool.spline": "Spline",
"tool.spline.hint": "Spline: click waypoints, Enter finishes (smooth curve through the points)",
"tool.bezier": "Bezier",
"tool.bezier.hint": "Bezier: anchor, handle 1, handle 2, end point — per segment; Enter finishes",
"tool.text": "Text",
"tool.text.hint": "Text: set anchor point, then type the text",
"tool.textbox": "Text column",
@@ -1131,6 +1137,18 @@ export const en: Record<TranslationKey, string> = {
"cmd.arc.center": "Center:",
"cmd.arc.start": "Start point (radius + start angle):",
"cmd.arc.end": "End point (end angle):",
"cmd.ellipse.label": "Ellipse",
"cmd.ellipse.corner1": "First corner:",
"cmd.ellipse.corner2": "Opposite corner:",
"cmd.spline.label": "Spline",
"cmd.spline.start": "Start point:",
"cmd.spline.next": "Next point ( Close Undo ):",
"cmd.bezier.label": "Bezier",
"cmd.bezier.start": "Start point:",
"cmd.bezier.c1": "Handle 1:",
"cmd.bezier.c2": "Handle 2:",
"cmd.bezier.end": "End point ( Finish ):",
"cmd.bezier.finish": "Finish",
"cmd.text.label": "Text",
"cmd.text.point": "Anchor point:",
"cmd.textbox.label": "Text column",
+6
View File
@@ -106,6 +106,12 @@ export function translateProject(project: Project, dx: number, dy: number): Proj
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
case "arc":
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
case "ellipse":
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
case "bezier":
return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } };
case "spline":
return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } };
case "text":
return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } };
case "image":
+22
View File
@@ -1502,6 +1502,28 @@ export type Drawing2DGeom =
| { shape: "rect"; min: Vec2; max: Vec2 }
| { shape: "circle"; center: Vec2; r: number }
| { shape: "arc"; center: Vec2; r: number; a0: number; a1: number }
/**
* Ellipse — achsparalleles Zentrum + zwei Halbachsen + Drehung (Radiant,
* CCW). Das Zeichen-Werkzeug erzeugt bewusst nur `rotation: 0`; das Feld
* existiert für zukünftige Dreh-Bearbeitung bzw. gedrehte Ellipsen aus
* anderen Quellen. Rendering/Snapping/Griffe tessellieren sie (wie ehemals
* `circle`), kein eigenes glattes SVG-Primitiv (s. `generatePlan/drawing2d.ts`).
*/
| { shape: "ellipse"; center: Vec2; rx: number; ry: number; rotation: number }
/**
* Kubische Bezier-Kette: `pts.length` ≡ 1 (mod 3), ≥ 4 — Punkte im Muster
* Anker·Griff·Griff·Anker·Griff·Griff·Anker·… (jedes weitere Segment fügt
* 3 Punkte an). Manuell gesetzte Kontrollpunkte (klassisches Bezier-
* Werkzeug), im Unterschied zu `spline` (Kurve DURCH Punkte, automatisch
* geglättet). Immer offen (kein `closed`-Feld).
*/
| { shape: "bezier"; pts: Vec2[] }
/**
* Glatte Kurve DURCH `pts` (uniformer Catmull-Rom), analog `polyline`, aber
* mit gerundetem statt geradem Verlauf zwischen den Wegpunkten. `closed`
* schliesst den Ring (letzter Wegpunkt → erster).
*/
| { shape: "spline"; pts: Vec2[]; closed: boolean }
| {
shape: "text";
at: Vec2;
+43 -1
View File
@@ -138,6 +138,36 @@ export function ToolIcon({ id }: { id: ToolId }) {
<circle cx="13.5" cy="11" r="0.9" fill="currentColor" stroke="none" />
</svg>
);
case "ellipse":
// Ellipse-Umriss (geneigt, unterscheidbar vom Kreis).
return (
<svg {...common}>
<ellipse cx="8" cy="8" rx="6" ry="4" />
</svg>
);
case "spline":
// Glatte S-Kurve durch drei Wegpunkte.
return (
<svg {...common}>
<path d="M 2.5 12.5 C 2.5 6 6 10 8 8 C 10 6 10.5 3.5 13.5 3.5" fill="none" />
<circle cx="2.5" cy="12.5" r="0.9" fill="currentColor" stroke="none" />
<circle cx="8" cy="8" r="0.9" fill="currentColor" stroke="none" />
<circle cx="13.5" cy="3.5" r="0.9" fill="currentColor" stroke="none" />
</svg>
);
case "bezier":
// Kubische Bezier-Kurve mit sichtbaren Kontrollpunkt-Griffen.
return (
<svg {...common}>
<path d="M 2.5 12.5 C 5 4 8 13 13.5 3.5" fill="none" />
<line x1="2.5" y1="12.5" x2="5" y2="4" strokeDasharray="1.5 1.2" />
<line x1="13.5" y1="3.5" x2="8" y2="13" strokeDasharray="1.5 1.2" />
<circle cx="2.5" cy="12.5" r="0.9" fill="currentColor" stroke="none" />
<circle cx="13.5" cy="3.5" r="0.9" fill="currentColor" stroke="none" />
<rect x="4.2" y="2.7" width="1.6" height="1.6" fill="currentColor" stroke="none" />
<rect x="7.2" y="12.2" width="1.6" height="1.6" fill="currentColor" stroke="none" />
</svg>
);
case "text":
// Serifen-„A" als Text-Symbol.
return (
@@ -174,7 +204,19 @@ export function ToolIcon({ id }: { id: ToolId }) {
// Werkzeuge/Befehle in drei Rubriken sortiert (Nutzerwunsch: „sortiere nach 2D
// und 3D und Bearbeiten"): reine Panel-Gruppierung (2D-Zeichnen · 3D/BIM-
// Bauteile · Bearbeiten-Aktionen).
const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "text", "textbox", "image"];
const TOOLS_2D: ToolId[] = [
"line",
"polyline",
"rect",
"circle",
"arc",
"ellipse",
"spline",
"bezier",
"text",
"textbox",
"image",
];
const TOOLS_3D: ToolId[] = ["wall", "ceiling", "window", "door", "stair", "room"];
const COMMANDS_3D = ["column", "ceilingopening", "roof", "extrude", "sectionline", "viewline"];
const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "multiline", "trim", "extend", "fillet", "chamfer", "join", "measure"];
+5
View File
@@ -724,6 +724,11 @@ function computeBounds(
else if (g.shape === "circle" || g.shape === "arc") {
acc({ x: g.center.x - g.r, y: g.center.y - g.r });
acc({ x: g.center.x + g.r, y: g.center.y + g.r });
} else if (g.shape === "ellipse") {
acc({ x: g.center.x - g.rx, y: g.center.y - g.ry });
acc({ x: g.center.x + g.rx, y: g.center.y + g.ry });
} else if (g.shape === "bezier" || g.shape === "spline") {
g.pts.forEach(acc);
} else if (g.shape === "text") acc(g.at);
else if (g.shape === "image") { acc(g.min); acc(g.max); }
}
+123
View File
@@ -0,0 +1,123 @@
/**
* `addDrawing2D` — Rendering der neuen Freiform-Geometrien (ellipse/bezier/
* spline): tessellierte Linien-/Polygon-Primitive wie bei polyline/rect,
* kein eigenes SVG-Primitiv (s. `model/types.ts` Drawing2DGeom-Doku).
*/
import { describe, it, expect } from "vitest";
import { addDrawing2D } from "./drawing2d";
import type { Drawing2D, Project } from "../../model/types";
import type { Primitive } from "./shared";
function project(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
const lwByCode = new Map<string, number>();
const colorByCode = new Map<string, string>();
const catByCode = new Map<string, Project["layers"][number]>();
describe("addDrawing2D — ellipse", () => {
it("gibt eine gefüllte Fläche + geschlossenen Linienzug (64 Segmente) aus", () => {
const d: Drawing2D = {
id: "e1",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "ellipse", center: { x: 0, y: 0 }, rx: 4, ry: 2, rotation: 0 },
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
const polys = out.filter((p) => p.kind === "polygon");
const lines = out.filter((p) => p.kind === "line");
expect(polys.length).toBe(1);
expect(lines.length).toBe(64);
for (const l of lines) expect(l.drawingId).toBe("e1");
// Alle Punkte liegen auf der (unrotierten) Ellipse.
for (const l of lines) {
const dx = l.a.x / 4, dy = l.a.y / 2;
expect(dx * dx + dy * dy).toBeCloseTo(1, 3);
}
});
});
describe("addDrawing2D — bezier", () => {
it("ein Segment: tessellierte offene Linienkette, kein Füll-Polygon", () => {
const d: Drawing2D = {
id: "b1",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: {
shape: "bezier",
pts: [{ x: 0, y: 0 }, { x: 1, y: 3 }, { x: 3, y: 3 }, { x: 4, y: 0 }],
},
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
expect(out.every((p) => p.kind === "line")).toBe(true);
expect(out.length).toBe(24); // segsPerCurve Default
const lines = out.filter((p): p is Extract<Primitive, { kind: "line" }> => p.kind === "line");
expect(lines[0].a).toEqual({ x: 0, y: 0 });
const last = lines[lines.length - 1];
expect(last.b.x).toBeCloseTo(4, 6);
expect(last.b.y).toBeCloseTo(0, 6);
});
});
describe("addDrawing2D — spline", () => {
it("offen: tessellierte Linienkette durch die Wegpunkte, kein Füll-Polygon", () => {
const d: Drawing2D = {
id: "s1",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "spline", pts: [{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 5, y: 1 }], closed: false },
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
expect(out.some((p) => p.kind === "polygon")).toBe(false);
const lines = out.filter((p): p is Extract<Primitive, { kind: "line" }> => p.kind === "line");
expect(lines[0].a).toEqual({ x: 0, y: 0 });
const last = lines[lines.length - 1];
expect(last.b.x).toBeCloseTo(5, 6);
expect(last.b.y).toBeCloseTo(1, 6);
});
it("geschlossen: Füll-Polygon + Linienring schliesst zum ersten Punkt", () => {
const d: Drawing2D = {
id: "s2",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: {
shape: "spline",
pts: [{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 4 }, { x: 0, y: 4 }],
closed: true,
},
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
expect(out.filter((p) => p.kind === "polygon").length).toBe(1);
const lines = out.filter((p): p is Extract<Primitive, { kind: "line" }> => p.kind === "line");
const last = lines[lines.length - 1];
expect(last.b).toEqual(lines[0].a);
});
});
+22
View File
@@ -13,6 +13,7 @@ import {
resolveStrokeWeight,
type Primitive,
} from "./shared";
import { ellipsePts, sampleBezierChain, sampleCatmullRom } from "../../tools/curves";
export function addDrawing2D(
out: Primitive[],
@@ -135,6 +136,27 @@ export function addDrawing2D(
drawingId: d.id,
});
break;
case "ellipse": {
// Tessellierte Kontur (wie polyline/rect) statt eigenem SVG-Primitiv —
// konsistent mit dem übrigen 2D-Zeichnen-Werkzeugsatz, keine eigene
// Grip-/Snap-/Export-Sonderbehandlung nötig.
const pts = ellipsePts(g.center, g.rx, g.ry, g.rotation);
fillPoly(pts);
for (let i = 0; i < pts.length; i++) seg(pts[i], pts[(i + 1) % pts.length]);
break;
}
case "bezier": {
const pts = sampleBezierChain(g.pts);
for (let i = 0; i < pts.length - 1; i++) seg(pts[i], pts[i + 1]);
break;
}
case "spline": {
const pts = sampleCatmullRom(g.pts, g.closed && g.pts.length > 2);
if (g.closed && g.pts.length > 2) fillPoly(pts);
for (let i = 0; i < pts.length - 1; i++) seg(pts[i], pts[i + 1]);
if (g.closed && g.pts.length > 2) seg(pts[pts.length - 1], pts[0]);
break;
}
case "text":
out.push({
kind: "drawingText",
+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;
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import { ellipsePts, sampleBezierChain, sampleCatmullRom } from "./curves";
describe("ellipsePts", () => {
it("liegt auf der Ellipse (rotation 0): Ost/Nord-Punkte bei rx/ry", () => {
const pts = ellipsePts({ x: 0, y: 0 }, 4, 2, 0, 4);
// t=0 → Ost (rx,0); t=π/2 → Nord (0,ry)
expect(pts[0].x).toBeCloseTo(4, 6);
expect(pts[0].y).toBeCloseTo(0, 6);
expect(pts[1].x).toBeCloseTo(0, 6);
expect(pts[1].y).toBeCloseTo(2, 6);
});
it("Drehung um 90° vertauscht die Achsen", () => {
const pts = ellipsePts({ x: 0, y: 0 }, 4, 2, Math.PI / 2, 4);
// Der ehemalige Ost-Punkt (rx,0) liegt jetzt bei (0, rx).
expect(pts[0].x).toBeCloseTo(0, 6);
expect(pts[0].y).toBeCloseTo(4, 6);
});
it("Mittelpunkt-Verschiebung wird respektiert", () => {
const pts = ellipsePts({ x: 10, y: 5 }, 3, 3, 0, 8);
for (const p of pts) {
expect(Math.hypot(p.x - 10, p.y - 5)).toBeCloseTo(3, 6);
}
});
});
describe("sampleBezierChain", () => {
it("weniger als 4 Punkte: Rohpunkte unverändert (Gummiband-Vorschau)", () => {
const pts = [{ x: 0, y: 0 }, { x: 1, y: 1 }];
expect(sampleBezierChain(pts)).toEqual(pts);
});
it("ein Segment: startet bei P0, endet bei P3", () => {
const p0 = { x: 0, y: 0 }, p1 = { x: 1, y: 3 }, p2 = { x: 3, y: 3 }, p3 = { x: 4, y: 0 };
const out = sampleBezierChain([p0, p1, p2, p3], 10);
expect(out[0]).toEqual(p0);
const last = out[out.length - 1];
expect(last.x).toBeCloseTo(p3.x, 6);
expect(last.y).toBeCloseTo(p3.y, 6);
});
it("t=0.5 folgt der De-Casteljau-Formel (symmetrischer Fall)", () => {
const p0 = { x: 0, y: 0 }, p1 = { x: 0, y: 4 }, p2 = { x: 4, y: 4 }, p3 = { x: 4, y: 0 };
const out = sampleBezierChain([p0, p1, p2, p3], 2); // t = 0.5 genau bei Index 1
// Symmetrische Kontrollpunkte → Kurvenmitte liegt exakt bei (2, 3).
expect(out[1].x).toBeCloseTo(2, 6);
expect(out[1].y).toBeCloseTo(3, 6);
});
it("zwei Segmente: verbindet am gemeinsamen Anker P3=P4", () => {
const pts = [
{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 1 }, { x: 3, y: 0 },
{ x: 4, y: -1 }, { x: 5, y: -1 }, { x: 6, y: 0 },
];
const out = sampleBezierChain(pts, 5);
expect(out[0]).toEqual(pts[0]);
const last = out[out.length - 1];
expect(last.x).toBeCloseTo(6, 6);
expect(last.y).toBeCloseTo(0, 6);
});
});
describe("sampleCatmullRom", () => {
it("weniger als 3 Punkte: Rohpunkte unverändert", () => {
const pts = [{ x: 0, y: 0 }, { x: 1, y: 1 }];
expect(sampleCatmullRom(pts, false)).toEqual(pts);
});
it("offen: läuft durch den ersten und letzten Wegpunkt", () => {
const pts = [{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 5, y: 1 }, { x: 8, y: 4 }];
const out = sampleCatmullRom(pts, false, 8);
expect(out[0]).toEqual(pts[0]);
const last = out[out.length - 1];
expect(last.x).toBeCloseTo(8, 6);
expect(last.y).toBeCloseTo(4, 6);
});
it("Kollineare Punkte ergeben eine Gerade (kein Überschwingen)", () => {
const pts = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }, { x: 3, y: 0 }];
const out = sampleCatmullRom(pts, false, 4);
for (const p of out) expect(p.y).toBeCloseTo(0, 6);
});
it("geschlossen: liefert keinen doppelten Schlusspunkt (Aufrufer schliesst selbst)", () => {
const pts = [{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 4 }, { x: 0, y: 4 }];
const out = sampleCatmullRom(pts, true, 6);
expect(out.length).toBe(pts.length * 6);
});
});
+104
View File
@@ -0,0 +1,104 @@
// Geometrie-Helfer für Ellipse/Bezier/Spline — reine Sample-/Tessellierungs-
// Funktionen, gemeinsam genutzt von den Zeichen-Werkzeugen (Vorschau), dem
// Grundriss-Generator (finales Rendering als Linien-/Polygon-Primitive) und
// dem Snapping (Kandidaten-Strecken).
import type { Vec2 } from "../model/types";
/** Punkte einer (ggf. gedrehten) Ellipse, N gleichverteilte Parameterschritte. */
export function ellipsePts(
center: Vec2,
rx: number,
ry: number,
rotation: number,
n = 64,
): Vec2[] {
const pts: Vec2[] = [];
const cosR = Math.cos(rotation);
const sinR = Math.sin(rotation);
for (let i = 0; i < n; i++) {
const t = (i / n) * Math.PI * 2;
const lx = rx * Math.cos(t);
const ly = ry * Math.sin(t);
pts.push({ x: center.x + lx * cosR - ly * sinR, y: center.y + lx * sinR + ly * cosR });
}
return pts;
}
/** Punkt auf einer kubischen Bezier-Kurve (De-Casteljau-Formel) bei Parameter `t` ∈ [0,1]. */
function cubicPoint(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 {
const u = 1 - t;
const a = u * u * u;
const b = 3 * u * u * t;
const c = 3 * u * t * t;
const d = t * t * t;
return {
x: a * p0.x + b * p1.x + c * p2.x + d * p3.x,
y: a * p0.y + b * p1.y + c * p2.y + d * p3.y,
};
}
/**
* Tessellierte Punkte einer kubischen Bezier-Kette. `pts` folgt dem Muster
* Anker·Griff·Griff·Anker·… (`pts.length` ≡ 1 mod 3). Mit weniger als 4
* Punkten (noch im Bau) werden die Rohpunkte unverändert zurückgegeben
* (Gummiband-Vorschau).
*/
export function sampleBezierChain(pts: Vec2[], segsPerCurve = 24): Vec2[] {
if (pts.length < 4) return [...pts];
const out: Vec2[] = [pts[0]];
for (let i = 0; i + 3 < pts.length; i += 3) {
const p0 = pts[i], p1 = pts[i + 1], p2 = pts[i + 2], p3 = pts[i + 3];
for (let s = 1; s <= segsPerCurve; s++) out.push(cubicPoint(p0, p1, p2, p3, s / segsPerCurve));
}
return out;
}
/** Kontrollpunkt-Zugriff für Catmull-Rom mit Rand-Klemmung bzw. Ring-Umlauf. */
function crPoint(pts: Vec2[], i: number, closed: boolean): Vec2 {
const n = pts.length;
if (closed) return pts[((i % n) + n) % n];
if (i < 0) return pts[0];
if (i >= n) return pts[n - 1];
return pts[i];
}
/**
* Tessellierte Punkte einer glatten Kurve DURCH `pts` (uniformer Catmull-
* Rom-Spline). Mit weniger als 3 Punkten (noch im Bau bzw. entartet) werden
* die Rohpunkte unverändert zurückgegeben (Gummiband-Vorschau bzw. Gerade).
* `closed` schliesst den Ring OHNE expliziten Doppelpunkt am Ende — Aufrufer
* schliessen wie bei `polyline` selbst mit einem letzten Segment zurück zu
* `pts[0]`.
*/
export function sampleCatmullRom(pts: Vec2[], closed: boolean, segsPerSpan = 16): Vec2[] {
if (pts.length < 3) return [...pts];
const out: Vec2[] = [];
const spans = closed ? pts.length : pts.length - 1;
for (let i = 0; i < spans; i++) {
const p0 = crPoint(pts, i - 1, closed);
const p1 = crPoint(pts, i, closed);
const p2 = crPoint(pts, i + 1, closed);
const p3 = crPoint(pts, i + 2, closed);
for (let s = 0; s < segsPerSpan; s++) {
const t = s / segsPerSpan;
const t2 = t * t;
const t3 = t2 * t;
const x =
0.5 *
(2 * p1.x +
(-p0.x + p2.x) * t +
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
const y =
0.5 *
(2 * p1.y +
(-p0.y + p2.y) * t +
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
out.push({ x, y });
}
}
if (!closed) out.push(pts[pts.length - 1]);
return out;
}
+18
View File
@@ -12,6 +12,7 @@ import { add, leftNormal, lineIntersect, normalize, scale, sub } from "../model/
import { wallReferenceOffset } from "../model/wall";
import { centroid as roomCentroid } from "../geometry/roomArea";
import type { SnapKind, SnapResult, SnapSettings } from "./types";
import { sampleBezierChain, sampleCatmullRom } from "./curves";
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y);
const mid = (a: Vec2, b: Vec2): Vec2 => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
@@ -101,6 +102,14 @@ function collectSegments(input: SnapInput): Seg[] {
const c1 = g.min, c3 = g.max;
const c2 = { x: g.max.x, y: g.min.y }, c4 = { x: g.min.x, y: g.max.y };
segs.push([c1, c2], [c2, c3], [c3, c4], [c4, c1]);
} else if (g.shape === "bezier") {
const pts = sampleBezierChain(g.pts);
for (let i = 0; i < pts.length - 1; i++) segs.push([pts[i], pts[i + 1]]);
} else if (g.shape === "spline") {
const closed = g.closed && g.pts.length > 2;
const pts = sampleCatmullRom(g.pts, closed);
for (let i = 0; i < pts.length - 1; i++) segs.push([pts[i], pts[i + 1]]);
if (closed) segs.push([pts[pts.length - 1], pts[0]]);
}
}
// Bereits gesetzte Draft-Segmente (Polylinie/Wand im Bau).
@@ -298,6 +307,15 @@ export function computeSnap(input: SnapInput): SnapResult | null {
consider(onCircle(c, c.arc.a1), "endpoint");
}
}
// Ellipse: nur der Mittelpunkt (keine Quadranten — bei gedrehter Ellipse
// wären die "Achsenpunkte" ohnehin keine Kreis-Quadranten mehr).
if (settings.center) {
for (const d of input.project.drawings2d) {
if (d.levelId !== input.levelId) continue;
if (!input.visibleCodes.has(d.categoryCode)) continue;
if (d.geom.shape === "ellipse") consider(d.geom.center, "center");
}
}
}
if (best) return best;
+42
View File
@@ -554,6 +554,45 @@ const arcTool: Tool = {
onCancel: (s) => [s, { draft: null, done: true }],
};
// Ellipse: Platzhalter-Werkzeug, gekoppelt an den `ellipse`-Befehl (zwei
// Bounding-Box-Ecken). Wie Kreis/Bogen nicht floorOnly.
const ellipseTool: Tool = {
id: "ellipse",
labelKey: "tool.ellipse",
hintKey: () => "tool.ellipse.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// Spline: Platzhalter-Werkzeug, gekoppelt an den `spline`-Befehl (Wegpunkte,
// wie Polylinie, aber geglättet). Nicht floorOnly.
const splineTool: Tool = {
id: "spline",
labelKey: "tool.spline",
hintKey: () => "tool.spline.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// Bezier: Platzhalter-Werkzeug, gekoppelt an den `bezier`-Befehl (manuelle
// Kontrollpunkt-Kette). Nicht floorOnly.
const bezierTool: Tool = {
id: "bezier",
labelKey: "tool.bezier",
hintKey: () => "tool.bezier.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// Text: Platzhalter-Werkzeug, gekoppelt an den `text`-Befehl (Ankerpunkt →
// getippte Zeile). Wie Kreis/Bogen nicht floorOnly (freies 2D-Element).
const textTool: Tool = {
@@ -611,6 +650,9 @@ const TOOLS: Record<ToolId, Tool> = {
rect: rectTool,
circle: circleTool,
arc: arcTool,
ellipse: ellipseTool,
spline: splineTool,
bezier: bezierTool,
text: textTool,
textbox: textboxTool,
image: imageTool,
+6
View File
@@ -179,6 +179,12 @@ const mvGeom = (geom: Drawing2DGeom, fn: (p: Vec2) => Vec2): Drawing2DGeom => {
return { ...geom, center: fn(geom.center) };
case "arc":
return { ...geom, center: fn(geom.center) };
case "ellipse":
return { ...geom, center: fn(geom.center) };
case "bezier":
return { ...geom, pts: geom.pts.map(fn) };
case "spline":
return { ...geom, pts: geom.pts.map(fn) };
case "text":
return { ...geom, at: fn(geom.at) };
case "image": {
+3
View File
@@ -21,6 +21,9 @@ export type ToolId =
| "rect"
| "circle"
| "arc"
| "ellipse"
| "spline"
| "bezier"
| "text"
| "textbox"
| "image";
+2 -1
View File
@@ -24,6 +24,7 @@ export function drawingGripVertices(d: Drawing2D): Vec2[] {
{ x: g.min.x, y: g.max.y },
];
}
if (g.shape === "bezier" || g.shape === "spline") return [...g.pts];
return [];
}
@@ -44,7 +45,7 @@ export function drawingMoveAnchor(d: Drawing2D, verts: Vec2[]): Vec2 | null {
return { x: sx / verts.length, y: sy / verts.length };
}
const g = d.geom;
if (g.shape === "circle" || g.shape === "arc") return g.center;
if (g.shape === "circle" || g.shape === "arc" || g.shape === "ellipse") return g.center;
if (g.shape === "text") return g.at;
return null;
}