diff --git a/src/commands/cmds/chamfer.test.ts b/src/commands/cmds/chamfer.test.ts new file mode 100644 index 0000000..b874095 --- /dev/null +++ b/src/commands/cmds/chamfer.test.ts @@ -0,0 +1,119 @@ +/** + * `chamferCommand` — die Kernel-Geometrie (`chamferCorner`) ist neu (existierte + * bisher gar nicht, anders als filletCorner). Test: eine rechtwinklige + * Polylinien-Ecke wird mit einer Distanz gefast (gerader Schnitt statt Bogen). + */ + +import { describe, it, expect } from "vitest"; +import { chamferCommand } from "./chamfer"; +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("chamferCommand", () => { + it("fast die Ecke mit getippter Distanz (Zahl-Eingabe)", () => { + const p = project(); + const ctx = makeCtx(p); + + // Schritt 1: Ecke wählen (Klick nahe (2,0)). + const [afterPick, r1] = chamferCommand.onInput( + chamferCommand.init(), + { kind: "point", point: { x: 2, y: 0 } }, + ctx, + ); + expect(r1.done).toBeFalsy(); + + // Schritt 2: Distanz getippt (0.5m) → commit. + const [, r2] = chamferCommand.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 vorkommen — durch die Fasung 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 }); + // Exakt zwei neue Punkte (gerade Fasung, keine Tessellierung): 0.5m vom + // Eck entlang jedes Schenkels. + expect(pts.length).toBe(4); + const close = (a: { x: number; y: number }, x: number, y: number) => + Math.hypot(a.x - x, a.y - y) < 1e-9; + expect(close(pts[1], 1.5, 0)).toBe(true); + expect(close(pts[2], 2, 0.5)).toBe(true); + }); + + it("daneben geklickt → Befehl bleibt im pick-Zustand (kein Treffer)", () => { + const p = project(); + const ctx = makeCtx(p); + const [state, result] = chamferCommand.onInput( + chamferCommand.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"); + }); + + it("Distanz zu gross für die Schenkellänge → No-op (kein commit-Effekt)", () => { + const p = project(); + const ctx = makeCtx(p); + const [afterPick] = chamferCommand.onInput( + chamferCommand.init(), + { kind: "point", point: { x: 2, y: 0 } }, + ctx, + ); + // Schenkel sind 2m lang; 10m Distanz passt auf keinen der beiden. + const [, r2] = chamferCommand.onInput(afterPick, { kind: "number", value: 10 }, ctx); + expect(r2.commit).toBeDefined(); + const next = r2.commit!(p); + expect(next).toBe(p); // unverändert + }); +}); diff --git a/src/commands/cmds/chamfer.ts b/src/commands/cmds/chamfer.ts new file mode 100644 index 0000000..9cb3f83 --- /dev/null +++ b/src/commands/cmds/chamfer.ts @@ -0,0 +1,210 @@ +// Chamfer — fast die Ecke einer 2D-Kurve (polyline/rect) mit einer geraden +// Abschrägung (Rhino/AutoCAD-artig, Gegenstück zu Fillet ohne Bogen). +// Gleicher Aufbau wie fillet.ts (siehe dort für die ausführlichere +// Begründung), nur mit `chamferCorner` statt `filletCorner`: +// 1) „Ecke wählen:" → Punkt (nächstgelegener Eckpunkt einer polyline/rect) +// 2) „Distanz:" → Punkt (Abstand von der Ecke) ODER getippte Zahl → commit +// +// Anders als Fillet braucht Chamfer KEINE Bogen-Tessellierung — die Fasung +// ist von Natur aus gerade, also ein exaktes (nicht approximiertes) Ergebnis. + +import type { Drawing2D, Drawing2DGeom } from "../../model/types"; +import { chamferCorner, type Chamfer } 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_DISTANCE = 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 chamfer-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 chamfer-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 chamfer-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; +} + +/** Fasungs-Geometrie für einen Treffer + Distanz (oder null, falls unmöglich). */ +function chamferFor(hit: CornerHit, distance: number): Chamfer | 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 chamferCorner(corner, prev, next, distance); +} + +/** Ersetzt den Eckpunkt durch die beiden Fasungs-Punkte (Schenkel↔Schenkel). */ +function applyChamferPoints(hit: CornerHit, c: Chamfer): Vec2[] { + return [ + ...hit.curve.pts.slice(0, hit.index), + c.a, + c.b, + ...hit.curve.pts.slice(hit.index + 1), + ]; +} + +function chamferDraft(hit: CornerHit, d: number, at: Vec2 | null): ToolDraft { + const c = d > EPS ? chamferFor(hit, d) : null; + const preview: DraftShape[] = c ? [{ kind: "poly", pts: [c.a, c.b], closed: false }] : []; + const draft: ToolDraft = { preview, vertices: [hit.curve.pts[hit.index]] }; + if (at) draft.hud = { at, text: `D: ${d.toFixed(3)}m${c ? "" : " ✗"}` }; + return draft; +} + +function commitChamfer(p: Project, hit: CornerHit, distance: number): Project { + const c = chamferFor(hit, distance); + if (!c) return p; // Distanz zu gross für die Schenkellänge, oder Ecke (fast) gerade → No-op + const pts = applyChamferPoints(hit, c); + 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 DISTANCE_FIELDS: CommandField[] = [{ id: "distance", labelKey: "cmd.field.distance" }]; + +interface ChamferPick extends CommandState { + phase: "pick"; +} +interface ChamferDistance extends CommandState { + phase: "distance"; + hit: CornerHit; + distance: number; +} +interface ChamferDone extends CommandState { + phase: "done"; +} +type ChamferState = ChamferPick | ChamferDistance | ChamferDone; + +const finish = (): [CommandState, CommandResult] => [ + { phase: "done", lastPoint: null } as ChamferDone, + { draft: null, done: true }, +]; + +export const chamferCommand: Command = { + name: "chamfer", + labelKey: "cmd.chamfer.label", + prompt: (s) => + (s as ChamferState).phase === "distance" ? "cmd.chamfer.distance" : "cmd.chamfer.pick", + accepts: (s) => ((s as ChamferState).phase === "distance" ? ["point", "number"] : ["point"]), + options: () => [], + fields: (s) => ((s as ChamferState).phase === "distance" ? DISTANCE_FIELDS : []), + + init: (): ChamferPick => ({ phase: "pick", lastPoint: null }), + + onInput: (state, input, ctx: CommandContext): [CommandState, CommandResult] => { + const s = state as ChamferState; + 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: ChamferDistance = { + phase: "distance", + hit, + distance: DEFAULT_DISTANCE, + lastPoint: input.point, + }; + return [next, { draft: chamferDraft(hit, next.distance, input.point) }]; + } + // phase === "distance" + let d = s.distance; + if (input.kind === "number") d = Math.max(0, input.value); + else if (input.kind === "point") d = Math.max(0, dist(s.hit.curve.pts[s.hit.index], input.point)); + else return [s, { draft: null }]; + return [ + { phase: "done", lastPoint: null } as ChamferDone, + { draft: null, done: true, commit: (p) => commitChamfer(p, s.hit, d) }, + ]; + }, + + onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => { + const s = state as ChamferState; + if (s.phase === "pick") { + const hit = pickCornerAt(ctx.project, ctx.level.id, point); + if (!hit) return [s, { draft: null }]; + return [s, { draft: chamferDraft(hit, DEFAULT_DISTANCE, point) }]; + } + if (s.phase !== "distance") return [s, { draft: null }]; + const d = Math.max(0, dist(s.hit.curve.pts[s.hit.index], point)); + const ns: ChamferDistance = { ...s, distance: d }; + return [ns, { draft: chamferDraft(s.hit, d, point) }]; + }, + + onConfirm: (): [CommandState, CommandResult] => finish(), + onCancel: (): [CommandState, CommandResult] => finish(), + + // Tab-Feld-Zyklus nur im „distance"-Schritt, analog zu fillet.ts/circle.ts. + fieldValues: (state, _locks, cursor): Record => { + const s = state as ChamferState; + if (s.phase !== "distance" || !cursor) return {}; + return { distance: dist(s.hit.curve.pts[s.hit.index], cursor) }; + }, + pointFromFields: (state, locks, cursor) => { + const s = state as ChamferState; + if (s.phase !== "distance") return null; + const corner = s.hit.curve.pts[s.hit.index]; + const d = "distance" in locks ? Math.max(0, locks.distance) : cursor ? dist(corner, cursor) : 0; + if (cursor) { + const dd = dist(corner, cursor); + if (dd > EPS) { + return { + x: corner.x + ((cursor.x - corner.x) / dd) * d, + y: corner.y + ((cursor.y - corner.y) / dd) * d, + }; + } + } + return { x: corner.x + d, y: corner.y }; + }, +}; diff --git a/src/commands/registry.ts b/src/commands/registry.ts index a7bf2b8..c8e481b 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -28,6 +28,7 @@ import { extrudeCommand } from "./cmds/extrude"; import { trimCommand } from "./cmds/trim"; import { extendCommand } from "./cmds/extend"; import { filletCommand } from "./cmds/fillet"; +import { chamferCommand } from "./cmds/chamfer"; import { importCommand } from "./cmds/import"; import { terrainCommand } from "./cmds/terrain"; import { measureCommand } from "./cmds/measure"; @@ -62,6 +63,7 @@ export const COMMANDS: Record = { trim: trimCommand, extend: extendCommand, fillet: filletCommand, + chamfer: chamferCommand, import: importCommand, terrain: terrainCommand, measure: measureCommand, @@ -117,6 +119,8 @@ export const ALIASES: Record = { verlängern: "extend", fi: "fillet", verrunden: "fillet", + ch: "chamfer", + fasen: "chamfer", imp: "import", ter: "terrain", schnittlinie: "sectionline", diff --git a/src/geometry/kernel2d.ts b/src/geometry/kernel2d.ts index 8d52d02..3ba2619 100644 --- a/src/geometry/kernel2d.ts +++ b/src/geometry/kernel2d.ts @@ -572,6 +572,44 @@ export function filletCorner( return { center, radius: r, tangentA, tangentB, startAngle, endAngle }; } +/** Ergebnis einer Eck-Fasung. */ +export interface Chamfer { + /** Punkt auf dem ersten Schenkel (corner→p1), im Abstand `distance` vom Eck. */ + a: Vec2; + /** Punkt auf dem zweiten Schenkel (corner→p2), im Abstand `distance` vom Eck. */ + b: Vec2; +} + +/** + * Fast die Ecke bei `corner` (Schenkel corner→p1, corner→p2) mit einer geraden + * Abschrägung: gleicher Abstand `distance` vom Eck auf BEIDEN Schenkeln, + * verbunden durch eine gerade Linie (klassischer Gleichabstands-Chamfer, wie + * `filletCorner`, nur ohne Bogen — die Ecke fällt weg, der Schnitt bleibt + * gerade). Liefert `null` bei (nahezu) kollinearen Schenkeln oder wenn + * `distance` länger als ein Schenkel ist. + */ +export function chamferCorner( + corner: Vec2, + p1: Vec2, + p2: Vec2, + distance: number, +): Chamfer | null { + const u1 = normalize(sub(p1, corner)); + const u2 = normalize(sub(p2, corner)); + const cosTheta = Math.max(-1, Math.min(1, dot(u1, u2))); + const theta = Math.acos(cosTheta); + if (theta < 1e-4 || Math.PI - theta < 1e-4) return null; // kollinear + if ( + distance > len(sub(p1, corner)) + EPS || + distance > len(sub(p2, corner)) + EPS + ) { + return null; + } + const a = add(corner, scale(u1, distance)); + const b = add(corner, scale(u2, distance)); + return { a, b }; +} + // ── Split / Join / Segment-Löschen (Drawing2D-Editieren) ───────────────────── // Reine Geometrie auf Polylinien (`Vec2[]` + `closed`). Die Operations-Schicht // (`src/editors/splitJoin.ts`) wandelt Drawing2D ↔ {pts, closed} und ruft diese diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 0d4b16b..f1b13b4 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -1189,6 +1189,10 @@ export const de = { "cmd.fillet.label": "Verrunden", "cmd.fillet.pick": "Ecke wählen:", "cmd.fillet.radius": "Radius:", + // Chamfer: Eckpunkt-Fasung mit Distanz (Maus oder getippt) — wie Fillet, ohne Bogen. + "cmd.chamfer.label": "Fasen", + "cmd.chamfer.pick": "Ecke wählen:", + "cmd.chamfer.distance": "Distanz:", // 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 10cee85..e50f384 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1177,6 +1177,10 @@ export const en: Record = { "cmd.fillet.label": "Fillet", "cmd.fillet.pick": "Select corner:", "cmd.fillet.radius": "Radius:", + // Chamfer: corner bevel with a distance (mouse or typed) — like Fillet, no arc. + "cmd.chamfer.label": "Chamfer", + "cmd.chamfer.pick": "Select corner:", + "cmd.chamfer.distance": "Distance:", // 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 30b4f47..1a6d617 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", "extend", "fillet", "join", "measure"]; +const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "trim", "extend", "fillet", "chamfer", "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 3dd5365..c568beb 100644 --- a/src/ui/ribbon/CommandIcon.tsx +++ b/src/ui/ribbon/CommandIcon.tsx @@ -98,6 +98,13 @@ export function CommandIcon({ name }: { name: string }) { ); + case "chamfer": + // Eckpunkt-Fasung: gerade Schenkel, Ecke durch eine gerade Schräge ersetzt. + return ( + + + + ); case "extend": // Linie, die bis zu einer Grenze verlängert wird (Pfeil + Ziellinie). return (