Neu gezeichnete Elemente treten der aktiven Gruppe bei; Fix: m/s/d bei Mehrfachauswahl

Zeichnet man ein neues Element, während man eine Gruppe betreten hat
(Isolation), wird es automatisch Mitglied dieser (innersten) Gruppe — man
zeichnet ja sichtlich "in" ihr. Vergleicht vor/nach jedem Commit über alle
Elementarten auf neu hinzugekommene IDs, additiv zur eigentlichen Mutation.

Separater Fix: Bewegen/Kopieren/Spiegeln/Drehen/Feld (m/s/d-Kürzel und die
gleichnamigen Befehle) funktionierten nicht, sobald mehr als ein 2D-Element
gewählt war — TransformSelection kannte nur ein einzelnes `drawingId`, das
bei Mehrfachauswahl `null` ist. Neues `drawingIds`-Feld (Union mit
`drawingId`, dedupliziert) durchgereicht von allen Aufrufstellen (Move/
Copy/Mirror/Array-Befehle, Tastatur-Kürzel-Pfad).
This commit is contained in:
2026-08-22 11:49:54 +02:00
parent 3be728c80e
commit 91908b1456
10 changed files with 219 additions and 9 deletions
+14 -2
View File
@@ -115,7 +115,7 @@ import { useGripEditing } from "./state/useGripEditing";
import { usePanelHostBase } from "./state/usePanelHostBase"; import { usePanelHostBase } from "./state/usePanelHostBase";
import { useDockDragState } from "./state/useDockDragState"; import { useDockDragState } from "./state/useDockDragState";
import { useMouseSelectionHandlers } from "./state/useMouseSelectionHandlers"; 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 type { GroupMembership } from "./state/groupActions";
import { useProjectFileActions } from "./state/useProjectFileActions"; import { useProjectFileActions } from "./state/useProjectFileActions";
import { useProjectActions } from "./state/useProjectActions"; import { useProjectActions } from "./state/useProjectActions";
@@ -621,6 +621,7 @@ export default function App() {
const transformSel: TransformSelection = { const transformSel: TransformSelection = {
wallIds: selectedWallIds, wallIds: selectedWallIds,
drawingId: selectedDrawingId, drawingId: selectedDrawingId,
drawingIds: selectedDrawingIds,
extrudedSolidId: selectedExtrudedSolidId, extrudedSolidId: selectedExtrudedSolidId,
columnId: selectedColumnId, columnId: selectedColumnId,
}; };
@@ -745,6 +746,7 @@ export default function App() {
selectedDrawingIds, selectedDrawingIds,
selectedExtrudedSolidId, selectedExtrudedSolidId,
selectedColumnId, selectedColumnId,
groupPath,
setProject, setProject,
setDraft, setDraft,
}); });
@@ -761,6 +763,7 @@ export default function App() {
selectedDrawingIds, selectedDrawingIds,
selectedExtrudedSolidId, selectedExtrudedSolidId,
selectedColumnId, selectedColumnId,
groupPath,
setProject, setProject,
setDraft, setDraft,
}; };
@@ -808,7 +811,15 @@ export default function App() {
}, },
pxPerMeter: () => planViewRef.current?.pxPerMeter() ?? 90, pxPerMeter: () => planViewRef.current?.pxPerMeter() ?? 90,
setDraft: (d) => cmdLatest.current.setDraft(d), 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", isFloor: () => cmdLatest.current.activeLevel.kind === "floor",
// Frisch über `text`/`textbox` erzeugtes Element sofort inline editieren // Frisch über `text`/`textbox` erzeugtes Element sofort inline editieren
// (kein Lookup nötig — der Commit hat es bereits angehängt, s. engine.ts // (kein Lookup nötig — der Commit hat es bereits angehängt, s. engine.ts
@@ -1270,6 +1281,7 @@ export default function App() {
setActiveTransform, setActiveTransform,
selectedWallIds, selectedWallIds,
selectedDrawingId, selectedDrawingId,
selectedDrawingIds,
selectedExtrudedSolidId, selectedExtrudedSolidId,
selectedColumnId, selectedColumnId,
engine, engine,
+8 -1
View File
@@ -49,12 +49,19 @@ function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec
} }
function hasSelection(sel: CommandSelection): boolean { 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 { function toTransformSel(sel: CommandSelection): TransformSelection {
return { return {
wallIds: sel.wallIds, wallIds: sel.wallIds,
drawingId: sel.drawingId, drawingId: sel.drawingId,
drawingIds: sel.drawingIds,
extrudedSolidId: sel.extrudedSolidId, extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId, columnId: sel.columnId,
}; };
+2
View File
@@ -45,6 +45,7 @@ function hasSelection(sel: CommandSelection): boolean {
return ( return (
sel.wallIds.length > 0 || sel.wallIds.length > 0 ||
sel.drawingId !== null || sel.drawingId !== null ||
(sel.drawingIds?.length ?? 0) > 0 ||
!!sel.extrudedSolidId || !!sel.extrudedSolidId ||
!!sel.columnId !!sel.columnId
); );
@@ -53,6 +54,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
return { return {
wallIds: sel.wallIds, wallIds: sel.wallIds,
drawingId: sel.drawingId, drawingId: sel.drawingId,
drawingIds: sel.drawingIds,
extrudedSolidId: sel.extrudedSolidId, extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId, columnId: sel.columnId,
}; };
+2
View File
@@ -35,6 +35,7 @@ function hasSelection(sel: CommandSelection): boolean {
return ( return (
sel.wallIds.length > 0 || sel.wallIds.length > 0 ||
sel.drawingId !== null || sel.drawingId !== null ||
(sel.drawingIds?.length ?? 0) > 0 ||
!!sel.extrudedSolidId || !!sel.extrudedSolidId ||
!!sel.columnId !!sel.columnId
); );
@@ -45,6 +46,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
return { return {
wallIds: sel.wallIds, wallIds: sel.wallIds,
drawingId: sel.drawingId, drawingId: sel.drawingId,
drawingIds: sel.drawingIds,
extrudedSolidId: sel.extrudedSolidId, extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId, columnId: sel.columnId,
}; };
+2
View File
@@ -52,6 +52,7 @@ function hasSelection(sel: CommandSelection): boolean {
return ( return (
sel.wallIds.length > 0 || sel.wallIds.length > 0 ||
sel.drawingId !== null || sel.drawingId !== null ||
(sel.drawingIds?.length ?? 0) > 0 ||
!!sel.extrudedSolidId || !!sel.extrudedSolidId ||
!!sel.columnId !!sel.columnId
); );
@@ -62,6 +63,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
return { return {
wallIds: sel.wallIds, wallIds: sel.wallIds,
drawingId: sel.drawingId, drawingId: sel.drawingId,
drawingIds: sel.drawingIds,
extrudedSolidId: sel.extrudedSolidId, extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId, columnId: sel.columnId,
}; };
+46
View File
@@ -7,6 +7,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { import {
addNewElementsToActiveGroup,
applyGroupDimming, applyGroupDimming,
containingGroupId, containingGroupId,
expandToGroups, 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", () => { describe("ungroup", () => {
it("löst genau EIN Level auf; Untergruppen bleiben als eigene Gruppen bestehen", () => { 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: [] }; const inner: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
+41
View File
@@ -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 * Erzeugt eine neue Gruppe aus einer Mengen-Auswahl (+ optional bereits
* gewählte Gruppen, die dabei als Untergruppen verschachtelt werden — „zwei * gewählte Gruppen, die dabei als Untergruppen verschachtelt werden — „zwei
+3
View File
@@ -502,6 +502,7 @@ export function useTransformShortcuts(
) => void, ) => void,
selectedWallIds: string[], selectedWallIds: string[],
selectedDrawingId: string | null, selectedDrawingId: string | null,
selectedDrawingIds: string[],
selectedExtrudedSolidId: string | null, selectedExtrudedSolidId: string | null,
selectedColumnId: string | null, selectedColumnId: string | null,
engine: CommandEngine, engine: CommandEngine,
@@ -543,6 +544,7 @@ export function useTransformShortcuts(
const hasSel = const hasSel =
selectedWallIds.length > 0 || selectedWallIds.length > 0 ||
!!selectedDrawingId || !!selectedDrawingId ||
selectedDrawingIds.length > 0 ||
!!selectedExtrudedSolidId || !!selectedExtrudedSolidId ||
!!selectedColumnId; !!selectedColumnId;
if (!hasSel) return; if (!hasSel) return;
@@ -567,6 +569,7 @@ export function useTransformShortcuts(
activeTransform, activeTransform,
selectedWallIds, selectedWallIds,
selectedDrawingId, selectedDrawingId,
selectedDrawingIds,
selectedExtrudedSolidId, selectedExtrudedSolidId,
selectedColumnId, selectedColumnId,
engine, engine,
+82
View File
@@ -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
});
});
+19 -6
View File
@@ -30,13 +30,27 @@ export type CopyMode = "move" | "copy" | "array" | "distribute"; // U / I / O /
/** Welche Auswahl transformiert wird. */ /** Welche Auswahl transformiert wird. */
export interface TransformSelection { export interface TransformSelection {
wallIds: string[]; 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; 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. */ /** Gewählter extrudierter Körper (truck-Integration); nur move.ts setzt ihn. */
extrudedSolidId?: string | null; extrudedSolidId?: string | null;
/** Gewählte Stütze (Tragwerk); von move/copy/mirror gesetzt. */ /** Gewählte Stütze (Tragwerk); von move/copy/mirror gesetzt. */
columnId?: string | null; columnId?: string | null;
} }
/** Vereinigt `drawingId`/`drawingIds` zu einer eindeutigen ID-Menge (dedupliziert). */
function drawingIdSet(sel: TransformSelection): Set<string> {
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). */ /** Wie viele Klickpunkte die Operation braucht (Drehen = 3, sonst 2). */
export const requiredPoints = (op: TransformOp): number => (op === "rotate" ? 3 : 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 items: AnyItem[] = [];
const ids = new Set(sel.wallIds); const ids = new Set(sel.wallIds);
for (const w of project.walls) if (ids.has(w.id)) items.push({ kind: "wall", wall: w }); for (const w of project.walls) if (ids.has(w.id)) items.push({ kind: "wall", wall: w });
if (sel.drawingId) { const drawIds = drawingIdSet(sel);
const d = project.drawings2d.find((x) => x.id === sel.drawingId); for (const d of project.drawings2d) if (drawIds.has(d.id)) items.push({ kind: "drawing", drawing: d });
if (d) items.push({ kind: "drawing", drawing: d });
}
if (sel.extrudedSolidId) { if (sel.extrudedSolidId) {
const s = (project.extrudedSolids ?? []).find((x) => x.id === sel.extrudedSolidId); const s = (project.extrudedSolids ?? []).find((x) => x.id === sel.extrudedSolidId);
if (s) items.push({ kind: "extrudedSolid", solid: s }); if (s) items.push({ kind: "extrudedSolid", solid: s });
@@ -292,9 +304,10 @@ export function commitTransform(
if (movesOriginal(mode)) { if (movesOriginal(mode)) {
const fn = transformFn(op, pts, 1); const fn = transformFn(op, pts, 1);
walls = walls.map((w) => (wallIds.has(w.id) ? { ...w, start: fn(w.start), end: fn(w.end) } : w)); 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) => 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) { if (sel.extrudedSolidId) {