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:
@@ -219,6 +219,9 @@ const TOOL_COMMAND: Partial<Record<ToolId, string>> = {
|
|||||||
rect: "rect",
|
rect: "rect",
|
||||||
circle: "circle",
|
circle: "circle",
|
||||||
arc: "arc",
|
arc: "arc",
|
||||||
|
ellipse: "ellipse",
|
||||||
|
spline: "spline",
|
||||||
|
bezier: "bezier",
|
||||||
text: "text",
|
text: "text",
|
||||||
textbox: "textbox",
|
textbox: "textbox",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -17,6 +17,9 @@ import { roomCommand } from "./cmds/room";
|
|||||||
import { rectCommand } from "./cmds/rect";
|
import { rectCommand } from "./cmds/rect";
|
||||||
import { circleCommand } from "./cmds/circle";
|
import { circleCommand } from "./cmds/circle";
|
||||||
import { arcCommand } from "./cmds/arc";
|
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 { textCommand } from "./cmds/text";
|
||||||
import { textboxCommand } from "./cmds/textbox";
|
import { textboxCommand } from "./cmds/textbox";
|
||||||
import { moveCommand } from "./cmds/move";
|
import { moveCommand } from "./cmds/move";
|
||||||
@@ -53,6 +56,9 @@ export const COMMANDS: Record<string, Command> = {
|
|||||||
rect: rectCommand,
|
rect: rectCommand,
|
||||||
circle: circleCommand,
|
circle: circleCommand,
|
||||||
arc: arcCommand,
|
arc: arcCommand,
|
||||||
|
ellipse: ellipseCommand,
|
||||||
|
spline: splineCommand,
|
||||||
|
bezier: bezierCommand,
|
||||||
text: textCommand,
|
text: textCommand,
|
||||||
textbox: textboxCommand,
|
textbox: textboxCommand,
|
||||||
move: moveCommand,
|
move: moveCommand,
|
||||||
@@ -104,6 +110,9 @@ export const ALIASES: Record<string, string> = {
|
|||||||
c: "circle",
|
c: "circle",
|
||||||
a: "arc",
|
a: "arc",
|
||||||
bogen: "arc",
|
bogen: "arc",
|
||||||
|
el: "ellipse",
|
||||||
|
sp: "spline",
|
||||||
|
bz: "bezier",
|
||||||
tx: "text",
|
tx: "text",
|
||||||
txt: "text",
|
txt: "text",
|
||||||
beschriftung: "text",
|
beschriftung: "text",
|
||||||
|
|||||||
@@ -178,6 +178,12 @@ export const de = {
|
|||||||
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
|
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
|
||||||
"tool.arc": "Bogen",
|
"tool.arc": "Bogen",
|
||||||
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
|
"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": "Text",
|
||||||
"tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen",
|
"tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen",
|
||||||
"tool.textbox": "Textspalte",
|
"tool.textbox": "Textspalte",
|
||||||
@@ -1142,6 +1148,18 @@ export const de = {
|
|||||||
"cmd.arc.center": "Mittelpunkt:",
|
"cmd.arc.center": "Mittelpunkt:",
|
||||||
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
|
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
|
||||||
"cmd.arc.end": "Endpunkt (Endwinkel):",
|
"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.label": "Text",
|
||||||
"cmd.text.point": "Ankerpunkt:",
|
"cmd.text.point": "Ankerpunkt:",
|
||||||
"cmd.textbox.label": "Textspalte",
|
"cmd.textbox.label": "Textspalte",
|
||||||
|
|||||||
@@ -176,6 +176,12 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"tool.circle.hint": "Circle: set center, then radius",
|
"tool.circle.hint": "Circle: set center, then radius",
|
||||||
"tool.arc": "Arc",
|
"tool.arc": "Arc",
|
||||||
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
|
"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": "Text",
|
||||||
"tool.text.hint": "Text: set anchor point, then type the text",
|
"tool.text.hint": "Text: set anchor point, then type the text",
|
||||||
"tool.textbox": "Text column",
|
"tool.textbox": "Text column",
|
||||||
@@ -1131,6 +1137,18 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.arc.center": "Center:",
|
"cmd.arc.center": "Center:",
|
||||||
"cmd.arc.start": "Start point (radius + start angle):",
|
"cmd.arc.start": "Start point (radius + start angle):",
|
||||||
"cmd.arc.end": "End point (end 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.label": "Text",
|
||||||
"cmd.text.point": "Anchor point:",
|
"cmd.text.point": "Anchor point:",
|
||||||
"cmd.textbox.label": "Text column",
|
"cmd.textbox.label": "Text column",
|
||||||
|
|||||||
@@ -106,6 +106,12 @@ export function translateProject(project: Project, dx: number, dy: number): Proj
|
|||||||
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
|
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
|
||||||
case "arc":
|
case "arc":
|
||||||
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
|
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":
|
case "text":
|
||||||
return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } };
|
return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } };
|
||||||
case "image":
|
case "image":
|
||||||
|
|||||||
@@ -1502,6 +1502,28 @@ export type Drawing2DGeom =
|
|||||||
| { shape: "rect"; min: Vec2; max: Vec2 }
|
| { shape: "rect"; min: Vec2; max: Vec2 }
|
||||||
| { shape: "circle"; center: Vec2; r: number }
|
| { shape: "circle"; center: Vec2; r: number }
|
||||||
| { shape: "arc"; center: Vec2; r: number; a0: number; a1: 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";
|
shape: "text";
|
||||||
at: Vec2;
|
at: Vec2;
|
||||||
|
|||||||
@@ -138,6 +138,36 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
|||||||
<circle cx="13.5" cy="11" r="0.9" fill="currentColor" stroke="none" />
|
<circle cx="13.5" cy="11" r="0.9" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</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":
|
case "text":
|
||||||
// Serifen-„A" als Text-Symbol.
|
// Serifen-„A" als Text-Symbol.
|
||||||
return (
|
return (
|
||||||
@@ -174,7 +204,19 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
|||||||
// Werkzeuge/Befehle in drei Rubriken sortiert (Nutzerwunsch: „sortiere nach 2D
|
// Werkzeuge/Befehle in drei Rubriken sortiert (Nutzerwunsch: „sortiere nach 2D
|
||||||
// und 3D und Bearbeiten"): reine Panel-Gruppierung (2D-Zeichnen · 3D/BIM-
|
// und 3D und Bearbeiten"): reine Panel-Gruppierung (2D-Zeichnen · 3D/BIM-
|
||||||
// Bauteile · Bearbeiten-Aktionen).
|
// 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 TOOLS_3D: ToolId[] = ["wall", "ceiling", "window", "door", "stair", "room"];
|
||||||
const COMMANDS_3D = ["column", "ceilingopening", "roof", "extrude", "sectionline", "viewline"];
|
const COMMANDS_3D = ["column", "ceilingopening", "roof", "extrude", "sectionline", "viewline"];
|
||||||
const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "multiline", "trim", "extend", "fillet", "chamfer", "join", "measure"];
|
const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "multiline", "trim", "extend", "fillet", "chamfer", "join", "measure"];
|
||||||
|
|||||||
@@ -724,6 +724,11 @@ function computeBounds(
|
|||||||
else if (g.shape === "circle" || g.shape === "arc") {
|
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 });
|
||||||
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 === "text") acc(g.at);
|
||||||
else if (g.shape === "image") { acc(g.min); acc(g.max); }
|
else if (g.shape === "image") { acc(g.min); acc(g.max); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
resolveStrokeWeight,
|
resolveStrokeWeight,
|
||||||
type Primitive,
|
type Primitive,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
import { ellipsePts, sampleBezierChain, sampleCatmullRom } from "../../tools/curves";
|
||||||
|
|
||||||
export function addDrawing2D(
|
export function addDrawing2D(
|
||||||
out: Primitive[],
|
out: Primitive[],
|
||||||
@@ -135,6 +136,27 @@ export function addDrawing2D(
|
|||||||
drawingId: d.id,
|
drawingId: d.id,
|
||||||
});
|
});
|
||||||
break;
|
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":
|
case "text":
|
||||||
out.push({
|
out.push({
|
||||||
kind: "drawingText",
|
kind: "drawingText",
|
||||||
|
|||||||
@@ -1407,6 +1407,28 @@ function resizeGeom(
|
|||||||
};
|
};
|
||||||
return { ...g, center, r };
|
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": {
|
case "text": {
|
||||||
// Text: nur die Einfügeposition um den Anker schieben (Höhe bleibt).
|
// 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 };
|
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) },
|
{ 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).
|
// Text: EIN Griff am Ankerpunkt (zum Verschieben; siehe moveGrip).
|
||||||
if (g.shape === "text") return [g.at];
|
if (g.shape === "text") return [g.at];
|
||||||
return [];
|
return [];
|
||||||
@@ -1518,7 +1551,7 @@ function moveGrip(
|
|||||||
if (g.shape === "line") {
|
if (g.shape === "line") {
|
||||||
return { ...d, geom: index === 0 ? { ...g, a: pt } : { ...g, b: pt } };
|
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));
|
const pts = g.pts.map((p, i) => (i === index ? pt : p));
|
||||||
return { ...d, geom: { ...g, pts } };
|
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);
|
const ang = Math.atan2(pt.y - g.center.y, pt.x - g.center.x);
|
||||||
return { ...d, geom: index === 0 ? { ...g, a0: ang } : { ...g, a1: ang } };
|
return { ...d, 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;
|
return d;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -1590,7 +1634,11 @@ function moveElementBy(
|
|||||||
return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
|
return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
|
||||||
case "circle":
|
case "circle":
|
||||||
case "arc":
|
case "arc":
|
||||||
|
case "ellipse":
|
||||||
return { ...d, geom: { ...g, center: mv(g.center) } };
|
return { ...d, geom: { ...g, center: mv(g.center) } };
|
||||||
|
case "bezier":
|
||||||
|
case "spline":
|
||||||
|
return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
|
||||||
case "text":
|
case "text":
|
||||||
return { ...d, geom: { ...g, at: mv(g.at) } };
|
return { ...d, geom: { ...g, at: mv(g.at) } };
|
||||||
default:
|
default:
|
||||||
@@ -1723,7 +1771,7 @@ function moveEdge(
|
|||||||
// Linie hat nur eine Kante [0,1] → das ganze Segment verschieben.
|
// Linie hat nur eine Kante [0,1] → das ganze Segment verschieben.
|
||||||
return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
|
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);
|
const pts = movePolygonEdge(g.pts, aIndex, bIndex, delta, !!g.closed);
|
||||||
return { ...d, geom: { ...g, pts } };
|
return { ...d, geom: { ...g, pts } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -494,7 +494,9 @@ function isClosedGeom(g: Drawing2DGeom): boolean {
|
|||||||
return (
|
return (
|
||||||
g.shape === "rect" ||
|
g.shape === "rect" ||
|
||||||
g.shape === "circle" ||
|
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);
|
||||||
acc(g.center.x + g.r, g.center.y + g.r);
|
acc(g.center.x + g.r, g.center.y + g.r);
|
||||||
break;
|
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":
|
case "text":
|
||||||
acc(g.at.x, g.at.y);
|
acc(g.at.x, g.at.y);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -290,6 +290,8 @@ export function useGripEditing(opts: {
|
|||||||
if (g.shape === "rect" || g.shape === "image") out.push(...ringEdges(verts, owner));
|
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(...ringEdges(verts, owner));
|
||||||
else if (g.shape === "polyline" && !g.closed) out.push(...openEdges(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));
|
else if (g.shape === "line") out.push(...openEdges(verts, owner));
|
||||||
}
|
}
|
||||||
if (effectiveDrawingIds.length === 0) {
|
if (effectiveDrawingIds.length === 0) {
|
||||||
@@ -462,7 +464,7 @@ export function useGripEditing(opts: {
|
|||||||
? project.drawings2d.find((d) => d.id === owner.drawingId)
|
? project.drawings2d.find((d) => d.id === owner.drawingId)
|
||||||
: selectedDrawing;
|
: selectedDrawing;
|
||||||
const g = ownerDrawing?.geom;
|
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);
|
const refIndex = neighborGripIndex(index);
|
||||||
return refIndex != null ? grips[refIndex] : null;
|
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 d = project.drawings2d.find((x) => x.id === owner.drawingId);
|
||||||
const g = d?.geom;
|
const g = d?.geom;
|
||||||
if (!g) return none;
|
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") {
|
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 }];
|
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);
|
return ringNeighborPts(corners, owner.localIndex, true);
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ function drawingsBBox(
|
|||||||
else if (g.shape === "circle" || g.shape === "arc") {
|
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);
|
||||||
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);
|
} else if (g.shape === "text") acc(g.at.x, g.at.y);
|
||||||
}
|
}
|
||||||
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
|
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 "rect": return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
|
||||||
case "circle": return { ...d, geom: { ...g, center: mv(g.center) } };
|
case "circle": return { ...d, geom: { ...g, center: mv(g.center) } };
|
||||||
case "arc": 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) } };
|
case "text": return { ...d, geom: { ...g, at: mv(g.at) } };
|
||||||
default: return d;
|
default: return d;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { add, leftNormal, lineIntersect, normalize, scale, sub } from "../model/
|
|||||||
import { wallReferenceOffset } from "../model/wall";
|
import { wallReferenceOffset } from "../model/wall";
|
||||||
import { centroid as roomCentroid } from "../geometry/roomArea";
|
import { centroid as roomCentroid } from "../geometry/roomArea";
|
||||||
import type { SnapKind, SnapResult, SnapSettings } from "./types";
|
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 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 });
|
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 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 };
|
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]);
|
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).
|
// 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");
|
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;
|
if (best) return best;
|
||||||
|
|||||||
@@ -554,6 +554,45 @@ const arcTool: Tool = {
|
|||||||
onCancel: (s) => [s, { draft: null, done: true }],
|
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 →
|
// Text: Platzhalter-Werkzeug, gekoppelt an den `text`-Befehl (Ankerpunkt →
|
||||||
// getippte Zeile). Wie Kreis/Bogen nicht floorOnly (freies 2D-Element).
|
// getippte Zeile). Wie Kreis/Bogen nicht floorOnly (freies 2D-Element).
|
||||||
const textTool: Tool = {
|
const textTool: Tool = {
|
||||||
@@ -611,6 +650,9 @@ const TOOLS: Record<ToolId, Tool> = {
|
|||||||
rect: rectTool,
|
rect: rectTool,
|
||||||
circle: circleTool,
|
circle: circleTool,
|
||||||
arc: arcTool,
|
arc: arcTool,
|
||||||
|
ellipse: ellipseTool,
|
||||||
|
spline: splineTool,
|
||||||
|
bezier: bezierTool,
|
||||||
text: textTool,
|
text: textTool,
|
||||||
textbox: textboxTool,
|
textbox: textboxTool,
|
||||||
image: imageTool,
|
image: imageTool,
|
||||||
|
|||||||
@@ -179,6 +179,12 @@ const mvGeom = (geom: Drawing2DGeom, fn: (p: Vec2) => Vec2): Drawing2DGeom => {
|
|||||||
return { ...geom, center: fn(geom.center) };
|
return { ...geom, center: fn(geom.center) };
|
||||||
case "arc":
|
case "arc":
|
||||||
return { ...geom, center: fn(geom.center) };
|
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":
|
case "text":
|
||||||
return { ...geom, at: fn(geom.at) };
|
return { ...geom, at: fn(geom.at) };
|
||||||
case "image": {
|
case "image": {
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export type ToolId =
|
|||||||
| "rect"
|
| "rect"
|
||||||
| "circle"
|
| "circle"
|
||||||
| "arc"
|
| "arc"
|
||||||
|
| "ellipse"
|
||||||
|
| "spline"
|
||||||
|
| "bezier"
|
||||||
| "text"
|
| "text"
|
||||||
| "textbox"
|
| "textbox"
|
||||||
| "image";
|
| "image";
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function drawingGripVertices(d: Drawing2D): Vec2[] {
|
|||||||
{ x: g.min.x, y: g.max.y },
|
{ x: g.min.x, y: g.max.y },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (g.shape === "bezier" || g.shape === "spline") return [...g.pts];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ export function drawingMoveAnchor(d: Drawing2D, verts: Vec2[]): Vec2 | null {
|
|||||||
return { x: sx / verts.length, y: sy / verts.length };
|
return { x: sx / verts.length, y: sy / verts.length };
|
||||||
}
|
}
|
||||||
const g = d.geom;
|
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;
|
if (g.shape === "text") return g.at;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user