Feld/Array-Befehl: dupliziert Auswahl N-mal in einer Geste
Neuer Ändern-Befehl "Feld": Basispunkt, Zielpunkt (Länge/Winkel per Tab- Feld), Anzahl per Option zyklisch (2..12). Nutzt den bestehenden Transform-Modus "array" (tools/transform.ts), der schon länger existierte aber bisher über keinen Befehl erreichbar war — nur ein Command-Wrapper nötig, keine neue Transform-Logik.
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, number>, 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<string, number> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -28,6 +28,7 @@ import { moveCommand } from "./cmds/move";
|
|||||||
import { mirrorCommand } from "./cmds/mirror";
|
import { mirrorCommand } from "./cmds/mirror";
|
||||||
import { joinCommand } from "./cmds/join";
|
import { joinCommand } from "./cmds/join";
|
||||||
import { copyCommand } from "./cmds/copy";
|
import { copyCommand } from "./cmds/copy";
|
||||||
|
import { arrayCommand } from "./cmds/array";
|
||||||
import { offsetCommand } from "./cmds/offset";
|
import { offsetCommand } from "./cmds/offset";
|
||||||
import { extrudeCommand } from "./cmds/extrude";
|
import { extrudeCommand } from "./cmds/extrude";
|
||||||
import { trimCommand } from "./cmds/trim";
|
import { trimCommand } from "./cmds/trim";
|
||||||
@@ -69,6 +70,7 @@ export const COMMANDS: Record<string, Command> = {
|
|||||||
mirror: mirrorCommand,
|
mirror: mirrorCommand,
|
||||||
join: joinCommand,
|
join: joinCommand,
|
||||||
copy: copyCommand,
|
copy: copyCommand,
|
||||||
|
array: arrayCommand,
|
||||||
offset: offsetCommand,
|
offset: offsetCommand,
|
||||||
extrude: extrudeCommand,
|
extrude: extrudeCommand,
|
||||||
trim: trimCommand,
|
trim: trimCommand,
|
||||||
@@ -130,6 +132,8 @@ export const ALIASES: Record<string, string> = {
|
|||||||
j: "join",
|
j: "join",
|
||||||
verbinden: "join",
|
verbinden: "join",
|
||||||
cp: "copy",
|
cp: "copy",
|
||||||
|
ar: "array",
|
||||||
|
feld: "array",
|
||||||
o: "offset",
|
o: "offset",
|
||||||
ex: "extrude",
|
ex: "extrude",
|
||||||
tr: "trim",
|
tr: "trim",
|
||||||
|
|||||||
@@ -1209,6 +1209,10 @@ export const de = {
|
|||||||
"cmd.copy.base": "Basispunkt:",
|
"cmd.copy.base": "Basispunkt:",
|
||||||
"cmd.copy.target": "Zielpunkt (Enter beendet):",
|
"cmd.copy.target": "Zielpunkt (Enter beendet):",
|
||||||
"cmd.copy.empty": "Nichts gewählt (erst Objekte wählen).",
|
"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.label": "Versatz",
|
||||||
"cmd.offset.pick": "Kurve wählen:",
|
"cmd.offset.pick": "Kurve wählen:",
|
||||||
"cmd.offset.side": "Seite oder Distanz:",
|
"cmd.offset.side": "Seite oder Distanz:",
|
||||||
|
|||||||
@@ -1197,6 +1197,10 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.copy.base": "Base point:",
|
"cmd.copy.base": "Base point:",
|
||||||
"cmd.copy.target": "Target point (Enter to finish):",
|
"cmd.copy.target": "Target point (Enter to finish):",
|
||||||
"cmd.copy.empty": "Nothing selected (select objects first).",
|
"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.label": "Offset",
|
||||||
"cmd.offset.pick": "Select curve:",
|
"cmd.offset.pick": "Select curve:",
|
||||||
"cmd.offset.side": "Side or distance:",
|
"cmd.offset.side": "Side or distance:",
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ const TOOLS_2D: ToolId[] = [
|
|||||||
];
|
];
|
||||||
const TOOLS_3D: ToolId[] = ["wall", "ceiling", "window", "door", "stair", "room"];
|
const TOOLS_3D: ToolId[] = ["wall", "ceiling", "window", "door", "stair", "room"];
|
||||||
const COMMANDS_3D = ["column", "ceilingopening", "roof", "extrude", "sectionline", "viewline"];
|
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";
|
type ToolsView = "list" | "icons";
|
||||||
const VIEW_STORAGE_KEY = "dossier.toolsPanel.view";
|
const VIEW_STORAGE_KEY = "dossier.toolsPanel.view";
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ export function CommandIcon({ name }: { name: string }) {
|
|||||||
<rect x="5.5" y="5.5" width="8" height="8" />
|
<rect x="5.5" y="5.5" width="8" height="8" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case "array":
|
||||||
|
// Drei Kopien eines Quadrats entlang eines Richtungspfeils (Feld).
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<rect x="1.5" y="10.5" width="3.6" height="3.6" />
|
||||||
|
<rect x="6.2" y="6" width="3.6" height="3.6" strokeDasharray="1.6 1.2" />
|
||||||
|
<rect x="10.9" y="1.5" width="3.6" height="3.6" strokeDasharray="1.6 1.2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case "measure":
|
case "measure":
|
||||||
// Lineal/Massband: Strecke mit Endmarken + kleiner Skala.
|
// Lineal/Massband: Strecke mit Endmarken + kleiner Skala.
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user