2D: Multiline-Werkzeug (Basislinie + N-1 parallele Kopien in einer Geste)

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.
This commit is contained in:
2026-08-20 21:07:21 +02:00
parent 245b7d7fe1
commit 4f48b73267
7 changed files with 354 additions and 1 deletions
+227
View File
@@ -0,0 +1,227 @@
// 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) };
}