diff --git a/src/commands/cmds/multiline.test.ts b/src/commands/cmds/multiline.test.ts new file mode 100644 index 0000000..220cb5a --- /dev/null +++ b/src/commands/cmds/multiline.test.ts @@ -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(); + }); +}); diff --git a/src/commands/cmds/multiline.ts b/src/commands/cmds/multiline.ts new file mode 100644 index 0000000..c2d8453 --- /dev/null +++ b/src/commands/cmds/multiline.ts @@ -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, 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 => { + 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) }; +} diff --git a/src/commands/registry.ts b/src/commands/registry.ts index c8e481b..15b4200 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -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 = { extend: extendCommand, fillet: filletCommand, chamfer: chamferCommand, + multiline: multilineCommand, import: importCommand, terrain: terrainCommand, measure: measureCommand, @@ -121,6 +123,8 @@ export const ALIASES: Record = { verrunden: "fillet", ch: "chamfer", fasen: "chamfer", + ml: "multiline", + parallel: "multiline", imp: "import", ter: "terrain", schnittlinie: "sectionline", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index f1b13b4..6f07a3b 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -1193,6 +1193,12 @@ export const de = { "cmd.chamfer.label": "Fasen", "cmd.chamfer.pick": "Ecke wählen:", "cmd.chamfer.distance": "Distanz:", + // Multiline: Basislinie + N-1 parallele Kopien in einer Geste (baut auf Offset auf). + "cmd.multiline.label": "Parallellinien", + "cmd.multiline.start": "Startpunkt:", + "cmd.multiline.end": "Endpunkt:", + "cmd.multiline.spacing": "Abstand/Seite (Option Anzahl zyklt die Linienzahl):", + "cmd.multiline.count": "Anzahl", // Editier-Werkzeuge Split / Join / Segment-Löschen (Tastatur: Ctrl+S / Ctrl+J / // Alt+Klick). Labels für Befehlsleiste/Menüs. "edit.split.label": "Teilen", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index e50f384..42577f4 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1181,6 +1181,12 @@ export const en: Record = { "cmd.chamfer.label": "Chamfer", "cmd.chamfer.pick": "Select corner:", "cmd.chamfer.distance": "Distance:", + // Multiline: base line + N-1 parallel copies in one gesture (builds on Offset). + "cmd.multiline.label": "Parallel lines", + "cmd.multiline.start": "Start point:", + "cmd.multiline.end": "End point:", + "cmd.multiline.spacing": "Spacing/side (\"Count\" option cycles line count):", + "cmd.multiline.count": "Count", // Edit tools Split / Join / Delete segment (keyboard: Ctrl+S / Ctrl+J / // Alt+Click). Labels for the command line / menus. "edit.split.label": "Split", diff --git a/src/panels/ToolsPanel.tsx b/src/panels/ToolsPanel.tsx index 1a6d617..015d891 100644 --- a/src/panels/ToolsPanel.tsx +++ b/src/panels/ToolsPanel.tsx @@ -177,7 +177,7 @@ export function ToolIcon({ id }: { id: ToolId }) { const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "text", "textbox", "image"]; const TOOLS_3D: ToolId[] = ["wall", "ceiling", "window", "door", "stair", "room"]; const COMMANDS_3D = ["column", "ceilingopening", "roof", "extrude", "sectionline", "viewline"]; -const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "trim", "extend", "fillet", "chamfer", "join", "measure"]; +const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "multiline", "trim", "extend", "fillet", "chamfer", "join", "measure"]; type ToolsView = "list" | "icons"; const VIEW_STORAGE_KEY = "dossier.toolsPanel.view"; diff --git a/src/ui/ribbon/CommandIcon.tsx b/src/ui/ribbon/CommandIcon.tsx index c568beb..ce830e2 100644 --- a/src/ui/ribbon/CommandIcon.tsx +++ b/src/ui/ribbon/CommandIcon.tsx @@ -73,6 +73,15 @@ export function CommandIcon({ name }: { name: string }) { ); + case "multiline": + // Drei parallele Linien (Basislinie + Kopien). + return ( + + + + + + ); case "trim": // Schere (Stutzen). return (