diff --git a/src/App.tsx b/src/App.tsx index baf4b78..5a78c38 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -115,7 +115,7 @@ import { useGripEditing } from "./state/useGripEditing"; import { usePanelHostBase } from "./state/usePanelHostBase"; import { useDockDragState } from "./state/useDockDragState"; import { useMouseSelectionHandlers } from "./state/useMouseSelectionHandlers"; -import { containingGroupId, EMPTY_MEMBERSHIP } from "./state/groupActions"; +import { addNewElementsToActiveGroup, containingGroupId, EMPTY_MEMBERSHIP } from "./state/groupActions"; import type { GroupMembership } from "./state/groupActions"; import { useProjectFileActions } from "./state/useProjectFileActions"; import { useProjectActions } from "./state/useProjectActions"; @@ -621,6 +621,7 @@ export default function App() { const transformSel: TransformSelection = { wallIds: selectedWallIds, drawingId: selectedDrawingId, + drawingIds: selectedDrawingIds, extrudedSolidId: selectedExtrudedSolidId, columnId: selectedColumnId, }; @@ -745,6 +746,7 @@ export default function App() { selectedDrawingIds, selectedExtrudedSolidId, selectedColumnId, + groupPath, setProject, setDraft, }); @@ -761,6 +763,7 @@ export default function App() { selectedDrawingIds, selectedExtrudedSolidId, selectedColumnId, + groupPath, setProject, setDraft, }; @@ -808,7 +811,15 @@ export default function App() { }, pxPerMeter: () => planViewRef.current?.pxPerMeter() ?? 90, setDraft: (d) => cmdLatest.current.setDraft(d), - commit: (mutate) => cmdLatest.current.setProject(mutate), + // Zeichnet man WÄHREND man eine Gruppe betreten hat (Isolation), wird + // das neue Element automatisch Mitglied dieser Gruppe (Nutzer-Wunsch: + // „wenn man etwas neu zeichnet ... zeichnet man in der Gruppe") — s. + // state/groupActions.ts::addNewElementsToActiveGroup (Diff vor/nach + // Commit über alle Elementarten, additiv zur eigentlichen Mutation). + commit: (mutate) => + cmdLatest.current.setProject((p) => + addNewElementsToActiveGroup(p, mutate(p), cmdLatest.current.groupPath), + ), isFloor: () => cmdLatest.current.activeLevel.kind === "floor", // Frisch über `text`/`textbox` erzeugtes Element sofort inline editieren // (kein Lookup nötig — der Commit hat es bereits angehängt, s. engine.ts @@ -1270,6 +1281,7 @@ export default function App() { setActiveTransform, selectedWallIds, selectedDrawingId, + selectedDrawingIds, selectedExtrudedSolidId, selectedColumnId, engine, diff --git a/src/commands/cmds/array.ts b/src/commands/cmds/array.ts index b53b951..c3a8aa9 100644 --- a/src/commands/cmds/array.ts +++ b/src/commands/cmds/array.ts @@ -49,12 +49,19 @@ function targetFromFields(base: Vec2, locks: Record, cursor: Vec } function hasSelection(sel: CommandSelection): boolean { - return sel.wallIds.length > 0 || sel.drawingId !== null || !!sel.extrudedSolidId || !!sel.columnId; + return ( + sel.wallIds.length > 0 || + sel.drawingId !== null || + (sel.drawingIds?.length ?? 0) > 0 || + !!sel.extrudedSolidId || + !!sel.columnId + ); } function toTransformSel(sel: CommandSelection): TransformSelection { return { wallIds: sel.wallIds, drawingId: sel.drawingId, + drawingIds: sel.drawingIds, extrudedSolidId: sel.extrudedSolidId, columnId: sel.columnId, }; diff --git a/src/commands/cmds/copy.ts b/src/commands/cmds/copy.ts index 021004c..2545534 100644 --- a/src/commands/cmds/copy.ts +++ b/src/commands/cmds/copy.ts @@ -45,6 +45,7 @@ function hasSelection(sel: CommandSelection): boolean { return ( sel.wallIds.length > 0 || sel.drawingId !== null || + (sel.drawingIds?.length ?? 0) > 0 || !!sel.extrudedSolidId || !!sel.columnId ); @@ -53,6 +54,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection { return { wallIds: sel.wallIds, drawingId: sel.drawingId, + drawingIds: sel.drawingIds, extrudedSolidId: sel.extrudedSolidId, columnId: sel.columnId, }; diff --git a/src/commands/cmds/mirror.ts b/src/commands/cmds/mirror.ts index 31af7ba..ba79f84 100644 --- a/src/commands/cmds/mirror.ts +++ b/src/commands/cmds/mirror.ts @@ -35,6 +35,7 @@ function hasSelection(sel: CommandSelection): boolean { return ( sel.wallIds.length > 0 || sel.drawingId !== null || + (sel.drawingIds?.length ?? 0) > 0 || !!sel.extrudedSolidId || !!sel.columnId ); @@ -45,6 +46,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection { return { wallIds: sel.wallIds, drawingId: sel.drawingId, + drawingIds: sel.drawingIds, extrudedSolidId: sel.extrudedSolidId, columnId: sel.columnId, }; diff --git a/src/commands/cmds/move.ts b/src/commands/cmds/move.ts index f955af8..f4214e9 100644 --- a/src/commands/cmds/move.ts +++ b/src/commands/cmds/move.ts @@ -52,6 +52,7 @@ function hasSelection(sel: CommandSelection): boolean { return ( sel.wallIds.length > 0 || sel.drawingId !== null || + (sel.drawingIds?.length ?? 0) > 0 || !!sel.extrudedSolidId || !!sel.columnId ); @@ -62,6 +63,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection { return { wallIds: sel.wallIds, drawingId: sel.drawingId, + drawingIds: sel.drawingIds, extrudedSolidId: sel.extrudedSolidId, columnId: sel.columnId, }; diff --git a/src/state/groupActions.test.ts b/src/state/groupActions.test.ts index bc7ddd6..ad2e549 100644 --- a/src/state/groupActions.test.ts +++ b/src/state/groupActions.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect } from "vitest"; import { + addNewElementsToActiveGroup, applyGroupDimming, containingGroupId, expandToGroups, @@ -185,6 +186,51 @@ describe("applyGroupDimming", () => { }); }); +describe("addNewElementsToActiveGroup", () => { + const g1: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] }; + const line = (id: string) => + ({ id, type: "drawing2d", levelId: "eg", categoryCode: "20", geom: { shape: "line", a: { x: 0, y: 0 }, b: { x: 1, y: 0 } } }) as Project["drawings2d"][number]; + + it("neu gezeichnetes Element wird Mitglied der aktiven (innersten) Gruppe", () => { + const before = project([g1]); + const after: Project = { ...before, drawings2d: [line("d1")] }; + const result = addNewElementsToActiveGroup(before, after, ["g1"]); + const g = result.groups!.find((x) => x.id === "g1")!; + expect(g.drawingIds).toEqual(["d1"]); + expect(g.wallIds).toEqual(["w1"]); // bestehende Mitgliedschaft bleibt + }); + + it("ohne aktive Gruppe (groupPath leer) bleibt das Projekt unverändert", () => { + const before = project([g1]); + const after: Project = { ...before, drawings2d: [line("d1")] }; + const result = addNewElementsToActiveGroup(before, after, []); + expect(result).toBe(after); // unverändert (Referenzgleichheit) + }); + + it("keine Änderung (before === after) ist ein No-op", () => { + const p = project([g1]); + expect(addNewElementsToActiveGroup(p, p, ["g1"])).toBe(p); + }); + + it("mehrere neue Elemente auf einmal (z. B. Feld/Array) werden alle Mitglied", () => { + const before = project([g1]); + const after: Project = { ...before, drawings2d: [line("d1"), line("d2"), line("d3")] }; + const result = addNewElementsToActiveGroup(before, after, ["g1"]); + const g = result.groups!.find((x) => x.id === "g1")!; + expect(g.drawingIds.sort()).toEqual(["d1", "d2", "d3"]); + }); + + it("verschachtelt: neues Element landet in der INNERSTEN (aktiven) Gruppe, nicht der äusseren", () => { + const inner: Group = { id: "g1", wallIds: [], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] }; + const outer: Group = { id: "g2", wallIds: [], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: ["g1"] }; + const before = project([inner, outer]); + const after: Project = { ...before, drawings2d: [line("d1")] }; + const result = addNewElementsToActiveGroup(before, after, ["g2", "g1"]); + expect(result.groups!.find((x) => x.id === "g1")!.drawingIds).toEqual(["d1"]); + expect(result.groups!.find((x) => x.id === "g2")!.drawingIds).toEqual([]); + }); +}); + describe("ungroup", () => { it("löst genau EIN Level auf; Untergruppen bleiben als eigene Gruppen bestehen", () => { const inner: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] }; diff --git a/src/state/groupActions.ts b/src/state/groupActions.ts index 3d0b305..e59aaeb 100644 --- a/src/state/groupActions.ts +++ b/src/state/groupActions.ts @@ -227,6 +227,47 @@ export function applyGroupDimming( }); } +/** IDs, die in `after` neu hinzugekommen sind (in `before` noch nicht vorhanden). */ +function newIds(before: { id: string }[], after: { id: string }[]): string[] { + const beforeIds = new Set(before.map((x) => x.id)); + return after.filter((x) => !beforeIds.has(x.id)).map((x) => x.id); +} + +/** + * Zeichnet man WÄHREND man eine Gruppe betreten hat (Isolation) ein neues + * Element, wird es automatisch Mitglied dieser (innersten) Gruppe — man + * zeichnet ja SICHTLICH „in" ihr (Nutzer-Wunsch, Vectorworks-Verhalten). + * Vergleicht `before`/`after` je Elementart auf neu hinzugekommene IDs (Diff + * über die commit-Mutation) und hängt sie an die aktive Gruppe an. `before` + * === `after` (keine Änderung) oder `groupPath` leer → `after` unverändert. + */ +export function addNewElementsToActiveGroup( + before: Project, + after: Project, + groupPath: string[], +): Project { + if (groupPath.length === 0 || before === after) return after; + const activeId = groupPath[groupPath.length - 1]; + const g = (after.groups ?? []).find((x) => x.id === activeId); + if (!g) return after; + const added: GroupMembership = { + wallIds: newIds(before.walls, after.walls), + drawingIds: newIds(before.drawings2d, after.drawings2d), + ceilingIds: newIds(before.ceilings ?? [], after.ceilings ?? []), + roomIds: newIds(before.rooms ?? [], after.rooms ?? []), + stairIds: newIds(before.stairs ?? [], after.stairs ?? []), + columnIds: newIds(before.columns ?? [], after.columns ?? []), + roofIds: newIds(before.roofs ?? [], after.roofs ?? []), + openingIds: newIds(before.openings ?? [], after.openings ?? []), + }; + if (KEYS.every((k) => added[k].length === 0)) return after; + const merged: Group = { ...g, ...mergeMembership(g, added) }; + return { + ...after, + groups: (after.groups ?? []).map((x) => (x.id === activeId ? merged : x)), + }; +} + /** * Erzeugt eine neue Gruppe aus einer Mengen-Auswahl (+ optional bereits * gewählte Gruppen, die dabei als Untergruppen verschachtelt werden — „zwei diff --git a/src/state/useKeyboardShortcuts.ts b/src/state/useKeyboardShortcuts.ts index 8294de8..a94421c 100644 --- a/src/state/useKeyboardShortcuts.ts +++ b/src/state/useKeyboardShortcuts.ts @@ -502,6 +502,7 @@ export function useTransformShortcuts( ) => void, selectedWallIds: string[], selectedDrawingId: string | null, + selectedDrawingIds: string[], selectedExtrudedSolidId: string | null, selectedColumnId: string | null, engine: CommandEngine, @@ -543,6 +544,7 @@ export function useTransformShortcuts( const hasSel = selectedWallIds.length > 0 || !!selectedDrawingId || + selectedDrawingIds.length > 0 || !!selectedExtrudedSolidId || !!selectedColumnId; if (!hasSel) return; @@ -567,6 +569,7 @@ export function useTransformShortcuts( activeTransform, selectedWallIds, selectedDrawingId, + selectedDrawingIds, selectedExtrudedSolidId, selectedColumnId, engine, diff --git a/src/tools/transform.test.ts b/src/tools/transform.test.ts new file mode 100644 index 0000000..a8e51a3 --- /dev/null +++ b/src/tools/transform.test.ts @@ -0,0 +1,82 @@ +/** + * `commitTransform`/`transformPreview` mit MEHREREN gewählten 2D-Elementen + * (`TransformSelection.drawingIds`) — Nutzer-Report: "wenn ich zwei Linien + * anwähle, dass ich dann m usw nicht verwenden kann". Vorher trug + * `TransformSelection` nur ein einzelnes `drawingId`, das bei einer + * Mehrfachauswahl (2+ Elemente) `null` war → Bewegen/Kopieren/Spiegeln/ + * Drehen/Feld griffen gar nicht. + */ + +import { describe, it, expect } from "vitest"; +import { commitTransform } from "./transform"; +import type { TransformSelection } from "./transform"; +import type { Drawing2D, Project } from "../model/types"; + +function project(drawings: Drawing2D[]): Project { + return { + id: "t", + name: "T", + lineStyles: [], + hatches: [], + components: [], + wallTypes: [], + drawingLevels: [], + layers: [], + walls: [], + doors: [], + drawings2d: drawings, + }; +} + +function line(id: string, x: number): Drawing2D { + return { + id, + type: "drawing2d", + levelId: "eg", + categoryCode: "20", + geom: { shape: "line", a: { x, y: 0 }, b: { x: x + 1, y: 0 } }, + }; +} + +describe("commitTransform — Mehrfachauswahl (drawingIds)", () => { + it("move: verschiebt ALLE gewählten 2D-Elemente, nicht nur eines", () => { + const p = project([line("d1", 0), line("d2", 5), line("d3", 10)]); + const sel: TransformSelection = { wallIds: [], drawingId: null, drawingIds: ["d1", "d2"] }; + const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 2, y: 3 }], "move", 1); + const d1 = next.drawings2d.find((d) => d.id === "d1")!.geom; + const d2 = next.drawings2d.find((d) => d.id === "d2")!.geom; + const d3 = next.drawings2d.find((d) => d.id === "d3")!.geom; + if (d1.shape === "line") expect(d1.a).toEqual({ x: 2, y: 3 }); + if (d2.shape === "line") expect(d2.a).toEqual({ x: 7, y: 3 }); // 5+2 + if (d3.shape === "line") expect(d3.a).toEqual({ x: 10, y: 0 }); // nicht gewählt, unverändert + }); + + it("copy: dupliziert ALLE gewählten 2D-Elemente (Original bleibt)", () => { + const p = project([line("d1", 0), line("d2", 5)]); + const sel: TransformSelection = { wallIds: [], drawingId: null, drawingIds: ["d1", "d2"] }; + const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 1, y: 0 }], "copy", 1); + expect(next.drawings2d.length).toBe(4); // 2 Original + 2 Kopien + }); + + it("array: dupliziert ALLE gewählten 2D-Elemente N-mal", () => { + const p = project([line("d1", 0), line("d2", 5)]); + const sel: TransformSelection = { wallIds: [], drawingId: null, drawingIds: ["d1", "d2"] }; + const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 1, y: 0 }], "array", 3); + expect(next.drawings2d.length).toBe(8); // 2 Original + 2×3 Kopien + }); + + it("weiterhin abwärtskompatibel: einzelnes `drawingId` (ohne drawingIds) funktioniert unverändert", () => { + const p = project([line("d1", 0)]); + const sel: TransformSelection = { wallIds: [], drawingId: "d1" }; + const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 3, y: 0 }], "move", 1); + const d1 = next.drawings2d[0].geom; + if (d1.shape === "line") expect(d1.a).toEqual({ x: 3, y: 0 }); + }); + + it("drawingId + drawingIds zusammen werden dedupliziert (kein Doppel-Verschieben)", () => { + const p = project([line("d1", 0), line("d2", 5)]); + const sel: TransformSelection = { wallIds: [], drawingId: "d1", drawingIds: ["d1", "d2"] }; + const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 1, y: 0 }], "copy", 1); + expect(next.drawings2d.length).toBe(4); // je EINE Kopie pro Element, nicht zwei für d1 + }); +}); diff --git a/src/tools/transform.ts b/src/tools/transform.ts index 76cd51b..979b9f4 100644 --- a/src/tools/transform.ts +++ b/src/tools/transform.ts @@ -30,13 +30,27 @@ export type CopyMode = "move" | "copy" | "array" | "distribute"; // U / I / O / /** Welche Auswahl transformiert wird. */ export interface TransformSelection { wallIds: string[]; + /** + * Einzeln gewähltes 2D-Element (Legacy-Feld, s. `CommandSelection.drawingId`). + * `drawingIds` (Mehrfachauswahl) wird bevorzugt ausgewertet; beide zusammen + * ergeben die MENGE der zu transformierenden 2D-Elemente (Union, dedupliziert). + */ drawingId: string | null; + /** ALLE gewählten 2D-Elemente (Mehrfachauswahl) — s. `CommandSelection.drawingIds`. */ + drawingIds?: string[]; /** Gewählter extrudierter Körper (truck-Integration); nur move.ts setzt ihn. */ extrudedSolidId?: string | null; /** Gewählte Stütze (Tragwerk); von move/copy/mirror gesetzt. */ columnId?: string | null; } +/** Vereinigt `drawingId`/`drawingIds` zu einer eindeutigen ID-Menge (dedupliziert). */ +function drawingIdSet(sel: TransformSelection): Set { + const ids = new Set(sel.drawingIds ?? []); + if (sel.drawingId) ids.add(sel.drawingId); + return ids; +} + /** Wie viele Klickpunkte die Operation braucht (Drehen = 3, sonst 2). */ export const requiredPoints = (op: TransformOp): number => (op === "rotate" ? 3 : 2); @@ -116,10 +130,8 @@ function gatherItems(project: Project, sel: TransformSelection): AnyItem[] { const items: AnyItem[] = []; const ids = new Set(sel.wallIds); for (const w of project.walls) if (ids.has(w.id)) items.push({ kind: "wall", wall: w }); - if (sel.drawingId) { - const d = project.drawings2d.find((x) => x.id === sel.drawingId); - if (d) items.push({ kind: "drawing", drawing: d }); - } + const drawIds = drawingIdSet(sel); + for (const d of project.drawings2d) if (drawIds.has(d.id)) items.push({ kind: "drawing", drawing: d }); if (sel.extrudedSolidId) { const s = (project.extrudedSolids ?? []).find((x) => x.id === sel.extrudedSolidId); if (s) items.push({ kind: "extrudedSolid", solid: s }); @@ -292,9 +304,10 @@ export function commitTransform( if (movesOriginal(mode)) { const fn = transformFn(op, pts, 1); walls = walls.map((w) => (wallIds.has(w.id) ? { ...w, start: fn(w.start), end: fn(w.end) } : w)); - if (sel.drawingId) { + const moveDrawIds = drawingIdSet(sel); + if (moveDrawIds.size > 0) { drawings2d = drawings2d.map((d) => - d.id === sel.drawingId ? { ...d, geom: mvGeom(d.geom, fn) } : d, + moveDrawIds.has(d.id) ? { ...d, geom: mvGeom(d.geom, fn) } : d, ); } if (sel.extrudedSolidId) {