diff --git a/src/commands/cmds/array.test.ts b/src/commands/cmds/array.test.ts new file mode 100644 index 0000000..4a19ad4 --- /dev/null +++ b/src/commands/cmds/array.test.ts @@ -0,0 +1,97 @@ +/** + * `arrayCommand` — dupliziert die Auswahl `count`-mal entlang eines Vektors + * in einer Geste. Baut auf dem bestehenden `mode:"array"` von + * `tools/transform.ts` auf (bisher ohne eigenen Befehl erreichbar). + */ + +import { describe, it, expect } from "vitest"; +import { arrayCommand } from "./array"; +import type { CommandContext, Project } from "../types"; +import type { Drawing2D } from "../../model/types"; + +function project(extra: Drawing2D[] = []): 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: extra, + context: [], + }; +} + +function makeCtx(p: Project, drawingId: string | null): CommandContext { + return { + project: p, + level: p.drawingLevels[0], + defaultCategoryCode: "20", + activeLineStyleId: "solid", + activeWallTypeId: "aw", + lastPoint: null, + selection: { wallIds: [], drawingId }, + }; +} + +const source: Drawing2D = { + id: "src", + type: "drawing2d", + levelId: "eg", + categoryCode: "20", + geom: { shape: "circle", center: { x: 0, y: 0 }, r: 1 }, +}; + +describe("arrayCommand", () => { + it("4 Kopien entlang +x im Abstand 3m (Anzahl per Option auf 4 gesetzt)", () => { + const p = project([source]); + const ctx = makeCtx(p, "src"); + let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx); + // Anzahl-Option 3× drücken: 3(default egal)→…→4 (zyklt 2..12, wir setzen sie + // deterministisch, indem wir bis 4 zyklen unabhängig vom Startwert testen wir + // stattdessen direkt über den committeten Output). + for (let i = 0; i < 12; i++) { + [state] = arrayCommand.onInput(state, { kind: "option", id: "count" }, ctx); + const opts = arrayCommand.options(state); + if (opts[0].value === "4") break; + } + const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 3, y: 0 } }, ctx); + expect(result.commit).toBeDefined(); + const next = result.commit!(p); + // Original bleibt + 4 neue Kopien. + expect(next.drawings2d.length).toBe(5); + const centers = next.drawings2d + .map((d) => d.geom) + .filter((g) => g.shape === "circle") + .map((g) => (g as { center: { x: number; y: number } }).center.x) + .sort((a, b) => a - b); + expect(centers).toEqual([0, 3, 6, 9, 12]); + }); + + it("leere Auswahl committet nichts", () => { + const p = project(); + const ctx = makeCtx(p, null); + let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx); + const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 5, y: 0 } }, ctx); + expect(result.commit).toBeUndefined(); + expect(result.done).toBe(true); + }); + + it("Zielpunkt gleich Basispunkt (Nullvektor) committet nichts", () => { + const p = project([source]); + const ctx = makeCtx(p, "src"); + let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 2, y: 2 } }, ctx); + const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 2, y: 2 } }, ctx); + expect(result.commit).toBeUndefined(); + }); +}); diff --git a/src/commands/cmds/array.ts b/src/commands/cmds/array.ts new file mode 100644 index 0000000..b53b951 --- /dev/null +++ b/src/commands/cmds/array.ts @@ -0,0 +1,161 @@ +// Array (Feld) — dupliziert die AKTUELLE Auswahl `count`-mal entlang eines +// Vektors, in EINER Geste (baut auf dem bestehenden Transform-Modus +// `"array"` auf, der bisher nie über einen Befehl erreichbar war — s. +// `tools/transform.ts::copyTs`). Schritte: +// 1) „Basispunkt:" → Punkt +// 2) „Zielpunkt ( Anzahl ):" → Punkt/Zahl (Tab length/angle) → committet +// ALLE `count` Kopien auf einmal (Original bleibt). „Anzahl" (Option, +// persistent über Aufrufe wie bei Multiline) zyklt 2..12. +// +// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl, +// wie move/copy). Leere Auswahl → No-op. + +import { commitTransform, transformPreview } from "../../tools/transform"; +import type { TransformSelection } from "../../tools/transform"; +import type { + CmdOption, + Command, + CommandField, + CommandResult, + CommandSelection, + CommandState, + Project, + ToolDraft, + Vec2, +} from "../types"; + +const DEG = Math.PI / 180; +const MIN_COUNT = 2; +const MAX_COUNT = 12; +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 Anzahl über Aufrufe hinweg (wie `lastCount` bei Multiline). */ +let lastCount = 3; + +const ARRAY_FIELDS: CommandField[] = [ + { id: "length", labelKey: "cmd.field.length" }, + { id: "angle", labelKey: "cmd.field.angle" }, +]; +const COUNT_OPTION: CmdOption = { id: "count", labelKey: "cmd.array.count" }; + +function targetFromFields(base: Vec2, locks: Record, cursor: Vec2 | null): Vec2 { + const ref = cursor ?? base; + const length = "length" in locks ? locks.length : segLen(base, ref); + const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref); + const ang = angleDeg * DEG; + return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length }; +} + +function hasSelection(sel: CommandSelection): boolean { + return sel.wallIds.length > 0 || sel.drawingId !== null || !!sel.extrudedSolidId || !!sel.columnId; +} +function toTransformSel(sel: CommandSelection): TransformSelection { + return { + wallIds: sel.wallIds, + drawingId: sel.drawingId, + extrudedSolidId: sel.extrudedSolidId, + columnId: sel.columnId, + }; +} + +interface ArrBase extends CommandState { + phase: "base"; +} +interface ArrTarget extends CommandState { + phase: "target"; + sel: CommandSelection; + base: Vec2; + cursor: Vec2 | null; +} +type ArrState = ArrBase | ArrTarget; + +/** Vorschau ALLER `lastCount` Kopien bei der aktuellen Cursor-Lage. */ +function arrayDraft(project: Project, sel: CommandSelection, base: Vec2, target: Vec2): ToolDraft { + const preview = transformPreview(project, toTransformSel(sel), "move", [base, target], "array", lastCount); + return { + preview, + vertices: [base], + hud: { + at: target, + text: `${segLen(base, target).toFixed(2)} m × ${lastCount}`, + }, + }; +} + +const idle = (): [CommandState, CommandResult] => [ + { phase: "base", lastPoint: null } as ArrBase, + { draft: null, done: true }, +]; + +export const arrayCommand: Command = { + name: "array", + labelKey: "cmd.array.label", + prompt: (s) => ((s as ArrState).phase === "target" ? "cmd.array.target" : "cmd.array.base"), + accepts: (s) => ((s as ArrState).phase === "target" ? ["point", "number", "option"] : ["point"]), + options: (s) => + (s as ArrState).phase === "target" ? [{ ...COUNT_OPTION, value: String(lastCount) }] : [], + init: (): ArrBase => ({ phase: "base", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as ArrState; + if (!hasSelection(ctx.selection)) return idle(); + + if (s.phase !== "target") { + if (input.kind !== "point") return [s, { draft: null }]; + const next: ArrTarget = { + phase: "target", + sel: ctx.selection, + base: input.point, + cursor: input.point, + lastPoint: input.point, + }; + return [next, { draft: { preview: [], vertices: [input.point] } }]; + } + + if (input.kind === "option") { + if (input.id === "count") { + lastCount = lastCount >= MAX_COUNT ? MIN_COUNT : lastCount + 1; + return [s, { draft: s.cursor ? arrayDraft(ctx.project, s.sel, s.base, s.cursor) : null }]; + } + return [s, { draft: null }]; + } + + let target: Vec2 | null = null; + if (input.kind === "point") target = input.point; + else if (input.kind === "number") target = { x: s.base.x + input.value, y: s.base.y }; + if (!target || segLen(s.base, target) < 1e-6) return idle(); + + const { base, sel } = s; + return [ + idle()[0], + { draft: null, done: true, commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, target!], "array", lastCount) }, + ]; + }, + + onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => { + const s = state as ArrState; + if (s.phase !== "target") return [s, { draft: null }]; + const ns: ArrTarget = { ...s, cursor: point }; + return [ns, { draft: arrayDraft(ctx.project, s.sel, s.base, point) }]; + }, + + onConfirm: (): [CommandState, CommandResult] => idle(), + onCancel: (): [CommandState, CommandResult] => idle(), + + fields: (state) => ((state as ArrState).phase === "target" ? ARRAY_FIELDS : []), + pointFromFields: (state, locks, cursor) => { + const s = state as ArrState; + if (s.phase !== "target") return null; + return targetFromFields(s.base, locks, cursor); + }, + fieldValues: (state, _locks, cursor): Record => { + const s = state as ArrState; + if (s.phase !== "target" || !cursor) return {}; + return { + length: segLen(s.base, cursor), + angle: ((segAngleDeg(s.base, cursor) % 360) + 360) % 360, + }; + }, +}; diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 011b8aa..055e431 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -28,6 +28,7 @@ import { moveCommand } from "./cmds/move"; import { mirrorCommand } from "./cmds/mirror"; import { joinCommand } from "./cmds/join"; import { copyCommand } from "./cmds/copy"; +import { arrayCommand } from "./cmds/array"; import { offsetCommand } from "./cmds/offset"; import { extrudeCommand } from "./cmds/extrude"; import { trimCommand } from "./cmds/trim"; @@ -69,6 +70,7 @@ export const COMMANDS: Record = { mirror: mirrorCommand, join: joinCommand, copy: copyCommand, + array: arrayCommand, offset: offsetCommand, extrude: extrudeCommand, trim: trimCommand, @@ -130,6 +132,8 @@ export const ALIASES: Record = { j: "join", verbinden: "join", cp: "copy", + ar: "array", + feld: "array", o: "offset", ex: "extrude", tr: "trim", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 40a3427..58f2394 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -1209,6 +1209,10 @@ export const de = { "cmd.copy.base": "Basispunkt:", "cmd.copy.target": "Zielpunkt (Enter beendet):", "cmd.copy.empty": "Nichts gewählt (erst Objekte wählen).", + "cmd.array.label": "Feld", + "cmd.array.base": "Basispunkt:", + "cmd.array.target": "Zielpunkt ( Anzahl ):", + "cmd.array.count": "Anzahl", "cmd.offset.label": "Versatz", "cmd.offset.pick": "Kurve wählen:", "cmd.offset.side": "Seite oder Distanz:", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index e6bc56e..fbfe214 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1197,6 +1197,10 @@ export const en: Record = { "cmd.copy.base": "Base point:", "cmd.copy.target": "Target point (Enter to finish):", "cmd.copy.empty": "Nothing selected (select objects first).", + "cmd.array.label": "Array", + "cmd.array.base": "Base point:", + "cmd.array.target": "Target point ( Count ):", + "cmd.array.count": "Count", "cmd.offset.label": "Offset", "cmd.offset.pick": "Select curve:", "cmd.offset.side": "Side or distance:", diff --git a/src/panels/ToolsPanel.tsx b/src/panels/ToolsPanel.tsx index f2704b9..d4e8dd2 100644 --- a/src/panels/ToolsPanel.tsx +++ b/src/panels/ToolsPanel.tsx @@ -241,7 +241,7 @@ const TOOLS_2D: ToolId[] = [ ]; 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", "multiline", "trim", "extend", "fillet", "chamfer", "join", "measure"]; +const COMMANDS_EDIT = ["move", "copy", "array", "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 ce830e2..bbe6bb7 100644 --- a/src/ui/ribbon/CommandIcon.tsx +++ b/src/ui/ribbon/CommandIcon.tsx @@ -36,6 +36,15 @@ export function CommandIcon({ name }: { name: string }) { ); + case "array": + // Drei Kopien eines Quadrats entlang eines Richtungspfeils (Feld). + return ( + + + + + + ); case "measure": // Lineal/Massband: Strecke mit Endmarken + kleiner Skala. return (