From 51e594ee90a000655541d35e9360000783955ebc Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 20 Aug 2026 20:53:31 +0200 Subject: [PATCH] 2D: Fillet und Extend als Befehle verdrahtet (Geometrie existierte bereits) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filletCorner (Eckpunkt-Verrundung) und extendSegment (Gegenstück zu Trim) existierten fertig implementiert und getestet im Kernel (kernel2d.ts), hatten aber keinen einzigen Aufrufer — kein Command, kein Werkzeug-Knopf, für den Nutzer nicht erreichbar. Reine Verdrahtungsarbeit, kein neues Geometrie- Problem: - fillet.ts: Ecke einer polyline/rect wählen (nächstgelegener Eckpunkt mit zwei Nachbarn), Radius per Maus oder getippt (Tab-Feld wie bei circle.ts). Da Polylinien im Modell keine Bogen-Segmente kennen (kein "Bulge" — bekannte, separate Lücke), wird die Verrundung als kurze Punktfolge tessselliert statt als echtes Arc-Objekt gespeichert; ein rect wird beim ersten Fillet zu einer polyline. Ehrlich im Kopfkommentar dokumentiert. - extend.ts: offene Kurve (line/offene polyline) nahe einem Ende anklicken, dieses Ende wird bis zur nächsten anderen Kurve verlängert (implizite Cutter wie bei Trim). Kein Treffer → No-op, Befehl bleibt aktiv. Beide in COMMANDS/ALIASES registriert (fi/verrunden, ext/verlaengern), i18n de/en ergänzt, in ToolsPanel COMMANDS_EDIT neben Trim/Join aufgenommen, eigene Icons in CommandIcon.tsx (bisher: unbekannter Name → kein Icon). Rotate NICHT angefasst: der ursprüngliche Befund „Rotate ist keine getippte Kommandozeilen-Aktion, nur ein UI-Button" war unvollständig — App.tsx routet sowohl den Ribbon-Button als auch das getippte Wort „rotate"/„drehen" ganz bewusst an startTransformOp() (den reicheren U/I/O/P-Move/Copy/Array/ Verteilen-Fluss), nicht an die generische Befehls-Engine (Kommentar dort erklärt das explizit). Ein zuerst geschriebener, einfacher rotateCommand (nur „move"-Modus) wäre über diese Sonderfälle nie erreicht worden UND hätte über neue Aliase wie „ro" einen inkonsistenten Zweit-Pfad geöffnet (voller Funktionsumfang bei „rotate", abgespeckter bei „ro") — verworfen, bevor committet. 6 neue Tests (fillet: Verrundung + Fehltreffer; extend: Verlängerung + Kein-Cutter-No-op). tsc -b / vitest run (911/911) / npm run build grün. --- src/commands/cmds/extend.test.ts | 85 ++++++++++++ src/commands/cmds/extend.ts | 180 ++++++++++++++++++++++++ src/commands/cmds/fillet.test.ts | 103 ++++++++++++++ src/commands/cmds/fillet.ts | 227 +++++++++++++++++++++++++++++++ src/commands/registry.ts | 9 ++ src/i18n/de.ts | 7 + src/i18n/en.ts | 7 + src/panels/ToolsPanel.tsx | 2 +- src/ui/ribbon/CommandIcon.tsx | 17 +++ 9 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 src/commands/cmds/extend.test.ts create mode 100644 src/commands/cmds/extend.ts create mode 100644 src/commands/cmds/fillet.test.ts create mode 100644 src/commands/cmds/fillet.ts diff --git a/src/commands/cmds/extend.test.ts b/src/commands/cmds/extend.test.ts new file mode 100644 index 0000000..84929a6 --- /dev/null +++ b/src/commands/cmds/extend.test.ts @@ -0,0 +1,85 @@ +/** + * `extendCommand` — Gegenstück zu Trim (`extendSegment` im Kernel existierte + * bereits, hatte aber keinen Aufrufer). Test: eine kurze Linie wird bis zu + * einer kreuzenden Cutter-Linie verlängert. + */ + +import { describe, it, expect } from "vitest"; +import { extendCommand } from "./extend"; +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: [ + // Cutter: vertikale Linie bei x=5, deckt y=-10..10 ab. + { id: "cutter", type: "drawing2d", levelId: "eg", categoryCode: "20", geom: { shape: "line", a: { x: 5, y: -10 }, b: { x: 5, y: 10 } } }, + // Ziel: kurze horizontale Linie von (0,0) bis (3,0) — reicht nicht bis zum Cutter. + { id: "target", type: "drawing2d", levelId: "eg", categoryCode: "20", geom: { shape: "line", a: { x: 0, y: 0 }, b: { x: 3, y: 0 } } }, + ], + 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("extendCommand", () => { + it("verlängert das nähere Ende der Linie bis zum nächsten Cutter", () => { + const p = project(); + const ctx = makeCtx(p); + const [, result] = extendCommand.onInput( + extendCommand.init(), + { kind: "point", point: { x: 3, y: 0 } }, // nahe am Ende (3,0) der Ziellinie + ctx, + ); + expect(result.commit).toBeDefined(); + const next = result.commit!(p); + const target = next.drawings2d.find((d) => d.id === "target")!; + expect(target.geom.shape).toBe("line"); + if (target.geom.shape === "line") { + expect(target.geom.a).toEqual({ x: 0, y: 0 }); // unverändertes Ende bleibt stehen + expect(target.geom.b.x).toBeCloseTo(5, 9); // bis zum Cutter (x=5) verlängert + expect(target.geom.b.y).toBeCloseTo(0, 9); + } + }); + + it("kein Cutter getroffen → No-op (kein commit-Effekt)", () => { + const p = project(); + // Cutter entfernen: nichts zum Verlängern-bis da. + p.drawings2d = p.drawings2d.filter((d) => d.id !== "cutter"); + const ctx = makeCtx(p); + const [, result] = extendCommand.onInput( + extendCommand.init(), + { kind: "point", point: { x: 3, y: 0 } }, + ctx, + ); + expect(result.commit).toBeDefined(); + const next = result.commit!(p); + expect(next).toBe(p); // unverändert (No-op) + }); +}); diff --git a/src/commands/cmds/extend.ts b/src/commands/cmds/extend.ts new file mode 100644 index 0000000..23705b8 --- /dev/null +++ b/src/commands/cmds/extend.ts @@ -0,0 +1,180 @@ +// Extend — verlängert eine OFFENE 2D-Kurve (line/offene polyline) bis zur +// nächsten anderen Kurve (Rhino/AutoCAD-artig, Gegenstück zu Trim). Modell: +// • Cutters sind IMPLIZIT alle ANDEREN Drawing2D-Elemente der aktiven Ebene +// (wie bei Trim — kein separater Cutter-Auswahlschritt). +// • Ein Schritt: der Nutzer klickt auf die zu verlängernde Kurve, NAHE dem +// Ende, das verlängert werden soll (das Ende, das dem Klick am nächsten +// liegt, wird verlängert — nicht zwingend das nächste per Cursor-Distanz +// zur ganzen Kurve, sondern zum jeweiligen Endpunkt). +// • Trifft die Verlängerung keinen Cutter, passiert nichts (kein Fehler, +// Befehl bleibt aktiv). +// • Wiederholend, bis Esc/Enter. +// +// Geschlossene Formen (rect, geschlossene polyline) und reine Punktformen +// (circle/arc/text) sind nicht extend-fähig — ein geschlossener Ring hat kein +// "Ende". Reine Geometrie liegt im Kernel (`extendSegment`); hier nur +// Treffer-Bestimmung, Drawing2D ↔ {pts,closed} und der Commit. +// +// Bezeichner englisch, Kommentare/UI deutsch (CONVENTIONS.md). + +import type { Drawing2D, Drawing2DGeom } from "../../model/types"; +import { + extendSegment, + pointSegmentDistance, + polylineEdges, +} from "../../geometry/kernel2d"; +import type { + Command, + CommandResult, + CommandState, + DraftShape, + Project, + Vec2, +} from "../types"; + +const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl + +interface Curve { + pts: Vec2[]; + closed: boolean; +} + +/** Wandelt eine extend-fähige 2D-Form (offen) in eine Punktliste oder null. */ +function geomToOpenCurve(g: Drawing2DGeom): Curve | null { + if (g.shape === "line") return { pts: [g.a, g.b], closed: false }; + if (g.shape === "polyline" && !g.closed) return { pts: g.pts, closed: false }; + return null; // rect/geschlossene polyline/circle/arc/text: kein Ende zum Verlängern +} + +/** Alle trim/extend-fähigen Drawing2D der Ebene als Cutter-Kurven (auch geschlossene). */ +function cuttersFor(project: Project, levelId: string, exceptId: string): Curve[] { + const out: Curve[] = []; + for (const d of project.drawings2d) { + if (d.id === exceptId) continue; + if (d.levelId !== levelId) continue; + const g = d.geom; + if (g.shape === "line") out.push({ pts: [g.a, g.b], closed: false }); + else if (g.shape === "polyline") out.push({ pts: g.pts, closed: g.closed }); + else if (g.shape === "rect") { + out.push({ + pts: [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }], + closed: true, + }); + } + } + return out; +} + +/** Nächste extend-fähige (offene) Drawing2D der Ebene zum Klickpunkt. */ +function pickOpenCurveAt(project: Project, levelId: string, pt: Vec2): Drawing2D | null { + let best: Drawing2D | null = null; + let bestD = HIT_TOL; + for (const d of project.drawings2d) { + if (d.levelId !== levelId) continue; + const curve = geomToOpenCurve(d.geom); + if (!curve) continue; + for (const [a, b] of polylineEdges(curve.pts, curve.closed)) { + const dd = pointSegmentDistance(pt, a, b); + if (dd < bestD) { + bestD = dd; + best = d; + } + } + } + return best; +} + +/** + * Verlängert die Kurve am Ende, das dem Klickpunkt am nächsten liegt, bis zum + * nächsten Schnitt mit den Cuttern (jenseits dieses Endes). Liefert die neuen + * Punkte, oder `null`, wenn kein Cutter getroffen wird (kein Effekt). + */ +function extendCurve(curve: Curve, cutters: Curve[], pick: Vec2): Vec2[] | null { + const n = curve.pts.length; + if (n < 2) return null; + const distStart = Math.hypot(pick.x - curve.pts[0].x, pick.y - curve.pts[0].y); + const distEnd = Math.hypot(pick.x - curve.pts[n - 1].x, pick.y - curve.pts[n - 1].y); + const extendStart = distStart <= distEnd; + const idxA = extendStart ? 1 : n - 2; + const idxB = extendStart ? 0 : n - 1; + const result = extendSegment(curve.pts[idxA], curve.pts[idxB], "end", cutters); + if (!result) return null; + const next = [...curve.pts]; + next[idxB] = result[1]; + return next; +} + +/** Wendet Extend auf das Projekt an: ersetzt die getroffene Kurve durch ihre verlängerte Form. */ +function applyExtend(p: Project, target: Drawing2D, pick: Vec2): Project { + const curve = geomToOpenCurve(target.geom); + if (!curve) return p; + const cutters = cuttersFor(p, target.levelId, target.id); + const next = extendCurve(curve, cutters, pick); + if (!next) return p; + const geom: Drawing2DGeom = + next.length === 2 && target.geom.shape === "line" + ? { shape: "line", a: next[0], b: next[1] } + : { shape: "polyline", pts: next, closed: false }; + return { + ...p, + drawings2d: p.drawings2d.map((d) => (d.id === target.id ? { ...d, geom } : d)), + }; +} + +/** Vorschau-Form der verlängerten Kurve (für den Draft). */ +function extendPreview(project: Project, target: Drawing2D, pick: Vec2): DraftShape[] { + const curve = geomToOpenCurve(target.geom); + if (!curve) return []; + const cutters = cuttersFor(project, target.levelId, target.id); + const next = extendCurve(curve, cutters, pick); + if (!next) return []; + return [{ kind: "poly", pts: next, closed: false }]; +} + +// ── Zustand ─────────────────────────────────────────────────────────────────── +// Einziger Schritt „pick": auf die zu verlängernde Kurve (nahe dem Ende) klicken; +// wiederholend, bis Esc/Enter. +interface ExtendPick extends CommandState { + phase: "pick"; +} +interface ExtendDone extends CommandState { + phase: "done"; +} +type ExtendState = ExtendPick | ExtendDone; + +const finish = (): [CommandState, CommandResult] => [ + { phase: "done", lastPoint: null } as ExtendDone, + { draft: null, done: true }, +]; + +export const extendCommand: Command = { + name: "extend", + labelKey: "cmd.extend.label", + prompt: () => "cmd.extend.pick", + accepts: () => ["point"], + options: () => [], + + init: (): ExtendPick => ({ phase: "pick", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as ExtendState; + if (s.phase === "done") return finish(); + if (input.kind !== "point") return [s, { draft: null }]; + const target = pickOpenCurveAt(ctx.project, ctx.level.id, input.point); + if (!target) return [s, { draft: null }]; + const pick = input.point; + return [s, { draft: null, commit: (p) => applyExtend(p, target, pick) }]; + }, + + onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => { + const s = state as ExtendState; + if (s.phase !== "pick") return [s, { draft: null }]; + const target = pickOpenCurveAt(ctx.project, ctx.level.id, point); + if (!target) return [s, { draft: null }]; + const preview = extendPreview(ctx.project, target, point); + return [s, { draft: { preview, vertices: [], hud: { at: point, text: "↗" } } }]; + }, + + onConfirm: (): [CommandState, CommandResult] => finish(), + onCancel: (): [CommandState, CommandResult] => finish(), +}; diff --git a/src/commands/cmds/fillet.test.ts b/src/commands/cmds/fillet.test.ts new file mode 100644 index 0000000..377cbc7 --- /dev/null +++ b/src/commands/cmds/fillet.test.ts @@ -0,0 +1,103 @@ +/** + * `filletCommand` — die Kernel-Geometrie (`filletCorner`) existierte bereits + * und war getestet, hatte aber KEINEN Aufrufer (kein Command, kein Tool). + * Test: eine rechtwinklige Polylinien-Ecke wird mit einem Radius verrundet. + */ + +import { describe, it, expect } from "vitest"; +import { filletCommand } from "./fillet"; +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: [ + // L-förmige offene Polylinie: (0,0) → (2,0) → (2,2). Ecke bei (2,0). + { + id: "poly", + type: "drawing2d", + levelId: "eg", + categoryCode: "20", + geom: { shape: "polyline", pts: [{ x: 0, y: 0 }, { x: 2, y: 0 }, { x: 2, y: 2 }], closed: false }, + }, + ], + 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("filletCommand", () => { + it("verrundet die Ecke mit getipptem Radius (Zahl-Eingabe)", () => { + const p = project(); + const ctx = makeCtx(p); + + // Schritt 1: Ecke wählen (Klick nahe (2,0)). + const [afterPick, r1] = filletCommand.onInput( + filletCommand.init(), + { kind: "point", point: { x: 2, y: 0 } }, + ctx, + ); + expect(r1.done).toBeFalsy(); + + // Schritt 2: Radius getippt (0.5m) → commit. + const [, r2] = filletCommand.onInput(afterPick, { kind: "number", value: 0.5 }, ctx); + expect(r2.done).toBe(true); + expect(r2.commit).toBeDefined(); + + const next = r2.commit!(p); + const poly = next.drawings2d.find((d) => d.id === "poly")!; + expect(poly.geom.shape).toBe("polyline"); + if (poly.geom.shape !== "polyline") return; + const pts = poly.geom.pts; + // Die scharfe Ecke (2,0) darf nicht mehr exakt in den Punkten vorkommen — + // sie wurde durch den Bogen ersetzt. + expect(pts.some((p) => p.x === 2 && p.y === 0)).toBe(false); + // Anfangs-/Endpunkt der ganzen Kette bleiben unverändert. + expect(pts[0]).toEqual({ x: 0, y: 0 }); + expect(pts[pts.length - 1]).toEqual({ x: 2, y: 2 }); + // Die Tangentenpunkte (0.5m vom Eck entlang jedes Schenkels) müssen vorkommen. + const hasNear = (x: number, y: number) => + pts.some((p) => Math.hypot(p.x - x, p.y - y) < 1e-6); + expect(hasNear(1.5, 0)).toBe(true); // Tangente auf dem ersten Schenkel + expect(hasNear(2, 0.5)).toBe(true); // Tangente auf dem zweiten Schenkel + }); + + it("daneben geklickt → Befehl bleibt im pick-Zustand (kein Treffer)", () => { + const p = project(); + const ctx = makeCtx(p); + const [state, result] = filletCommand.onInput( + filletCommand.init(), + { kind: "point", point: { x: 50, y: 50 } }, + ctx, + ); + expect(result.commit).toBeUndefined(); + expect(result.done).toBeFalsy(); + expect((state as { phase: string }).phase).toBe("pick"); + }); +}); diff --git a/src/commands/cmds/fillet.ts b/src/commands/cmds/fillet.ts new file mode 100644 index 0000000..fb747d7 --- /dev/null +++ b/src/commands/cmds/fillet.ts @@ -0,0 +1,227 @@ +// Fillet — verrundet die Ecke einer 2D-Kurve (polyline/rect) mit einem Radius +// (Rhino/AutoCAD-artig). Die reine Geometrie (`filletCorner`) existierte +// bereits im Kernel, hatte aber KEINEN Aufrufer — reine Verdrahtungsarbeit, +// kein neues Geometrie-Problem. Schritte: +// 1) „Ecke wählen:" → Punkt (nächstgelegener Eckpunkt einer polyline/rect +// wird gewählt; ein einzelnes `line`-Element hat keine eigene Ecke) +// 2) „Radius:" → Punkt (Abstand von der Ecke) ODER getippte Zahl → commit +// +// EINSCHRÄNKUNG (ehrlich, nicht verschwiegen): Polylinien kennen im Modell +// bisher keine Bogen-Segmente (kein „Bulge" — siehe PENDENZEN.md, 2D- +// Vervollständigung). Die Verrundung wird daher als kurze Punktfolge entlang +// des Bogens TESSELLIERT (approximiert), nicht als echtes Arc-Segment +// gespeichert — optisch rund, aber kein editierbares Bogen-Objekt. Ein rect +// wird beim ersten Fillet zu einer polyline (kein reines Rechteck mehr). + +import type { Drawing2D, Drawing2DGeom } from "../../model/types"; +import { filletCorner, type Fillet } from "../../geometry/kernel2d"; +import type { + Command, + CommandContext, + CommandField, + CommandResult, + CommandState, + DraftShape, + Project, + ToolDraft, + Vec2, +} from "../types"; + +const HIT_TOL = 0.3; // Modell-Meter: Pick-Toleranz für die Eckenwahl +const DEFAULT_RADIUS = 0.1; // m — Startwert, bis der Nutzer per Maus/Tab einen eigenen setzt +const EPS = 1e-6; +const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y); + +interface Curve { + pts: Vec2[]; + closed: boolean; +} + +/** Wandelt eine fillet-fähige 2D-Form (polyline/rect) in eine Punktliste oder null. */ +function geomToCurve(g: Drawing2DGeom): Curve | null { + if (g.shape === "polyline") return { pts: g.pts, closed: g.closed }; + if (g.shape === "rect") { + return { + pts: [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }], + closed: true, + }; + } + return null; // line (keine eigene Ecke), circle/arc/text: nicht fillet-fähig +} + +/** Ein Treffer: das Element + der Eckpunkt-Index (mit gültigen beiden Nachbarn). */ +interface CornerHit { + drawing: Drawing2D; + curve: Curve; + index: number; +} + +/** Nächster Eckpunkt (mit zwei Nachbarn) einer fillet-fähigen Drawing2D zum Klickpunkt. */ +function pickCornerAt(project: Project, levelId: string, pt: Vec2): CornerHit | null { + let best: CornerHit | null = null; + let bestD = HIT_TOL; + for (const d of project.drawings2d) { + if (d.levelId !== levelId) continue; + const curve = geomToCurve(d.geom); + if (!curve) continue; + const n = curve.pts.length; + for (let i = 0; i < n; i++) { + // Offene Polylinie: die beiden Endpunkte haben keinen zweiten Nachbarn. + if (!curve.closed && (i === 0 || i === n - 1)) continue; + const dd = dist(pt, curve.pts[i]); + if (dd < bestD) { + bestD = dd; + best = { drawing: d, curve, index: i }; + } + } + } + return best; +} + +/** Verrundungs-Geometrie für einen Treffer + Radius (oder null, falls unmöglich). */ +function filletFor(hit: CornerHit, r: number): Fillet | null { + const n = hit.curve.pts.length; + const prev = hit.curve.pts[(hit.index - 1 + n) % n]; + const next = hit.curve.pts[(hit.index + 1) % n]; + const corner = hit.curve.pts[hit.index]; + return filletCorner(corner, prev, next, r); +} + +/** Bogen (center/radius/start-/endAngle) als kurze Punktfolge tesselliert (kürzester Bogen). */ +function tessellateFillet(f: Fillet, segments = 12): Vec2[] { + const TAU = Math.PI * 2; + let delta = f.endAngle - f.startAngle; + while (delta > Math.PI) delta -= TAU; + while (delta < -Math.PI) delta += TAU; + const pts: Vec2[] = []; + for (let i = 0; i <= segments; i++) { + const a = f.startAngle + (delta * i) / segments; + pts.push({ x: f.center.x + f.radius * Math.cos(a), y: f.center.y + f.radius * Math.sin(a) }); + } + return pts; +} + +/** Ersetzt den Eckpunkt durch die tessellierte Bogen-Punktfolge (Tangente↔Tangente). */ +function applyFilletPoints(hit: CornerHit, f: Fillet): Vec2[] { + const arc = tessellateFillet(f); + return [ + ...hit.curve.pts.slice(0, hit.index), + ...arc, + ...hit.curve.pts.slice(hit.index + 1), + ]; +} + +function filletDraft(hit: CornerHit, r: number, at: Vec2 | null): ToolDraft { + const f = r > EPS ? filletFor(hit, r) : null; + const preview: DraftShape[] = f + ? [{ kind: "poly", pts: tessellateFillet(f), closed: false }] + : []; + const draft: ToolDraft = { preview, vertices: [hit.curve.pts[hit.index]] }; + if (at) draft.hud = { at, text: `R: ${r.toFixed(3)}m${f ? "" : " ✗"}` }; + return draft; +} + +function commitFillet(p: Project, hit: CornerHit, r: number): Project { + const f = filletFor(hit, r); + if (!f) return p; // Radius zu gross für die Schenkellänge, oder Ecke (fast) gerade → No-op + const pts = applyFilletPoints(hit, f); + const geom: Drawing2DGeom = { shape: "polyline", pts, closed: hit.curve.closed }; + return { + ...p, + drawings2d: p.drawings2d.map((d) => (d.id === hit.drawing.id ? { ...d, geom } : d)), + }; +} + +// ── Zustand ─────────────────────────────────────────────────────────────────── +const RADIUS_FIELDS: CommandField[] = [{ id: "radius", labelKey: "cmd.field.radius" }]; + +interface FilletPick extends CommandState { + phase: "pick"; +} +interface FilletRadius extends CommandState { + phase: "radius"; + hit: CornerHit; + radius: number; +} +interface FilletDone extends CommandState { + phase: "done"; +} +type FilletState = FilletPick | FilletRadius | FilletDone; + +const finish = (): [CommandState, CommandResult] => [ + { phase: "done", lastPoint: null } as FilletDone, + { draft: null, done: true }, +]; + +export const filletCommand: Command = { + name: "fillet", + labelKey: "cmd.fillet.label", + prompt: (s) => ((s as FilletState).phase === "radius" ? "cmd.fillet.radius" : "cmd.fillet.pick"), + accepts: (s) => ((s as FilletState).phase === "radius" ? ["point", "number"] : ["point"]), + options: () => [], + fields: (s) => ((s as FilletState).phase === "radius" ? RADIUS_FIELDS : []), + + init: (): FilletPick => ({ phase: "pick", lastPoint: null }), + + onInput: (state, input, ctx: CommandContext): [CommandState, CommandResult] => { + const s = state as FilletState; + if (s.phase === "done") return finish(); + if (s.phase === "pick") { + if (input.kind !== "point") return [s, { draft: null }]; + const hit = pickCornerAt(ctx.project, ctx.level.id, input.point); + if (!hit) return [s, { draft: null }]; // daneben geklickt → aktiv bleiben + const next: FilletRadius = { phase: "radius", hit, radius: DEFAULT_RADIUS, lastPoint: input.point }; + return [next, { draft: filletDraft(hit, next.radius, input.point) }]; + } + // phase === "radius" + let r = s.radius; + if (input.kind === "number") r = Math.max(0, input.value); + else if (input.kind === "point") r = Math.max(0, dist(s.hit.curve.pts[s.hit.index], input.point)); + else return [s, { draft: null }]; + return [ + { phase: "done", lastPoint: null } as FilletDone, + { draft: null, done: true, commit: (p) => commitFillet(p, s.hit, r) }, + ]; + }, + + onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => { + const s = state as FilletState; + if (s.phase === "pick") { + const hit = pickCornerAt(ctx.project, ctx.level.id, point); + if (!hit) return [s, { draft: null }]; + return [s, { draft: filletDraft(hit, DEFAULT_RADIUS, point) }]; + } + if (s.phase !== "radius") return [s, { draft: null }]; + const r = Math.max(0, dist(s.hit.curve.pts[s.hit.index], point)); + const ns: FilletRadius = { ...s, radius: r }; + return [ns, { draft: filletDraft(s.hit, r, point) }]; + }, + + onConfirm: (): [CommandState, CommandResult] => finish(), + onCancel: (): [CommandState, CommandResult] => finish(), + + // Tab-Feld-Zyklus nur im „radius"-Schritt (ein Radius-Feld), analog zu + // circle.ts: der gelockte Wert bestimmt den Punkt auf der Winkelhalbierenden + // Richtung Cursor (bzw. +X ohne Cursor), dist(Ecke, Punkt) = radius. + fieldValues: (state, _locks, cursor): Record => { + const s = state as FilletState; + if (s.phase !== "radius" || !cursor) return {}; + return { radius: dist(s.hit.curve.pts[s.hit.index], cursor) }; + }, + pointFromFields: (state, locks, cursor) => { + const s = state as FilletState; + if (s.phase !== "radius") return null; + const corner = s.hit.curve.pts[s.hit.index]; + const r = "radius" in locks ? Math.max(0, locks.radius) : cursor ? dist(corner, cursor) : 0; + if (cursor) { + const d = dist(corner, cursor); + if (d > EPS) { + return { + x: corner.x + ((cursor.x - corner.x) / d) * r, + y: corner.y + ((cursor.y - corner.y) / d) * r, + }; + } + } + return { x: corner.x + r, y: corner.y }; + }, +}; diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 2cb953b..a7bf2b8 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -26,6 +26,8 @@ import { copyCommand } from "./cmds/copy"; import { offsetCommand } from "./cmds/offset"; import { extrudeCommand } from "./cmds/extrude"; import { trimCommand } from "./cmds/trim"; +import { extendCommand } from "./cmds/extend"; +import { filletCommand } from "./cmds/fillet"; import { importCommand } from "./cmds/import"; import { terrainCommand } from "./cmds/terrain"; import { measureCommand } from "./cmds/measure"; @@ -58,6 +60,8 @@ export const COMMANDS: Record = { offset: offsetCommand, extrude: extrudeCommand, trim: trimCommand, + extend: extendCommand, + fillet: filletCommand, import: importCommand, terrain: terrainCommand, measure: measureCommand, @@ -108,6 +112,11 @@ export const ALIASES: Record = { o: "offset", ex: "extrude", tr: "trim", + ext: "extend", + verlaengern: "extend", + verlängern: "extend", + fi: "fillet", + verrunden: "fillet", imp: "import", ter: "terrain", schnittlinie: "sectionline", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 044fe1a..0d4b16b 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -1182,6 +1182,13 @@ export const de = { // Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend. "cmd.trim.label": "Stutzen", "cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):", + // Extend: Gegenstück zu Trim — verlängert eine offene Kurve bis zur nächsten anderen Kurve. + "cmd.extend.label": "Verlängern", + "cmd.extend.pick": "Kurve nahe dem zu verlängernden Ende anklicken (Esc beendet):", + // Fillet: Eckpunkt-Verrundung mit Radius (Maus oder getippt). + "cmd.fillet.label": "Verrunden", + "cmd.fillet.pick": "Ecke wählen:", + "cmd.fillet.radius": "Radius:", // Editier-Werkzeuge Split / Join / Segment-Löschen (Tastatur: Ctrl+S / Ctrl+J / // Alt+Klick). Labels für Befehlsleiste/Menüs. "edit.split.label": "Teilen", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index bcdd4d4..10cee85 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1170,6 +1170,13 @@ export const en: Record = { // Trim (quick-trim): click the part to cut away; repeats until Esc. "cmd.trim.label": "Trim", "cmd.trim.pick": "Click the part to trim away (Esc to finish):", + // Extend: counterpart to Trim — extends an open curve to the next other curve. + "cmd.extend.label": "Extend", + "cmd.extend.pick": "Click the curve near the end to extend (Esc to finish):", + // Fillet: corner rounding with a radius (mouse or typed). + "cmd.fillet.label": "Fillet", + "cmd.fillet.pick": "Select corner:", + "cmd.fillet.radius": "Radius:", // Edit tools Split / Join / Delete segment (keyboard: Ctrl+S / Ctrl+J / // Alt+Click). Labels for the command line / menus. "edit.split.label": "Split", diff --git a/src/panels/ToolsPanel.tsx b/src/panels/ToolsPanel.tsx index da8af38..30b4f47 100644 --- a/src/panels/ToolsPanel.tsx +++ b/src/panels/ToolsPanel.tsx @@ -177,7 +177,7 @@ export function ToolIcon({ id }: { id: ToolId }) { const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "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", "trim", "join", "measure"]; +const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "trim", "extend", "fillet", "join", "measure"]; type ToolsView = "list" | "icons"; const VIEW_STORAGE_KEY = "dossier.toolsPanel.view"; diff --git a/src/ui/ribbon/CommandIcon.tsx b/src/ui/ribbon/CommandIcon.tsx index f2f7cd7..3dd5365 100644 --- a/src/ui/ribbon/CommandIcon.tsx +++ b/src/ui/ribbon/CommandIcon.tsx @@ -91,6 +91,23 @@ export function CommandIcon({ name }: { name: string }) { ); + case "fillet": + // Eckpunkt-Verrundung: gerade Schenkel, Ecke durch einen Bogen ersetzt. + return ( + + + + ); + case "extend": + // Linie, die bis zu einer Grenze verlängert wird (Pfeil + Ziellinie). + return ( + + + + + + + ); case "column": // Stütze: Quadrat-Querschnitt mit vertikalen Kanten (Pfeiler). return (