From 121cb5a372a86cb4f2314b3f153e625ae0292470 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 22 Aug 2026 01:45:32 +0200 Subject: [PATCH] 2D: Ellipse, Spline und Bezier als neue Zeichenwerkzeuge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/App.tsx | 3 + src/commands/cmds/bezier.test.ts | 96 ++++++++++++++ src/commands/cmds/bezier.ts | 157 +++++++++++++++++++++++ src/commands/cmds/ellipse.test.ts | 88 +++++++++++++ src/commands/cmds/ellipse.ts | 106 ++++++++++++++++ src/commands/cmds/spline.test.ts | 77 ++++++++++++ src/commands/cmds/spline.ts | 160 ++++++++++++++++++++++++ src/commands/registry.ts | 9 ++ src/i18n/de.ts | 18 +++ src/i18n/en.ts | 18 +++ src/model/geoRebase.ts | 6 + src/model/types.ts | 22 ++++ src/panels/ToolsPanel.tsx | 44 ++++++- src/plan/generatePlan.ts | 5 + src/plan/generatePlan/drawing2d.test.ts | 123 ++++++++++++++++++ src/plan/generatePlan/drawing2d.ts | 22 ++++ src/state/projectSlice.ts | 52 +++++++- src/state/selectionInfo.ts | 18 ++- src/state/useGripEditing.ts | 7 +- src/state/useProjectFileActions.ts | 8 ++ src/tools/curves.test.ts | 91 ++++++++++++++ src/tools/curves.ts | 104 +++++++++++++++ src/tools/snapping.ts | 18 +++ src/tools/tools.ts | 42 +++++++ src/tools/transform.ts | 6 + src/tools/types.ts | 3 + src/viewport/drawingGrips.ts | 3 +- 27 files changed, 1299 insertions(+), 7 deletions(-) create mode 100644 src/commands/cmds/bezier.test.ts create mode 100644 src/commands/cmds/bezier.ts create mode 100644 src/commands/cmds/ellipse.test.ts create mode 100644 src/commands/cmds/ellipse.ts create mode 100644 src/commands/cmds/spline.test.ts create mode 100644 src/commands/cmds/spline.ts create mode 100644 src/plan/generatePlan/drawing2d.test.ts create mode 100644 src/tools/curves.test.ts create mode 100644 src/tools/curves.ts diff --git a/src/App.tsx b/src/App.tsx index 72a5d8c..88fc7a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -219,6 +219,9 @@ const TOOL_COMMAND: Partial> = { rect: "rect", circle: "circle", arc: "arc", + ellipse: "ellipse", + spline: "spline", + bezier: "bezier", text: "text", textbox: "textbox", }; diff --git a/src/commands/cmds/bezier.test.ts b/src/commands/cmds/bezier.test.ts new file mode 100644 index 0000000..723f3bc --- /dev/null +++ b/src/commands/cmds/bezier.test.ts @@ -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(); + }); +}); diff --git a/src/commands/cmds/bezier.ts b/src/commands/cmds/bezier.ts new file mode 100644 index 0000000..41645c0 --- /dev/null +++ b/src/commands/cmds/bezier.ts @@ -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(), +}; diff --git a/src/commands/cmds/ellipse.test.ts b/src/commands/cmds/ellipse.test.ts new file mode 100644 index 0000000..712d39e --- /dev/null +++ b/src/commands/cmds/ellipse.test.ts @@ -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); + }); +}); diff --git a/src/commands/cmds/ellipse.ts b/src/commands/cmds/ellipse.ts new file mode 100644 index 0000000..119b284 --- /dev/null +++ b/src/commands/cmds/ellipse.ts @@ -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(), +}; diff --git a/src/commands/cmds/spline.test.ts b/src/commands/cmds/spline.test.ts new file mode 100644 index 0000000..85a4881 --- /dev/null +++ b/src/commands/cmds/spline.test.ts @@ -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); + }); +}); diff --git a/src/commands/cmds/spline.ts b/src/commands/cmds/spline.ts new file mode 100644 index 0000000..8940856 --- /dev/null +++ b/src/commands/cmds/spline.ts @@ -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), +}; diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 15b4200..cdb1769 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -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 = { 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 = { c: "circle", a: "arc", bogen: "arc", + el: "ellipse", + sp: "spline", + bz: "bezier", tx: "text", txt: "text", beschriftung: "text", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index aa0b7ea..4380f5a 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -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", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c1b9e31..2d01f0d 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -176,6 +176,12 @@ export const en: Record = { "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 = { "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", diff --git a/src/model/geoRebase.ts b/src/model/geoRebase.ts index d1fbb4d..b37f251 100644 --- a/src/model/geoRebase.ts +++ b/src/model/geoRebase.ts @@ -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": diff --git a/src/model/types.ts b/src/model/types.ts index 928c799..9a87cec 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -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; diff --git a/src/panels/ToolsPanel.tsx b/src/panels/ToolsPanel.tsx index 015d891..96b3da8 100644 --- a/src/panels/ToolsPanel.tsx +++ b/src/panels/ToolsPanel.tsx @@ -138,6 +138,36 @@ export function ToolIcon({ id }: { id: ToolId }) { ); + case "ellipse": + // Ellipse-Umriss (geneigt, unterscheidbar vom Kreis). + return ( + + + + ); + case "spline": + // Glatte S-Kurve durch drei Wegpunkte. + return ( + + + + + + + ); + case "bezier": + // Kubische Bezier-Kurve mit sichtbaren Kontrollpunkt-Griffen. + return ( + + + + + + + + + + ); 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"]; diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index a4e6c03..f3b56f5 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -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); } } diff --git a/src/plan/generatePlan/drawing2d.test.ts b/src/plan/generatePlan/drawing2d.test.ts new file mode 100644 index 0000000..ac71542 --- /dev/null +++ b/src/plan/generatePlan/drawing2d.test.ts @@ -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(); +const colorByCode = new Map(); +const catByCode = new Map(); + +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 => 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 => 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 => p.kind === "line"); + const last = lines[lines.length - 1]; + expect(last.b).toEqual(lines[0].a); + }); +}); diff --git a/src/plan/generatePlan/drawing2d.ts b/src/plan/generatePlan/drawing2d.ts index 0678e22..d379031 100644 --- a/src/plan/generatePlan/drawing2d.ts +++ b/src/plan/generatePlan/drawing2d.ts @@ -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", diff --git a/src/state/projectSlice.ts b/src/state/projectSlice.ts index d7613a6..ce97cfe 100644 --- a/src/state/projectSlice.ts +++ b/src/state/projectSlice.ts @@ -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 } }; } diff --git a/src/state/selectionInfo.ts b/src/state/selectionInfo.ts index 7ea1f10..472c1c7 100644 --- a/src/state/selectionInfo.ts +++ b/src/state/selectionInfo.ts @@ -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; diff --git a/src/state/useGripEditing.ts b/src/state/useGripEditing.ts index 20e0f35..dbbd1b2 100644 --- a/src/state/useGripEditing.ts +++ b/src/state/useGripEditing.ts @@ -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); diff --git a/src/state/useProjectFileActions.ts b/src/state/useProjectFileActions.ts index b931482..3b5a3c6 100644 --- a/src/state/useProjectFileActions.ts +++ b/src/state/useProjectFileActions.ts @@ -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; } diff --git a/src/tools/curves.test.ts b/src/tools/curves.test.ts new file mode 100644 index 0000000..c71281b --- /dev/null +++ b/src/tools/curves.test.ts @@ -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); + }); +}); diff --git a/src/tools/curves.ts b/src/tools/curves.ts new file mode 100644 index 0000000..4cd199f --- /dev/null +++ b/src/tools/curves.ts @@ -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; +} diff --git a/src/tools/snapping.ts b/src/tools/snapping.ts index aa850c8..7983fdb 100644 --- a/src/tools/snapping.ts +++ b/src/tools/snapping.ts @@ -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; diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 8ad3a9a..a4141a0 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -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 = { rect: rectTool, circle: circleTool, arc: arcTool, + ellipse: ellipseTool, + spline: splineTool, + bezier: bezierTool, text: textTool, textbox: textboxTool, image: imageTool, diff --git a/src/tools/transform.ts b/src/tools/transform.ts index 1524c24..17874ca 100644 --- a/src/tools/transform.ts +++ b/src/tools/transform.ts @@ -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": { diff --git a/src/tools/types.ts b/src/tools/types.ts index 8ec27de..3a69f9b 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -21,6 +21,9 @@ export type ToolId = | "rect" | "circle" | "arc" + | "ellipse" + | "spline" + | "bezier" | "text" | "textbox" | "image"; diff --git a/src/viewport/drawingGrips.ts b/src/viewport/drawingGrips.ts index e106443..449dcec 100644 --- a/src/viewport/drawingGrips.ts +++ b/src/viewport/drawingGrips.ts @@ -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; }