167 lines
5.4 KiB
TypeScript
167 lines
5.4 KiB
TypeScript
// Arc — Mittelpunkt + Startpunkt + Endpunkt. Schritte:
|
||
// 1) „Mittelpunkt:" → Punkt
|
||
// 2) „Startpunkt:" → Punkt (setzt Radius r = |P1−Center| UND Startwinkel a0)
|
||
// 3) „Endpunkt:" → Punkt (setzt Endwinkel a1; Radius bleibt) → commit
|
||
//
|
||
// Der Bogen läuft CCW von a0 nach a1 (wie DXF-ARC und das `{shape:"arc"}`-
|
||
// Rendering in PlanView). Der Endpunkt wird nur für seinen WINKEL genutzt (auf
|
||
// den Kreis mit Radius r projiziert). In generatePlan/PlanView als glatter
|
||
// SVG-Bogen (`drawingArc`) gerendert.
|
||
|
||
import type { Drawing2D } from "../../model/types";
|
||
import { uniqueId } from "../../tools/types";
|
||
import type {
|
||
Command,
|
||
CommandContext,
|
||
CommandResult,
|
||
CommandState,
|
||
DraftShape,
|
||
Project,
|
||
ToolDraft,
|
||
Vec2,
|
||
} from "../types";
|
||
|
||
const EPS = 1e-6;
|
||
|
||
interface ArcCenter extends CommandState {
|
||
phase: "center";
|
||
}
|
||
interface ArcStart extends CommandState {
|
||
phase: "start";
|
||
center: Vec2;
|
||
cursor: Vec2 | null;
|
||
}
|
||
interface ArcEnd extends CommandState {
|
||
phase: "end";
|
||
center: Vec2;
|
||
r: number;
|
||
a0: number;
|
||
cursor: Vec2 | null;
|
||
}
|
||
type ArcState = ArcCenter | ArcStart | ArcEnd;
|
||
|
||
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||
const angle = (c: Vec2, p: Vec2): number => Math.atan2(p.y - c.y, p.x - c.x);
|
||
|
||
/** CCW-Spanne von a0 nach a1, normiert auf (0, 2π]. */
|
||
function sweepOf(a0: number, a1: number): number {
|
||
const TAU = Math.PI * 2;
|
||
const s = ((a1 - a0) % TAU + TAU) % TAU;
|
||
return s < EPS ? TAU : s;
|
||
}
|
||
|
||
/** Punkte eines Bogens (CCW a0→a1) für die Vorschau. */
|
||
function arcPts(center: Vec2, r: number, a0: number, a1: number): Vec2[] {
|
||
const sweep = sweepOf(a0, a1);
|
||
const N = Math.max(2, Math.ceil(sweep / (Math.PI / 32)));
|
||
const pts: Vec2[] = [];
|
||
for (let i = 0; i <= N; i++) {
|
||
const t = a0 + (sweep * i) / N;
|
||
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
/** Punkte einer vollen Kreis-Approximation (Radius-Vorschau im „start"-Schritt). */
|
||
function circlePts(center: Vec2, r: number): Vec2[] {
|
||
const N = 48;
|
||
const pts: Vec2[] = [];
|
||
for (let i = 0; i < N; i++) {
|
||
const t = (i / N) * Math.PI * 2;
|
||
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
/** Vorschau (offener Bogen) + HUD (Spanne in Grad). */
|
||
function arcDraft(center: Vec2, r: number, a0: number, a1: number, at: Vec2 | null): ToolDraft {
|
||
if (r < EPS) return { preview: [], vertices: [center] };
|
||
const preview: DraftShape[] = [{ kind: "poly", pts: arcPts(center, r, a0, a1), closed: false }];
|
||
const draft: ToolDraft = { preview, vertices: [center] };
|
||
if (at) {
|
||
const deg = (sweepOf(a0, a1) * 180) / Math.PI;
|
||
draft.hud = { at, text: `${deg.toFixed(0)}°` };
|
||
}
|
||
return draft;
|
||
}
|
||
|
||
function appendArc(p: Project, center: Vec2, r: number, a0: number, a1: number, ctx: CommandContext): Project {
|
||
if (r < EPS) return p;
|
||
const d: Drawing2D = {
|
||
id: uniqueId("dr2d"),
|
||
type: "drawing2d",
|
||
levelId: ctx.level.id,
|
||
categoryCode: ctx.defaultCategoryCode,
|
||
geom: { shape: "arc", center, r, a0, a1 },
|
||
};
|
||
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||
}
|
||
|
||
const idle = (): [CommandState, CommandResult] => [
|
||
{ phase: "center", lastPoint: null },
|
||
{ draft: null, done: true },
|
||
];
|
||
|
||
export const arcCommand: Command = {
|
||
name: "arc",
|
||
labelKey: "cmd.arc.label",
|
||
prompt: (s) => {
|
||
const phase = (s as ArcState).phase;
|
||
return phase === "start" ? "cmd.arc.start" : phase === "end" ? "cmd.arc.end" : "cmd.arc.center";
|
||
},
|
||
accepts: () => ["point"],
|
||
options: () => [],
|
||
init: (): ArcCenter => ({ phase: "center", lastPoint: null }),
|
||
|
||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||
const s = state as ArcState;
|
||
if (input.kind !== "point") return [s, { draft: null }];
|
||
const pt = input.point;
|
||
if (s.phase === "center") {
|
||
const ns: ArcStart = { phase: "start", center: pt, cursor: pt, lastPoint: pt };
|
||
return [ns, { draft: { preview: [], vertices: [pt] } }];
|
||
}
|
||
if (s.phase === "start") {
|
||
const r = dist(s.center, pt);
|
||
if (r < EPS) return [s, { draft: null }];
|
||
const a0 = angle(s.center, pt);
|
||
const ns: ArcEnd = { phase: "end", center: s.center, r, a0, cursor: pt, lastPoint: pt };
|
||
return [ns, { draft: arcDraft(s.center, r, a0, a0, pt) }];
|
||
}
|
||
// Endpunkt: Endwinkel setzen, Bogen committen.
|
||
const a1 = angle(s.center, pt);
|
||
const { center, r, a0 } = s;
|
||
return [
|
||
{ phase: "center", lastPoint: null },
|
||
{ draft: null, done: true, commit: (p) => appendArc(p, center, r, a0, a1, ctx) },
|
||
];
|
||
},
|
||
|
||
onMove: (state, point): [CommandState, CommandResult] => {
|
||
const s = state as ArcState;
|
||
if (s.phase === "start") {
|
||
const r = dist(s.center, point);
|
||
const ns: ArcStart = { ...s, cursor: point };
|
||
return [
|
||
ns,
|
||
{
|
||
draft: {
|
||
preview: [{ kind: "poly", pts: circlePts(s.center, r), closed: true }],
|
||
vertices: [s.center],
|
||
hud: { at: point, text: `r ${r.toFixed(2)} m` },
|
||
},
|
||
},
|
||
];
|
||
}
|
||
if (s.phase === "end") {
|
||
const a1 = angle(s.center, point);
|
||
const ns: ArcEnd = { ...s, cursor: point };
|
||
return [ns, { draft: arcDraft(s.center, s.r, s.a0, a1, point) }];
|
||
}
|
||
return [s, { draft: null }];
|
||
},
|
||
|
||
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||
onCancel: (): [CommandState, CommandResult] => idle(),
|
||
};
|