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:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* `multilineCommand` — zeichnet eine Basislinie + N-1 parallele Kopien in
|
||||
* einer Geste (baut auf `offsetSegment` auf, wie der bestehende `offset`-
|
||||
* Befehl). Test: Start → Ende → Abstand (getippt) → commit erzeugt die
|
||||
* erwartete Anzahl paralleler Linien.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { multilineCommand } from "./multiline";
|
||||
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: [],
|
||||
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("multilineCommand", () => {
|
||||
it("Basislinie + 2 Kopien (Default-Anzahl 3) bei getipptem Abstand", () => {
|
||||
const p = project();
|
||||
const ctx = makeCtx(p);
|
||||
|
||||
const [afterStart] = multilineCommand.onInput(
|
||||
multilineCommand.init(),
|
||||
{ kind: "point", point: { x: 0, y: 0 } },
|
||||
ctx,
|
||||
);
|
||||
const [afterEnd] = multilineCommand.onInput(
|
||||
afterStart,
|
||||
{ kind: "point", point: { x: 10, y: 0 } },
|
||||
ctx,
|
||||
);
|
||||
const [, result] = multilineCommand.onInput(afterEnd, { kind: "number", value: 1 }, ctx);
|
||||
expect(result.commit).toBeDefined();
|
||||
|
||||
const next = result.commit!(p);
|
||||
expect(next.drawings2d.length).toBe(3); // Basislinie + 2 Kopien (Default-Count)
|
||||
for (const d of next.drawings2d) {
|
||||
expect(d.geom.shape).toBe("line");
|
||||
if (d.geom.shape === "line") {
|
||||
// Alle Linien laufen horizontal von x=0 bis x=10 (nur y verschoben).
|
||||
expect(d.geom.a.x).toBeCloseTo(0, 9);
|
||||
expect(d.geom.b.x).toBeCloseTo(10, 9);
|
||||
expect(d.geom.a.y).toBeCloseTo(d.geom.b.y, 9);
|
||||
}
|
||||
}
|
||||
// Die y-Werte sind 0, ±1, ±2 (je nach Seite) — auf jeden Fall drei
|
||||
// unterschiedliche Werte im Abstand von genau 1m.
|
||||
const ys = next.drawings2d
|
||||
.map((d) => (d.geom.shape === "line" ? d.geom.a.y : NaN))
|
||||
.sort((a, b) => a - b);
|
||||
expect(ys[1] - ys[0]).toBeCloseTo(1, 9);
|
||||
expect(ys[2] - ys[1]).toBeCloseTo(1, 9);
|
||||
});
|
||||
|
||||
it("Null-Strecke (Start=Ende) → No-op, Befehl beendet ohne Elemente", () => {
|
||||
const p = project();
|
||||
const ctx = makeCtx(p);
|
||||
const [afterStart] = multilineCommand.onInput(
|
||||
multilineCommand.init(),
|
||||
{ kind: "point", point: { x: 5, y: 5 } },
|
||||
ctx,
|
||||
);
|
||||
const [, result] = multilineCommand.onInput(
|
||||
afterStart,
|
||||
{ kind: "point", point: { x: 5, y: 5 } },
|
||||
ctx,
|
||||
);
|
||||
expect(result.done).toBe(true);
|
||||
expect(result.commit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { trimCommand } from "./cmds/trim";
|
||||
import { extendCommand } from "./cmds/extend";
|
||||
import { filletCommand } from "./cmds/fillet";
|
||||
import { chamferCommand } from "./cmds/chamfer";
|
||||
import { multilineCommand } from "./cmds/multiline";
|
||||
import { importCommand } from "./cmds/import";
|
||||
import { terrainCommand } from "./cmds/terrain";
|
||||
import { measureCommand } from "./cmds/measure";
|
||||
@@ -64,6 +65,7 @@ export const COMMANDS: Record<string, Command> = {
|
||||
extend: extendCommand,
|
||||
fillet: filletCommand,
|
||||
chamfer: chamferCommand,
|
||||
multiline: multilineCommand,
|
||||
import: importCommand,
|
||||
terrain: terrainCommand,
|
||||
measure: measureCommand,
|
||||
@@ -121,6 +123,8 @@ export const ALIASES: Record<string, string> = {
|
||||
verrunden: "fillet",
|
||||
ch: "chamfer",
|
||||
fasen: "chamfer",
|
||||
ml: "multiline",
|
||||
parallel: "multiline",
|
||||
imp: "import",
|
||||
ter: "terrain",
|
||||
schnittlinie: "sectionline",
|
||||
|
||||
Reference in New Issue
Block a user