2D: Fillet und Extend als Befehle verdrahtet (Geometrie existierte bereits)
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.
This commit is contained in:
@@ -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)
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, number> => {
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -26,6 +26,8 @@ import { copyCommand } from "./cmds/copy";
|
|||||||
import { offsetCommand } from "./cmds/offset";
|
import { offsetCommand } from "./cmds/offset";
|
||||||
import { extrudeCommand } from "./cmds/extrude";
|
import { extrudeCommand } from "./cmds/extrude";
|
||||||
import { trimCommand } from "./cmds/trim";
|
import { trimCommand } from "./cmds/trim";
|
||||||
|
import { extendCommand } from "./cmds/extend";
|
||||||
|
import { filletCommand } from "./cmds/fillet";
|
||||||
import { importCommand } from "./cmds/import";
|
import { importCommand } from "./cmds/import";
|
||||||
import { terrainCommand } from "./cmds/terrain";
|
import { terrainCommand } from "./cmds/terrain";
|
||||||
import { measureCommand } from "./cmds/measure";
|
import { measureCommand } from "./cmds/measure";
|
||||||
@@ -58,6 +60,8 @@ export const COMMANDS: Record<string, Command> = {
|
|||||||
offset: offsetCommand,
|
offset: offsetCommand,
|
||||||
extrude: extrudeCommand,
|
extrude: extrudeCommand,
|
||||||
trim: trimCommand,
|
trim: trimCommand,
|
||||||
|
extend: extendCommand,
|
||||||
|
fillet: filletCommand,
|
||||||
import: importCommand,
|
import: importCommand,
|
||||||
terrain: terrainCommand,
|
terrain: terrainCommand,
|
||||||
measure: measureCommand,
|
measure: measureCommand,
|
||||||
@@ -108,6 +112,11 @@ export const ALIASES: Record<string, string> = {
|
|||||||
o: "offset",
|
o: "offset",
|
||||||
ex: "extrude",
|
ex: "extrude",
|
||||||
tr: "trim",
|
tr: "trim",
|
||||||
|
ext: "extend",
|
||||||
|
verlaengern: "extend",
|
||||||
|
verlängern: "extend",
|
||||||
|
fi: "fillet",
|
||||||
|
verrunden: "fillet",
|
||||||
imp: "import",
|
imp: "import",
|
||||||
ter: "terrain",
|
ter: "terrain",
|
||||||
schnittlinie: "sectionline",
|
schnittlinie: "sectionline",
|
||||||
|
|||||||
@@ -1182,6 +1182,13 @@ export const de = {
|
|||||||
// Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend.
|
// Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend.
|
||||||
"cmd.trim.label": "Stutzen",
|
"cmd.trim.label": "Stutzen",
|
||||||
"cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):",
|
"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 /
|
// Editier-Werkzeuge Split / Join / Segment-Löschen (Tastatur: Ctrl+S / Ctrl+J /
|
||||||
// Alt+Klick). Labels für Befehlsleiste/Menüs.
|
// Alt+Klick). Labels für Befehlsleiste/Menüs.
|
||||||
"edit.split.label": "Teilen",
|
"edit.split.label": "Teilen",
|
||||||
|
|||||||
@@ -1170,6 +1170,13 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
// Trim (quick-trim): click the part to cut away; repeats until Esc.
|
// Trim (quick-trim): click the part to cut away; repeats until Esc.
|
||||||
"cmd.trim.label": "Trim",
|
"cmd.trim.label": "Trim",
|
||||||
"cmd.trim.pick": "Click the part to trim away (Esc to finish):",
|
"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 /
|
// Edit tools Split / Join / Delete segment (keyboard: Ctrl+S / Ctrl+J /
|
||||||
// Alt+Click). Labels for the command line / menus.
|
// Alt+Click). Labels for the command line / menus.
|
||||||
"edit.split.label": "Split",
|
"edit.split.label": "Split",
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
|||||||
const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "text", "textbox", "image"];
|
const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "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", "trim", "join", "measure"];
|
const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "trim", "extend", "fillet", "join", "measure"];
|
||||||
|
|
||||||
type ToolsView = "list" | "icons";
|
type ToolsView = "list" | "icons";
|
||||||
const VIEW_STORAGE_KEY = "dossier.toolsPanel.view";
|
const VIEW_STORAGE_KEY = "dossier.toolsPanel.view";
|
||||||
|
|||||||
@@ -91,6 +91,23 @@ export function CommandIcon({ name }: { name: string }) {
|
|||||||
<circle cx="8" cy="13" r="1.4" fill="currentColor" stroke="none" />
|
<circle cx="8" cy="13" r="1.4" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case "fillet":
|
||||||
|
// Eckpunkt-Verrundung: gerade Schenkel, Ecke durch einen Bogen ersetzt.
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M3,13 L3,8 Q3,3 8,3 L13,3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "extend":
|
||||||
|
// Linie, die bis zu einer Grenze verlängert wird (Pfeil + Ziellinie).
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<line x1="2.5" y1="10" x2="9" y2="6" />
|
||||||
|
<line x1="9" y1="6" x2="12.5" y2="3.7" strokeDasharray="1.6 1.6" />
|
||||||
|
<polyline points="10,2.8 13,3 12.8,6" />
|
||||||
|
<line x1="12.5" y1="9.5" x2="12.5" y2="13.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case "column":
|
case "column":
|
||||||
// Stütze: Quadrat-Querschnitt mit vertikalen Kanten (Pfeiler).
|
// Stütze: Quadrat-Querschnitt mit vertikalen Kanten (Pfeiler).
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user