From bd2b12bfb765ea4a0ecd1f98525336a93bab6dcd Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 5 Jul 2026 15:44:05 +0200 Subject: [PATCH] Werkzeuge: Bogen-Werkzeug (arcCommand, 3-Klick CCW) + Toolbar/Icon/i18n verdrahtet --- src/App.tsx | 1 + src/commands/cmds/arc.ts | 166 ++++++++++++++++++++++++++++++++++++++ src/commands/registry.ts | 4 + src/i18n/de.ts | 6 ++ src/i18n/en.ts | 6 ++ src/panels/ToolsPanel.tsx | 7 ++ src/tools/tools.ts | 15 ++++ src/tools/types.ts | 3 +- 8 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 src/commands/cmds/arc.ts diff --git a/src/App.tsx b/src/App.tsx index bc6acb3..c8bcb8a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -213,6 +213,7 @@ const TOOL_COMMAND: Partial> = { polyline: "polyline", rect: "rect", circle: "circle", + arc: "arc", }; /** Befehlsname für ein gekoppeltes Werkzeug (oder null, wenn keiner). */ function coupledCommandFor(id: ToolId): string | null { diff --git a/src/commands/cmds/arc.ts b/src/commands/cmds/arc.ts new file mode 100644 index 0000000..2c3fda0 --- /dev/null +++ b/src/commands/cmds/arc.ts @@ -0,0 +1,166 @@ +// Arc — Mittelpunkt + Startpunkt + Endpunkt. Schritte: +// 1) „Mittelpunkt:" → Punkt +// 2) „Startpunkt:" → Punkt (setzt Radius r = |P1−Center| UND Startwinkel a0) +// 3) „Endpunkt:" → Punkt (setzt Endwinkel a1; Radius bleibt) → commit +// +// Der Bogen läuft CCW von a0 nach a1 (wie DXF-ARC und das `{shape:"arc"}`- +// Rendering in PlanView). Der Endpunkt wird nur für seinen WINKEL genutzt (auf +// den Kreis mit Radius r projiziert). In generatePlan/PlanView als glatter +// SVG-Bogen (`drawingArc`) gerendert. + +import type { Drawing2D } from "../../model/types"; +import { uniqueId } from "../../tools/types"; +import type { + Command, + CommandContext, + CommandResult, + CommandState, + DraftShape, + Project, + ToolDraft, + Vec2, +} from "../types"; + +const EPS = 1e-6; + +interface ArcCenter extends CommandState { + phase: "center"; +} +interface ArcStart extends CommandState { + phase: "start"; + center: Vec2; + cursor: Vec2 | null; +} +interface ArcEnd extends CommandState { + phase: "end"; + center: Vec2; + r: number; + a0: number; + cursor: Vec2 | null; +} +type ArcState = ArcCenter | ArcStart | ArcEnd; + +const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y); +const angle = (c: Vec2, p: Vec2): number => Math.atan2(p.y - c.y, p.x - c.x); + +/** CCW-Spanne von a0 nach a1, normiert auf (0, 2π]. */ +function sweepOf(a0: number, a1: number): number { + const TAU = Math.PI * 2; + const s = ((a1 - a0) % TAU + TAU) % TAU; + return s < EPS ? TAU : s; +} + +/** Punkte eines Bogens (CCW a0→a1) für die Vorschau. */ +function arcPts(center: Vec2, r: number, a0: number, a1: number): Vec2[] { + const sweep = sweepOf(a0, a1); + const N = Math.max(2, Math.ceil(sweep / (Math.PI / 32))); + const pts: Vec2[] = []; + for (let i = 0; i <= N; i++) { + const t = a0 + (sweep * i) / N; + pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) }); + } + return pts; +} + +/** Punkte einer vollen Kreis-Approximation (Radius-Vorschau im „start"-Schritt). */ +function circlePts(center: Vec2, r: number): Vec2[] { + const N = 48; + const pts: Vec2[] = []; + for (let i = 0; i < N; i++) { + const t = (i / N) * Math.PI * 2; + pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) }); + } + return pts; +} + +/** Vorschau (offener Bogen) + HUD (Spanne in Grad). */ +function arcDraft(center: Vec2, r: number, a0: number, a1: number, at: Vec2 | null): ToolDraft { + if (r < EPS) return { preview: [], vertices: [center] }; + const preview: DraftShape[] = [{ kind: "poly", pts: arcPts(center, r, a0, a1), closed: false }]; + const draft: ToolDraft = { preview, vertices: [center] }; + if (at) { + const deg = (sweepOf(a0, a1) * 180) / Math.PI; + draft.hud = { at, text: `${deg.toFixed(0)}°` }; + } + return draft; +} + +function appendArc(p: Project, center: Vec2, r: number, a0: number, a1: number, ctx: CommandContext): Project { + if (r < EPS) return p; + const d: Drawing2D = { + id: uniqueId("dr2d"), + type: "drawing2d", + levelId: ctx.level.id, + categoryCode: ctx.defaultCategoryCode, + geom: { shape: "arc", center, r, a0, a1 }, + }; + return { ...p, drawings2d: [...p.drawings2d, d] }; +} + +const idle = (): [CommandState, CommandResult] => [ + { phase: "center", lastPoint: null }, + { draft: null, done: true }, +]; + +export const arcCommand: Command = { + name: "arc", + labelKey: "cmd.arc.label", + prompt: (s) => { + const phase = (s as ArcState).phase; + return phase === "start" ? "cmd.arc.start" : phase === "end" ? "cmd.arc.end" : "cmd.arc.center"; + }, + accepts: () => ["point"], + options: () => [], + init: (): ArcCenter => ({ phase: "center", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as ArcState; + if (input.kind !== "point") return [s, { draft: null }]; + const pt = input.point; + if (s.phase === "center") { + const ns: ArcStart = { phase: "start", center: pt, cursor: pt, lastPoint: pt }; + return [ns, { draft: { preview: [], vertices: [pt] } }]; + } + if (s.phase === "start") { + const r = dist(s.center, pt); + if (r < EPS) return [s, { draft: null }]; + const a0 = angle(s.center, pt); + const ns: ArcEnd = { phase: "end", center: s.center, r, a0, cursor: pt, lastPoint: pt }; + return [ns, { draft: arcDraft(s.center, r, a0, a0, pt) }]; + } + // Endpunkt: Endwinkel setzen, Bogen committen. + const a1 = angle(s.center, pt); + const { center, r, a0 } = s; + return [ + { phase: "center", lastPoint: null }, + { draft: null, done: true, commit: (p) => appendArc(p, center, r, a0, a1, ctx) }, + ]; + }, + + onMove: (state, point): [CommandState, CommandResult] => { + const s = state as ArcState; + if (s.phase === "start") { + const r = dist(s.center, point); + const ns: ArcStart = { ...s, cursor: point }; + return [ + ns, + { + draft: { + preview: [{ kind: "poly", pts: circlePts(s.center, r), closed: true }], + vertices: [s.center], + hud: { at: point, text: `r ${r.toFixed(2)} m` }, + }, + }, + ]; + } + if (s.phase === "end") { + const a1 = angle(s.center, point); + const ns: ArcEnd = { ...s, cursor: point }; + return [ns, { draft: arcDraft(s.center, s.r, s.a0, a1, point) }]; + } + return [s, { draft: null }]; + }, + + onConfirm: (): [CommandState, CommandResult] => idle(), + onCancel: (): [CommandState, CommandResult] => idle(), +}; diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 2e70e98..b3cebdf 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -13,6 +13,7 @@ import { stairCommand } from "./cmds/stair"; import { roomCommand } from "./cmds/room"; import { rectCommand } from "./cmds/rect"; import { circleCommand } from "./cmds/circle"; +import { arcCommand } from "./cmds/arc"; import { moveCommand } from "./cmds/move"; import { mirrorCommand } from "./cmds/mirror"; import { joinCommand } from "./cmds/join"; @@ -35,6 +36,7 @@ export const COMMANDS: Record = { polyline: polylineCommand, rect: rectCommand, circle: circleCommand, + arc: arcCommand, move: moveCommand, mirror: mirrorCommand, join: joinCommand, @@ -67,6 +69,8 @@ export const ALIASES: Record = { pl: "polyline", rec: "rect", c: "circle", + a: "arc", + bogen: "arc", m: "move", s: "mirror", spiegeln: "mirror", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index e4c93e8..94cad1c 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -118,6 +118,8 @@ export const de = { "tool.rect": "Rechteck", "tool.circle": "Kreis", "tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius", + "tool.arc": "Bogen", + "tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)", "tool.select.hint": "Auswahl — Elemente klicken/aufziehen", "tool.window.hint": "Fenster: Wand wählen, dann Position setzen", "tool.door.hint": "Tür: Wand wählen, dann Position setzen", @@ -709,6 +711,10 @@ export const de = { "cmd.circle.label": "Kreis", "cmd.circle.center": "Mittelpunkt:", "cmd.circle.radius": "Radius:", + "cmd.arc.label": "Bogen", + "cmd.arc.center": "Mittelpunkt:", + "cmd.arc.start": "Startpunkt (Radius + Startwinkel):", + "cmd.arc.end": "Endpunkt (Endwinkel):", // Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl. "cmd.move.label": "Verschieben", "cmd.move.base": "Basispunkt:", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index a12e868..c4b08ed 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -117,6 +117,8 @@ export const en: Record = { "tool.rect": "Rectangle", "tool.circle": "Circle", "tool.circle.hint": "Circle: set center, then radius", + "tool.arc": "Arc", + "tool.arc.hint": "Arc: center, start point, then end point (CCW)", "tool.select.hint": "Select — click/drag elements", "tool.window.hint": "Window: pick a wall, then set the position", "tool.door.hint": "Door: pick a wall, then set the position", @@ -699,6 +701,10 @@ export const en: Record = { "cmd.circle.label": "Circle", "cmd.circle.center": "Center:", "cmd.circle.radius": "Radius:", + "cmd.arc.label": "Arc", + "cmd.arc.center": "Center:", + "cmd.arc.start": "Start point (radius + start angle):", + "cmd.arc.end": "End point (end angle):", "cmd.move.label": "Move", "cmd.move.base": "Base point:", "cmd.move.target": "Target point:", diff --git a/src/panels/ToolsPanel.tsx b/src/panels/ToolsPanel.tsx index c22ce97..aa38c46 100644 --- a/src/panels/ToolsPanel.tsx +++ b/src/panels/ToolsPanel.tsx @@ -118,6 +118,13 @@ function ToolIcon({ id }: { id: ToolId }) { ); + case "arc": + // Offener Halbbogen. + return ( + + + + ); default: return null; } diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 433a7a1..6e0a583 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -541,6 +541,19 @@ const circleTool: Tool = { onCancel: (s) => [s, { draft: null, done: true }], }; +// Bogen: Platzhalter-Werkzeug, gekoppelt an den `arc`-Befehl (3 Klicks: +// Mitte → Start/Radius → Ende). Wie Kreis nicht floorOnly. +const arcTool: Tool = { + id: "arc", + labelKey: "tool.arc", + hintKey: () => "tool.arc.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 }], +}; + // ── Registry ───────────────────────────────────────────────────────────────── const TOOLS: Record = { @@ -555,6 +568,7 @@ const TOOLS: Record = { polyline: polylineTool, rect: rectTool, circle: circleTool, + arc: arcTool, }; /** Liefert das Werkzeug zur ID. */ @@ -575,4 +589,5 @@ export const TOOL_ORDER: ToolId[] = [ "polyline", "rect", "circle", + "arc", ]; diff --git a/src/tools/types.ts b/src/tools/types.ts index 1b0cb76..2702355 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -19,7 +19,8 @@ export type ToolId = | "line" | "polyline" | "rect" - | "circle"; + | "circle" + | "arc"; // ── Snapping ───────────────────────────────────────────────────────────────