2D: Chamfer (Eckpunkt-Fasung) neu gebaut und verdrahtet
Anders als Fillet/Extend existierte hierfür noch gar keine Geometrie — chamferCorner (src/geometry/kernel2d.ts) ist neu: gleicher Abstand vom Eck auf beiden Schenkeln, gerade verbunden (klassischer Gleichabstands-Chamfer, analog zu filletCorner, nur ohne Bogen — daher exakt statt tesselliert). commands/cmds/chamfer.ts wortwörtlich nach dem fillet.ts-Muster: Ecke wählen, Distanz per Maus oder getippt (Tab-Feld). In Registry/Aliase (ch/fasen), ToolsPanel (COMMANDS_EDIT neben Fillet), CommandIcon, i18n de/en aufgenommen. 3 neue Tests (Fasung, Fehltreffer, Distanz-zu-gross-No-op). tsc -b / vitest run (914/914) / npm run build grün.
This commit is contained in:
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, number> => {
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -28,6 +28,7 @@ import { extrudeCommand } from "./cmds/extrude";
|
|||||||
import { trimCommand } from "./cmds/trim";
|
import { trimCommand } from "./cmds/trim";
|
||||||
import { extendCommand } from "./cmds/extend";
|
import { extendCommand } from "./cmds/extend";
|
||||||
import { filletCommand } from "./cmds/fillet";
|
import { filletCommand } from "./cmds/fillet";
|
||||||
|
import { chamferCommand } from "./cmds/chamfer";
|
||||||
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";
|
||||||
@@ -62,6 +63,7 @@ export const COMMANDS: Record<string, Command> = {
|
|||||||
trim: trimCommand,
|
trim: trimCommand,
|
||||||
extend: extendCommand,
|
extend: extendCommand,
|
||||||
fillet: filletCommand,
|
fillet: filletCommand,
|
||||||
|
chamfer: chamferCommand,
|
||||||
import: importCommand,
|
import: importCommand,
|
||||||
terrain: terrainCommand,
|
terrain: terrainCommand,
|
||||||
measure: measureCommand,
|
measure: measureCommand,
|
||||||
@@ -117,6 +119,8 @@ export const ALIASES: Record<string, string> = {
|
|||||||
verlängern: "extend",
|
verlängern: "extend",
|
||||||
fi: "fillet",
|
fi: "fillet",
|
||||||
verrunden: "fillet",
|
verrunden: "fillet",
|
||||||
|
ch: "chamfer",
|
||||||
|
fasen: "chamfer",
|
||||||
imp: "import",
|
imp: "import",
|
||||||
ter: "terrain",
|
ter: "terrain",
|
||||||
schnittlinie: "sectionline",
|
schnittlinie: "sectionline",
|
||||||
|
|||||||
@@ -572,6 +572,44 @@ export function filletCorner(
|
|||||||
return { center, radius: r, tangentA, tangentB, startAngle, endAngle };
|
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) ─────────────────────
|
// ── Split / Join / Segment-Löschen (Drawing2D-Editieren) ─────────────────────
|
||||||
// Reine Geometrie auf Polylinien (`Vec2[]` + `closed`). Die Operations-Schicht
|
// Reine Geometrie auf Polylinien (`Vec2[]` + `closed`). Die Operations-Schicht
|
||||||
// (`src/editors/splitJoin.ts`) wandelt Drawing2D ↔ {pts, closed} und ruft diese
|
// (`src/editors/splitJoin.ts`) wandelt Drawing2D ↔ {pts, closed} und ruft diese
|
||||||
|
|||||||
@@ -1189,6 +1189,10 @@ export const de = {
|
|||||||
"cmd.fillet.label": "Verrunden",
|
"cmd.fillet.label": "Verrunden",
|
||||||
"cmd.fillet.pick": "Ecke wählen:",
|
"cmd.fillet.pick": "Ecke wählen:",
|
||||||
"cmd.fillet.radius": "Radius:",
|
"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 /
|
// 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",
|
||||||
|
|||||||
@@ -1177,6 +1177,10 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.fillet.label": "Fillet",
|
"cmd.fillet.label": "Fillet",
|
||||||
"cmd.fillet.pick": "Select corner:",
|
"cmd.fillet.pick": "Select corner:",
|
||||||
"cmd.fillet.radius": "Radius:",
|
"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 /
|
// 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", "extend", "fillet", "join", "measure"];
|
const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "trim", "extend", "fillet", "chamfer", "join", "measure"];
|
||||||
|
|
||||||
type ToolsView = "list" | "icons";
|
type ToolsView = "list" | "icons";
|
||||||
const VIEW_STORAGE_KEY = "dossier.toolsPanel.view";
|
const VIEW_STORAGE_KEY = "dossier.toolsPanel.view";
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ export function CommandIcon({ name }: { name: string }) {
|
|||||||
<path d="M3,13 L3,8 Q3,3 8,3 L13,3" />
|
<path d="M3,13 L3,8 Q3,3 8,3 L13,3" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case "chamfer":
|
||||||
|
// Eckpunkt-Fasung: gerade Schenkel, Ecke durch eine gerade Schräge ersetzt.
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M3,13 L3,6 L6,3 L13,3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case "extend":
|
case "extend":
|
||||||
// Linie, die bis zu einer Grenze verlängert wird (Pfeil + Ziellinie).
|
// Linie, die bis zu einer Grenze verlängert wird (Pfeil + Ziellinie).
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user