4f48b73267
Neuer Befehl multiline.ts, baut auf derselben Offset-Geometrie auf wie der bestehende offset-Befehl (offsetSegment) — erzeugt aber alle Kopien auf einmal statt einzeln nacheinander offsetten zu müssen. Deckt gerade Basislinien ab (häufigster Fall: Grenzlinie+Abstand, Leitungspaar, Randlinien), keine mehrsegmentige Variante. Ablauf: Start-/Endpunkt (Tab-Felder Länge/Winkel wie line.ts), dann Abstand per Maus-Klick (Seite bestimmt Vorzeichen, wie bei offset.ts) oder getippt; die Anzahl (Default 3, Bereich 2-6) zyklt über eine Inline-Option und bleibt wie Abstand/Seite über Aufrufe hinweg persistent. In Registry/Aliase (ml/parallel), ToolsPanel (COMMANDS_EDIT neben Offset), CommandIcon, i18n de/en aufgenommen. 2 neue Tests. tsc -b / vitest run (916/916) / npm run build grün.
228 lines
8.9 KiB
TypeScript
228 lines
8.9 KiB
TypeScript
// Multiline — zeichnet in EINER Geste eine gerade Basislinie PLUS N−1 dazu
|
||
// parallel versetzte Kopien (gleicher Abstand, gleiche Seite), z. B. für
|
||
// Doppellinien/Leitungstrassen/Abstandslinien. Baut auf derselben Offset-
|
||
// Geometrie wie der bestehende `offset`-Befehl auf (`offsetSegment`), erzeugt
|
||
// aber alle Kopien auf einmal statt einzeln nacheinander offsetten zu müssen.
|
||
// Schritte:
|
||
// 1) „Startpunkt:" → Punkt
|
||
// 2) „Endpunkt:" → Punkt (Tab-Felder Länge/Winkel wie bei Line)
|
||
// 3) „Abstand/Seite:" → Distanz tippen (persistent über Aufrufe, wie bei
|
||
// Offset) ODER Seite per Klick (Vorzeichen aus der Klickseite) → commit
|
||
// ALLER `count` Linien (Basislinie + count−1 Kopien bei d, 2d, …).
|
||
// Die Anzahl `count` zyklt über eine Inline-Option (2..6, dann zurück zu 2).
|
||
//
|
||
// Reine gerade Basislinien (kein Polylinien-Multiline) — deckt den häufigsten
|
||
// Anwendungsfall ab (Grenzlinie+Abstand, Leitungspaar, Randlinien), ohne die
|
||
// Komplexität einer mehrsegmentigen Variante.
|
||
|
||
import type { Drawing2D } from "../../model/types";
|
||
import { segmentHud, uniqueId } from "../../tools/types";
|
||
import { leftNormal, normalize, sub } from "../../model/geometry";
|
||
import { offsetSegment } from "../../geometry/kernel2d";
|
||
import type {
|
||
CmdOption,
|
||
Command,
|
||
CommandContext,
|
||
CommandField,
|
||
CommandResult,
|
||
CommandState,
|
||
DraftShape,
|
||
Project,
|
||
ToolDraft,
|
||
Vec2,
|
||
} from "../types";
|
||
|
||
const EPS = 1e-6;
|
||
const DEG = Math.PI / 180;
|
||
const MIN_COUNT = 2;
|
||
const MAX_COUNT = 6;
|
||
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||
|
||
/** Persistente Einstellungen über Aufrufe hinweg (wie `lastDistance` bei Offset). */
|
||
let lastSpacing = 0.3;
|
||
let lastSign = 1; // +1 = links, −1 = rechts
|
||
let lastCount = 3;
|
||
|
||
const LINE_FIELDS: CommandField[] = [
|
||
{ id: "length", labelKey: "cmd.field.length" },
|
||
{ id: "angle", labelKey: "cmd.field.angle" },
|
||
];
|
||
|
||
function endFromFields(a: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
|
||
const ref = cursor ?? a;
|
||
const length = "length" in locks ? locks.length : segLen(a, ref);
|
||
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(a, ref);
|
||
const ang = angleDeg * DEG;
|
||
return { x: a.x + Math.cos(ang) * length, y: a.y + Math.sin(ang) * length };
|
||
}
|
||
|
||
/** Vorzeichen (+links / −rechts) für einen Klickpunkt relativ zur Basislinie a→b. */
|
||
function sideSign(a: Vec2, b: Vec2, pt: Vec2): number {
|
||
const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||
const u = normalize(sub(b, a));
|
||
const n = leftNormal(u);
|
||
const rel = sub(pt, mid);
|
||
return rel.x * n.x + rel.y * n.y >= 0 ? 1 : -1;
|
||
}
|
||
|
||
/** Die `count` parallelen Linien (Index 0 = Basislinie) als Punktpaare. */
|
||
function multilinePairs(a: Vec2, b: Vec2, spacing: number, sign: number, count: number): [Vec2, Vec2][] {
|
||
const out: [Vec2, Vec2][] = [[a, b]];
|
||
for (let k = 1; k < count; k++) {
|
||
out.push(offsetSegment(a, b, sign * spacing * k));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function appendMultiline(p: Project, a: Vec2, b: Vec2, spacing: number, sign: number, count: number, ctx: CommandContext): Project {
|
||
if (segLen(a, b) < EPS) return p;
|
||
const pairs = multilinePairs(a, b, spacing, sign, count);
|
||
const copies: Drawing2D[] = pairs.map(([pa, pb]) => ({
|
||
id: uniqueId("dr2d"),
|
||
type: "drawing2d",
|
||
levelId: ctx.level.id,
|
||
categoryCode: ctx.defaultCategoryCode,
|
||
geom: { shape: "line", a: pa, b: pb },
|
||
}));
|
||
return { ...p, drawings2d: [...p.drawings2d, ...copies] };
|
||
}
|
||
|
||
const COUNT_OPTION: CmdOption = { id: "count", labelKey: "cmd.multiline.count" };
|
||
|
||
// ── Zustand ───────────────────────────────────────────────────────────────────
|
||
interface MlStart extends CommandState {
|
||
phase: "start";
|
||
}
|
||
interface MlEnd extends CommandState {
|
||
phase: "end";
|
||
a: Vec2;
|
||
cursor: Vec2 | null;
|
||
}
|
||
interface MlSpacing extends CommandState {
|
||
phase: "spacing";
|
||
a: Vec2;
|
||
b: Vec2;
|
||
cursor: Vec2 | null;
|
||
}
|
||
type MlState = MlStart | MlEnd | MlSpacing;
|
||
|
||
const idle = (): [CommandState, CommandResult] => [
|
||
{ phase: "start", lastPoint: null } as MlStart,
|
||
{ draft: null, done: true },
|
||
];
|
||
|
||
/** Vorschau aller `lastCount` Linien bei gegebenem Abstand+Seite. */
|
||
function spacingDraft(a: Vec2, b: Vec2, spacing: number, sign: number, at: Vec2 | null): ToolDraft {
|
||
const pairs = multilinePairs(a, b, spacing, sign, lastCount);
|
||
const preview: DraftShape[] = pairs.map(([pa, pb]) => ({ kind: "line", a: pa, b: pb }));
|
||
const draft: ToolDraft = { preview, vertices: [a, b] };
|
||
if (at) draft.hud = { at, text: `${spacing.toFixed(2)} m × ${lastCount}` };
|
||
return draft;
|
||
}
|
||
|
||
export const multilineCommand: Command = {
|
||
name: "multiline",
|
||
labelKey: "cmd.multiline.label",
|
||
prompt: (s) => {
|
||
const ms = s as MlState;
|
||
if (ms.phase === "end") return "cmd.multiline.end";
|
||
if (ms.phase === "spacing") return "cmd.multiline.spacing";
|
||
return "cmd.multiline.start";
|
||
},
|
||
accepts: (s) => ((s as MlState).phase === "spacing" ? ["point", "number", "option"] : ["point"]),
|
||
options: (s) => ((s as MlState).phase === "spacing" ? [{ ...COUNT_OPTION, value: String(lastCount) }] : []),
|
||
|
||
init: (): MlStart => ({ phase: "start", lastPoint: null }),
|
||
|
||
onInput: (state, input, ctx: CommandContext): [CommandState, CommandResult] => {
|
||
const s = state as MlState;
|
||
|
||
if (s.phase === "start") {
|
||
if (input.kind !== "point") return [s, { draft: null }];
|
||
const next: MlEnd = { phase: "end", a: input.point, cursor: input.point, lastPoint: input.point };
|
||
return [next, { draft: { preview: [], vertices: [input.point] } }];
|
||
}
|
||
|
||
if (s.phase === "end") {
|
||
if (input.kind !== "point") return [s, { draft: spacingDraftForEnd(s) }];
|
||
if (segLen(s.a, input.point) < EPS) return idle();
|
||
const next: MlSpacing = { phase: "spacing", a: s.a, b: input.point, cursor: input.point, lastPoint: input.point };
|
||
return [next, { draft: spacingDraft(next.a, next.b, lastSpacing, lastSign, input.point) }];
|
||
}
|
||
|
||
// phase === "spacing"
|
||
if (input.kind === "option") {
|
||
if (input.id === "count") {
|
||
lastCount = lastCount >= MAX_COUNT ? MIN_COUNT : lastCount + 1;
|
||
const sign = s.cursor ? sideSign(s.a, s.b, s.cursor) : lastSign;
|
||
return [s, { draft: spacingDraft(s.a, s.b, lastSpacing, sign, s.cursor) }];
|
||
}
|
||
return [s, { draft: null }];
|
||
}
|
||
if (input.kind === "number") {
|
||
const mag = Math.abs(input.value);
|
||
if (mag < EPS) return [s, { draft: null }];
|
||
lastSpacing = mag;
|
||
lastSign = input.value < 0 ? -1 : lastSign;
|
||
const { a, b } = s;
|
||
return [
|
||
idle()[0],
|
||
{ draft: null, done: true, commit: (p) => appendMultiline(p, a, b, lastSpacing, lastSign, lastCount, ctx) },
|
||
];
|
||
}
|
||
if (input.kind === "point") {
|
||
lastSign = sideSign(s.a, s.b, input.point);
|
||
const { a, b } = s;
|
||
return [
|
||
idle()[0],
|
||
{ draft: null, done: true, commit: (p) => appendMultiline(p, a, b, lastSpacing, lastSign, lastCount, ctx) },
|
||
];
|
||
}
|
||
return [s, { draft: null }];
|
||
},
|
||
|
||
onMove: (state, point): [CommandState, CommandResult] => {
|
||
const s = state as MlState;
|
||
if (s.phase === "end") {
|
||
const next: MlEnd = { ...s, cursor: point };
|
||
const preview: DraftShape[] = segLen(s.a, point) >= EPS ? [{ kind: "line", a: s.a, b: point }] : [];
|
||
const draft: ToolDraft = { preview, vertices: [s.a] };
|
||
if (segLen(s.a, point) >= EPS) draft.hud = segmentHud(s.a, point);
|
||
return [next, { draft }];
|
||
}
|
||
if (s.phase === "spacing") {
|
||
const sign = sideSign(s.a, s.b, point);
|
||
const next: MlSpacing = { ...s, cursor: point };
|
||
return [next, { draft: spacingDraft(s.a, s.b, lastSpacing, sign, point) }];
|
||
}
|
||
return [s, { draft: null }];
|
||
},
|
||
|
||
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||
onCancel: (): [CommandState, CommandResult] => idle(),
|
||
|
||
// Tab-Feld-Zyklus nur im „end"-Schritt (Basislinie): Länge + Winkel, wie line.ts.
|
||
fields: (state) => ((state as MlState).phase === "end" ? LINE_FIELDS : []),
|
||
pointFromFields: (state, locks, cursor) => {
|
||
const s = state as MlState;
|
||
if (s.phase !== "end") return null;
|
||
return endFromFields(s.a, locks, cursor);
|
||
},
|
||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||
const s = state as MlState;
|
||
if (s.phase !== "end" || !cursor) return {};
|
||
return {
|
||
length: segLen(s.a, cursor),
|
||
angle: ((segAngleDeg(s.a, cursor) % 360) + 360) % 360,
|
||
};
|
||
},
|
||
};
|
||
|
||
/** Vorschau der Basislinie im „end"-Schritt (Fallback für Nicht-Punkt-Eingaben). */
|
||
function spacingDraftForEnd(s: MlEnd): ToolDraft {
|
||
if (!s.cursor || segLen(s.a, s.cursor) < EPS) return { preview: [], vertices: [s.a] };
|
||
return { preview: [{ kind: "line", a: s.a, b: s.cursor }], vertices: [s.a], hud: segmentHud(s.a, s.cursor) };
|
||
}
|