From 91ed2b2454cccdb07381aa868822553c1a75addd Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 22 Aug 2026 11:17:24 +0200 Subject: [PATCH] Gruppieren + Gruppe betreten/verlassen (Vectorworks-artig) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl/Cmd+G fasst die Auswahl zu einer Gruppe zusammen (Elemente + bereits gewählte Gruppen werden dabei verschachtelt statt vermischt); Ctrl/Cmd+ Shift+G löst eine Gruppenhülle auf (ein Level, Untergruppen bleiben bestehen). Eine Gruppe ist reine Mitgliedschaft (IDs je Elementart) ohne eigenes Transform-Objekt — Verschieben/Kopieren/Löschen/Feld brauchen dadurch keine Sonderbehandlung, eine Gruppen-Auswahl ist einfach eine grosse Mehrfachauswahl. Klick auf ein gruppiertes Element wählt die ganze Gruppe (Einzelklick UND Auswahlrahmen); Doppelklick betritt sie (Isolation): alles ausserhalb wird gedimmt und lässt sich nicht mehr anwählen, ein Knopf oben links verlässt sie wieder. Verschachtelte Gruppen: innerhalb einer betretenen Gruppe sind nur ihre direkten Untergruppen als Gruppe anwählbar, ihre direkten Mitglieder einzeln editierbar. --- src/App.tsx | 100 +++++++++ src/i18n/de.ts | 2 + src/i18n/en.ts | 2 + src/model/types.ts | 33 +++ src/plan/PlanView.tsx | 60 ++++++ src/state/groupActions.test.ts | 203 +++++++++++++++++ src/state/groupActions.ts | 287 +++++++++++++++++++++++++ src/state/selectionSlice.ts | 21 ++ src/state/useKeyboardShortcuts.ts | 52 +++++ src/state/useMouseSelectionHandlers.ts | 97 ++++++++- src/state/useSelectionState.ts | 10 + src/styles.css | 21 ++ src/views/viewportContent.tsx | 71 ++++-- 13 files changed, 940 insertions(+), 19 deletions(-) create mode 100644 src/state/groupActions.test.ts create mode 100644 src/state/groupActions.ts diff --git a/src/App.tsx b/src/App.tsx index eeea446..0672a5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -115,12 +115,15 @@ 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 type { GroupMembership } from "./state/groupActions"; import { useProjectFileActions } from "./state/useProjectFileActions"; import { useProjectActions } from "./state/useProjectActions"; import { useActiveToolKeys, useBooleanOpsShortcut, useDrawingClipboardShortcut, + useGroupShortcut, useSelectAllAndDeleteShortcut, useSplitJoinShortcut, useToolNumberShortcuts, @@ -408,6 +411,10 @@ export default function App() { selectedColumnId, selectedContextObjectIds, setSelectedContextObjectIds, + selectedGroupIds, + setSelectedGroupIds, + groupPath, + setGroupPath, } = useSelectionState(); // ── Kontextmenüs (transienter UI-State, bleibt lokal) ───────────────────── @@ -1155,6 +1162,81 @@ export default function App() { }; useBooleanOpsShortcut(selectedDrawingIds, project.drawings2d, applyBoolean); + // Ctrl/Cmd+G / Ctrl/Cmd+Shift+G = Gruppieren/Auflösen (Vectorworks-artig, + // s. state/groupActions.ts). `applyGroupSelection` setzt nach dem Gruppieren/ + // Auflösen alle betroffenen Auswahl-Kanäle auf einen Schlag. + const applyGroupSelection = (mem: GroupMembership, groupIds: string[]) => { + setSelectedWallIds(mem.wallIds); + setSelectedDrawingIds(mem.drawingIds); + setSelectedCeilingIds(mem.ceilingIds); + setSelectedOpeningIds(mem.openingIds); + setSelectedStairIds(mem.stairIds); + setSelectedRoomIds(mem.roomIds); + setSelectedColumnIds(mem.columnIds); + setSelectedRoofIds(mem.roofIds); + setSelectedGroupIds(groupIds); + }; + useGroupShortcut( + project, + setProject, + { + wallIds: selectedWallIds, + drawingIds: selectedDrawingIds, + ceilingIds: selectedCeilingIds, + roomIds: selectedRoomIds, + stairIds: selectedStairIds, + columnIds: selectedColumnIds, + roofIds: selectedRoofIds, + openingIds: selectedOpeningIds, + }, + selectedGroupIds, + applyGroupSelection, + ); + + // Doppelklick auf ein gruppiertes Element → Gruppe betreten (Isolation): + // alles ausserhalb wird gedimmt/gesperrt gerendert (s. viewportContent.tsx/ + // PlanView „greyed"-Pass) und lässt sich nicht mehr anwählen (s. + // useMouseSelectionHandlers.ts). Auswahl wird geleert, damit man sauber + // "in" der Gruppe startet. + const onEnterGroup = (hit: { + wallId?: string; + drawingId?: string; + ceilingId?: string; + roomId?: string; + stairId?: string; + columnId?: string; + roofId?: string; + }): boolean => { + const kind = hit.wallId + ? "wallIds" + : hit.drawingId + ? "drawingIds" + : hit.ceilingId + ? "ceilingIds" + : hit.roomId + ? "roomIds" + : hit.stairId + ? "stairIds" + : hit.columnId + ? "columnIds" + : hit.roofId + ? "roofIds" + : null; + const id = hit.wallId ?? hit.drawingId ?? hit.ceilingId ?? hit.roomId ?? hit.stairId ?? hit.columnId ?? hit.roofId; + if (!kind || !id) return false; + const gid = containingGroupId(project, kind, id, groupPath); + if (!gid) return false; + setGroupPath([...groupPath, gid]); + applyGroupSelection({ ...EMPTY_MEMBERSHIP }, []); + return true; + }; + // "Gruppe verlassen"-Knopf (oben links im Grundriss): verlässt genau EIN + // Isolations-Level (Vectorworks-Verhalten bei verschachtelten Gruppen). + const onLeaveGroup = () => { + setGroupPath(groupPath.slice(0, -1)); + applyGroupSelection({ ...EMPTY_MEMBERSHIP }, []); + }; + // Alt+Klick im Grundriss = Segment löschen (Schere): PlanView meldet das // getroffene Drawing2D + den Modellpunkt; App ermittelt daraus die nächst- // gelegene Kante (nearestEdgeIndex) und entfernt sie. Geschlossene Form → @@ -2328,6 +2410,9 @@ export default function App() { setSelectedContextObjectIds, selectedSectionLineId, setSelectedSectionLineId, + selectedGroupIds, + setSelectedGroupIds, + groupPath, }, contextMenuState: { setMenu, setStampEditorRoomId, editTextId, setEditTextId, setEditTextMinHeightM }, }); @@ -2780,6 +2865,8 @@ export default function App() { onStampMove={moveRoomStamp} onStampEdit={onOpenStampEditor} onEditDrawingText={onEditDrawingText} + onEnterGroup={onEnterGroup} + groupPath={groupPath} editTextId={editTextId} editTextMinHeightM={editTextMinHeightM} onCommitTextEdit={commitTextEdit} @@ -2819,6 +2906,19 @@ export default function App() { onEdit3dWallTop={onEdit3dWallTop} onEdit3dEnd={onEdit3dEnd} /> + {/* "Gruppe verlassen" (Vectorworks-artig): sichtbar, solange eine + Gruppe betreten ist (Isolation — alles andere gedimmt/gesperrt, + s. viewportContent.tsx). Verlässt genau EIN Level. */} + {groupPath.length > 0 && ( + + )} {/* Command-Line (Rhino-artig) UNTER dem Viewport — nur Viewport-breit. Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */} = { "ribbon.group.custom": "Custom bar", "tools.view.label": "Display", "tools.view.list": "List", + "group.leave": "Leave group", + "group.leave.hint": "Leave isolation — back to normal view (one level)", "tools.view.icons": "Icons", "tools.cat.2d": "2D", "tools.cat.3d": "3D", diff --git a/src/model/types.ts b/src/model/types.ts index 14ef288..83743f7 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -2232,6 +2232,27 @@ export interface LayoutFolder { kind?: "layout" | "master"; } +/** + * Gruppe (Vectorworks-artig) — reine Mitgliedschaft je Elementart, siehe + * {@link Project.groups}. Alle ID-Listen sind additiv; ein Element gehört + * NIE zu mehr als einer Gruppe auf derselben Ebene (das Werkzeug/der Befehl + * stellt das beim Gruppieren sicher, nicht das Modell selbst). + */ +export interface Group { + id: string; + name?: string; + wallIds: string[]; + drawingIds: string[]; + ceilingIds: string[]; + roomIds: string[]; + stairIds: string[]; + columnIds: string[]; + roofIds: string[]; + openingIds: string[]; + /** Verschachtelte Gruppen (diese Gruppe enthält andere Gruppen als Ganzes). */ + groupIds: string[]; +} + export interface Project { id: string; name: string; @@ -2318,6 +2339,18 @@ export interface Project { * Projekte/Tests ohne `columns` gültig bleiben (Default: leer behandeln). */ columns?: Column[]; + /** + * Gruppen (Vectorworks-artig: Cmd+G fasst die Auswahl zu einer Einheit + * zusammen; Doppelklick betritt die Gruppe zum Bearbeiten, alles andere + * wird dabei gedimmt/gesperrt — s. `state/useGroupState.ts`). Eine Gruppe + * ist reine MITGLIEDSCHAFT (IDs je Elementart, wie eine gespeicherte + * Mehrfachauswahl) — kein eigenes Geometrie-/Transform-Objekt, damit + * Verschieben/Kopieren/Löschen weiterhin dieselbe Maschinerie nutzen wie + * eine normale Mehrfachauswahl. `groupIds` erlaubt Verschachtelung (eine + * Gruppe kann andere Gruppen enthalten). Optional, damit bestehende + * Projekte/Tests ohne `groups` gültig bleiben (Default: leer behandeln). + */ + groups?: Group[]; /** * Bibliothek parametrischer Wand-Regelwerke. Optional, damit bestehende * Projekte ohne `parametricWalls` gültig bleiben (Default: leer behandeln). diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 5dc9ddf..26110b3 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -223,6 +223,23 @@ export interface MarqueeSelection { crossing: boolean; } +/** + * Ein per Doppelklick getroffenes, potenziell gruppiertes Element — genau + * EIN Feld gesetzt (analog `PlanSelection`, aber ohne die Klick-spezifischen + * Felder). Für Gruppen-Eintritt (`onEnterGroup`); Formen ohne eigenes + * Polygon-Primitiv (z. B. reine Linien-2D-Elemente) werden hier nicht + * erkannt (kleine, bewusste Vereinfachung — s. `anyGroupableHit`). + */ +export interface GroupableHit { + wallId?: string; + drawingId?: string; + ceilingId?: string; + roomId?: string; + stairId?: string; + columnId?: string; + roofId?: string; +} + export interface PlanViewProps { plan: Plan; /** @@ -322,6 +339,17 @@ export interface PlanViewProps { onStampEdit?: (roomId: string) => void; /** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */ onEditDrawingText?: (drawingId: string) => void; + /** + * Doppelklick auf IRGENDEIN Element (Wand/2D/Decke/Raum/Treppe/Stütze/ + * Dach): meldet, welches Element getroffen wurde (genau EIN Feld gesetzt). + * Der Aufrufer prüft, ob es zu einer Gruppe gehört (Vectorworks „Gruppe + * betreten") und liefert `true`, wenn er die Gruppe betreten hat — dann + * bricht `onDoubleClick` sofort ab, VOR allen anderen Doppelklick-Aktionen + * (Gruppen-Eintritt hat Vorrang, wie in Vectorworks). `false`/kein Handler + * → die übrigen Doppelklick-Prüfungen (Text/Raum-Stempel/Schnittlinie) laufen + * normal weiter. + */ + onEnterGroup?: (hit: GroupableHit) => boolean; /** * Drawing2D-ID, deren Inhalt gerade inline auf der Zeichenfläche editiert * wird (frisch über `text`/`textbox` erzeugt ODER per Doppelklick auf ein @@ -501,6 +529,7 @@ export const PlanView = forwardRef( onStampMove, onStampEdit, onEditDrawingText, + onEnterGroup, editTextId, editTextMinHeightM, onCommitTextEdit, @@ -1087,6 +1116,30 @@ export const PlanView = forwardRef( return null; }; + // Irgendein Element (Wand/Decke/Raum/Treppe/Stütze/Dach/2D) unter dem + // Cursor — für Gruppen-Eintritt per Doppelklick (`onEnterGroup`). Nutzt + // `hitTest` (Polygon-Primitive, deckt alle flächigen Bauteile ab) und + // fällt für linienbasierte 2D-Elemente auf `pickDrawing` zurück (das + // selbst erst nach dessen Definition unten verfügbar ist, daher als + // separate Closure statt inline hier). + const anyGroupableHit = (clientX: number, clientY: number): GroupableHit | null => { + const idx = hitTest(clientX, clientY); + if (idx !== null) { + const p = plan.primitives[idx]; + if (p.kind === "polygon") { + if (p.wallId) return { wallId: p.wallId }; + if (p.ceilingId) return { ceilingId: p.ceilingId }; + if (p.roomId) return { roomId: p.roomId }; + if (p.stairId) return { stairId: p.stairId }; + if (p.columnId) return { columnId: p.columnId }; + if (p.roofId) return { roofId: p.roofId }; + if (p.drawingId) return { drawingId: p.drawingId }; + } + } + const did = pickDrawing(clientX, clientY); + return did ? { drawingId: did } : null; + }; + // Abstand (Bildschirm-Pixel) eines Klicks vom Ring eines Kreis/Bogen-2D- // Elements — bei gefüllten Kreisen zählt auch INNERHALB als Treffer (d=0). // Bögen zusätzlich auf ihre Winkel-Spanne begrenzt (CCW, Modell-Radiant, s. @@ -2002,6 +2055,13 @@ export const PlanView = forwardRef( toolHandlers!.onToolCommit(); return; } + // Gruppen betreten hat VORRANG vor allen anderen Doppelklick-Aktionen + // (Vectorworks: ein gruppiertes Element öffnet zuerst die Gruppe — erst + // ein weiterer Doppelklick INNERHALB träfe dann z. B. den Text-Editor). + if (onEnterGroup) { + const hit = anyGroupableHit(e.clientX, e.clientY); + if (hit && onEnterGroup(hit)) return; + } // Doppelklick auf ein Text-2D-Element → Inhalt bearbeiten (Vorrang vor Raum). if (onEditDrawingText) { const tid = pickDrawingText(e.clientX, e.clientY); diff --git a/src/state/groupActions.test.ts b/src/state/groupActions.test.ts new file mode 100644 index 0000000..bc7ddd6 --- /dev/null +++ b/src/state/groupActions.test.ts @@ -0,0 +1,203 @@ +/** + * `groupActions.ts` — reine Gruppen-Logik (Mitgliedschaft/Verschachtelung/ + * Erzeugen/Auflösen), Vectorworks-artig: Cmd+G fasst die Auswahl zusammen, + * Doppelklick betritt eine Gruppe (Isolation), alles ausserhalb wird + * gedimmt/gesperrt. + */ + +import { describe, it, expect } from "vitest"; +import { + applyGroupDimming, + containingGroupId, + expandToGroups, + groupSelection, + isInsideActiveGroup, + topLevelGroups, + transitiveMembership, + ungroup, +} from "./groupActions"; +import type { Primitive } from "../plan/generatePlan/shared"; +import type { Group, Project } from "../model/types"; + +const EMPTY_MEM = { + wallIds: [] as string[], + drawingIds: [] as string[], + ceilingIds: [] as string[], + roomIds: [] as string[], + stairIds: [] as string[], + columnIds: [] as string[], + roofIds: [] as string[], + openingIds: [] as string[], +}; + +function project(groups: Group[]): Project { + return { + id: "t", + name: "T", + lineStyles: [], + hatches: [], + components: [], + wallTypes: [], + drawingLevels: [], + layers: [], + walls: [], + doors: [], + drawings2d: [], + groups, + }; +} + +describe("groupSelection", () => { + it("erzeugt eine Gruppe aus ≥2 gewählten Elementen", () => { + const p = project([]); + const res = groupSelection(p, { ...EMPTY_MEM, wallIds: ["w1", "w2"] }, []); + expect(res).not.toBeNull(); + expect(res!.project.groups).toHaveLength(1); + expect(res!.project.groups![0]).toMatchObject({ wallIds: ["w1", "w2"], groupIds: [] }); + }); + + it("weniger als 2 Elemente gruppiert nicht (Vectorworks-Konvention)", () => { + const p = project([]); + expect(groupSelection(p, { ...EMPTY_MEM, wallIds: ["w1"] }, [])).toBeNull(); + expect(groupSelection(p, EMPTY_MEM, [])).toBeNull(); + }); + + it("bereits gewählte Gruppen werden als Untergruppen verschachtelt, nicht vermischt", () => { + const inner: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] }; + const p = project([inner]); + const res = groupSelection(p, { ...EMPTY_MEM, wallIds: ["w2"] }, ["g1"]); + expect(res).not.toBeNull(); + const outer = res!.project.groups!.find((g) => g.id === res!.groupId)!; + expect(outer.wallIds).toEqual(["w2"]); + expect(outer.groupIds).toEqual(["g1"]); + }); +}); + +describe("transitiveMembership", () => { + it("löst verschachtelte Gruppen rekursiv auf", () => { + const inner: Group = { id: "g1", wallIds: ["w1"], drawingIds: ["d1"], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] }; + const outer: Group = { id: "g2", wallIds: ["w2"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: ["g1"] }; + const p = project([inner, outer]); + const mem = transitiveMembership(p, "g2"); + expect(mem.wallIds.sort()).toEqual(["w1", "w2"]); + expect(mem.drawingIds).toEqual(["d1"]); + }); + + it("unbekannte Gruppen-ID liefert leere Mitgliedschaft", () => { + const mem = transitiveMembership(project([]), "missing"); + expect(mem).toEqual(EMPTY_MEM); + }); +}); + +describe("topLevelGroups / containingGroupId", () => { + const inner: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] }; + const outer: Group = { id: "g2", wallIds: ["w2"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: ["g1"] }; + const p = project([inner, outer]); + + it("topLevelGroups schliesst verschachtelte Untergruppen aus", () => { + expect(topLevelGroups(p).map((g) => g.id)).toEqual(["g2"]); + }); + + it("ohne Isolation (groupPath leer): findet nur die ÄUSSERE Gruppe für ein Element in g1", () => { + expect(containingGroupId(p, "wallIds", "w1", [])).toBe("g2"); + }); + + it("innerhalb von g2 isoliert: findet g1 (direkte Untergruppe) für w1", () => { + expect(containingGroupId(p, "wallIds", "w1", ["g2"])).toBe("g1"); + }); + + it("innerhalb von g2 isoliert: w2 (direktes Mitglied von g2 selbst) gehört zu KEINER Gruppe mehr (einzeln editierbar)", () => { + expect(containingGroupId(p, "wallIds", "w2", ["g2"])).toBeNull(); + }); + + it("Element ausserhalb jeder Gruppe liefert null", () => { + expect(containingGroupId(p, "wallIds", "w999", [])).toBeNull(); + }); +}); + +describe("isInsideActiveGroup", () => { + const inner: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] }; + const outer: Group = { id: "g2", wallIds: ["w2"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: ["g1"] }; + const p = project([inner, outer]); + + it("ohne Isolation ist alles editierbar", () => { + expect(isInsideActiveGroup(p, "wallIds", "w1", [])).toBe(true); + expect(isInsideActiveGroup(p, "wallIds", "irgendwas", [])).toBe(true); + }); + + it("isoliert in g2: w2 und (transitiv über g1) w1 sind editierbar", () => { + expect(isInsideActiveGroup(p, "wallIds", "w2", ["g2"])).toBe(true); + expect(isInsideActiveGroup(p, "wallIds", "w1", ["g2"])).toBe(true); + }); + + it("isoliert in g2: ein Element ausserhalb der Gruppe ist gesperrt", () => { + expect(isInsideActiveGroup(p, "wallIds", "w-outside", ["g2"])).toBe(false); + }); +}); + +describe("expandToGroups", () => { + const g1: Group = { id: "g1", wallIds: ["w1", "w2"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] }; + const p = project([g1]); + + it("Klick/Marquee-Treffer, der zu einer Gruppe gehört, wird auf die ganze Gruppe erweitert", () => { + const { expanded, groupIds } = expandToGroups(p, { ...EMPTY_MEM, wallIds: ["w1"] }, []); + expect(expanded.wallIds.sort()).toEqual(["w1", "w2"]); // w2 kommt über die Gruppe dazu + expect(groupIds).toEqual(["g1"]); + }); + + it("Treffer ohne Gruppe bleibt einzeln, gemischt mit einem gruppierten Treffer", () => { + const { expanded, groupIds } = expandToGroups(p, { ...EMPTY_MEM, wallIds: ["w1", "w-loose"] }, []); + expect(expanded.wallIds.sort()).toEqual(["w-loose", "w1", "w2"]); + expect(groupIds).toEqual(["g1"]); + }); + + it("Marquee über mehrere Elementarten hinweg erweitert jede Art unabhängig", () => { + const g2: Group = { id: "g2", wallIds: [], drawingIds: ["d1"], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] }; + const p2 = project([g1, g2]); + const { expanded, groupIds } = expandToGroups(p2, { ...EMPTY_MEM, wallIds: ["w1"], drawingIds: ["d1"] }, []); + expect(expanded.wallIds.sort()).toEqual(["w1", "w2"]); + expect(expanded.drawingIds).toEqual(["d1"]); + expect(groupIds.sort()).toEqual(["g1", "g2"]); + }); +}); + +describe("applyGroupDimming", () => { + const g1: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] }; + const p = project([g1]); + const wallInside = { kind: "line", a: { x: 0, y: 0 }, b: { x: 1, y: 0 }, cls: "wall", weightMm: 0.2, wallId: "w1" } as unknown as Primitive; + const wallOutside = { kind: "line", a: { x: 0, y: 0 }, b: { x: 1, y: 0 }, cls: "wall", weightMm: 0.2, wallId: "w-outside" } as unknown as Primitive; + const noOwner = { kind: "line", a: { x: 0, y: 0 }, b: { x: 1, y: 0 }, cls: "grid", weightMm: 0.1 } as unknown as Primitive; + + it("ohne Isolation (groupPath leer) bleibt alles unverändert", () => { + const out = applyGroupDimming([wallInside, wallOutside], p, []); + expect(out).toEqual([wallInside, wallOutside]); + }); + + it("isoliert in g1: das Mitglied bleibt normal, alles andere mit ID wird gedimmt", () => { + const out = applyGroupDimming([wallInside, wallOutside], p, ["g1"]); + expect(out[0].greyed).toBeUndefined(); + expect(out[1].greyed).toBe(true); + }); + + it("Primitive ohne jede ID (Raster o. Ä.) werden nie gedimmt", () => { + const out = applyGroupDimming([noOwner], p, ["g1"]); + expect(out[0].greyed).toBeUndefined(); + }); +}); + +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: [] }; + const outer: Group = { id: "g2", wallIds: ["w2"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: ["g1"] }; + const p = project([inner, outer]); + const res = ungroup(p, "g2"); + expect(res).not.toBeNull(); + expect(res!.project.groups!.map((g) => g.id)).toEqual(["g1"]); // g2 weg, g1 bleibt + expect(res!.members.wallIds).toEqual(["w2"]); + expect(res!.subGroupIds).toEqual(["g1"]); + }); + + it("unbekannte ID liefert null", () => { + expect(ungroup(project([]), "missing")).toBeNull(); + }); +}); diff --git a/src/state/groupActions.ts b/src/state/groupActions.ts new file mode 100644 index 0000000..3d0b305 --- /dev/null +++ b/src/state/groupActions.ts @@ -0,0 +1,287 @@ +// Gruppen (Vectorworks-artig, s. `model/types.ts::Group`): reine Funktionen +// über Mitgliedschaft/Verschachtelung + Erzeugen/Auflösen. Keine React-/Store- +// Abhängigkeit — testbar ohne Mock, wird von `useGroupHandlers.ts` (Klick-/ +// Tastatur-Verdrahtung) und `PlanView.tsx` (Dimm-Rendering) konsumiert. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import type { Group, Project } from "../model/types"; +import { uniqueId } from "../tools/types"; +import type { Primitive } from "../plan/generatePlan/shared"; + +/** Mitglieds-IDs je Elementart — dieselbe Form wie `Group`, ohne `id`/`name`/`groupIds`. */ +export interface GroupMembership { + wallIds: string[]; + drawingIds: string[]; + ceilingIds: string[]; + roomIds: string[]; + stairIds: string[]; + columnIds: string[]; + roofIds: string[]; + openingIds: string[]; +} + +export type MemberKind = keyof GroupMembership; + +export const EMPTY_MEMBERSHIP: GroupMembership = { + wallIds: [], + drawingIds: [], + ceilingIds: [], + roomIds: [], + stairIds: [], + columnIds: [], + roofIds: [], + openingIds: [], +}; + +const KEYS: MemberKind[] = [ + "wallIds", + "drawingIds", + "ceilingIds", + "roomIds", + "stairIds", + "columnIds", + "roofIds", + "openingIds", +]; + +export function mergeMembership(a: GroupMembership, b: GroupMembership): GroupMembership { + const out = { ...a }; + for (const k of KEYS) out[k] = [...new Set([...a[k], ...b[k]])]; + return out; +} + +/** + * Transitive Mitgliedschaft einer Gruppe: löst verschachtelte Gruppen + * (`groupIds`) rekursiv auf und vereinigt alle Mitglieds-IDs. Zyklenschutz + * über `seen` (sollte nie vorkommen — `groupSelection` verhindert das beim + * Erzeugen —, ist aber billig und schützt gegen manuell kaputte Projekte). + */ +export function transitiveMembership( + project: Project, + groupId: string, + seen: Set = new Set(), +): GroupMembership { + if (seen.has(groupId)) return EMPTY_MEMBERSHIP; + seen.add(groupId); + const g = (project.groups ?? []).find((x) => x.id === groupId); + if (!g) return EMPTY_MEMBERSHIP; + let out: GroupMembership = { + wallIds: [...g.wallIds], + drawingIds: [...g.drawingIds], + ceilingIds: [...g.ceilingIds], + roomIds: [...g.roomIds], + stairIds: [...g.stairIds], + columnIds: [...g.columnIds], + roofIds: [...g.roofIds], + openingIds: [...g.openingIds], + }; + for (const sub of g.groupIds) out = mergeMembership(out, transitiveMembership(project, sub, seen)); + return out; +} + +/** IDs aller Gruppen, die als Untergruppe in irgendeiner ANDEREN Gruppe stehen. */ +function nestedGroupIds(project: Project): Set { + const s = new Set(); + for (const g of project.groups ?? []) for (const id of g.groupIds) s.add(id); + return s; +} + +/** Top-Level-Gruppen (nicht Untergruppe einer anderen Gruppe) — Kandidaten ausserhalb jeder Isolation. */ +export function topLevelGroups(project: Project): Group[] { + const nested = nestedGroupIds(project); + return (project.groups ?? []).filter((g) => !nested.has(g.id)); +} + +/** + * Die „sichtbare" Gruppe, die ein Element enthält, relativ zum aktuellen + * Isolations-Kontext `groupPath` (leer = nicht isoliert): + * • nicht isoliert: sucht unter den TOP-LEVEL-Gruppen (verschachtelte + * Untergruppen sind dabei „unsichtbar", man sieht nur die äusserste Hülle). + * • isoliert (aktive Gruppe = letztes Element von `groupPath`): sucht NUR + * unter den DIREKTEN Untergruppen der aktiven Gruppe — direkte Mitglieder + * der aktiven Gruppe selbst zählen NICHT als „von einer Gruppe enthalten" + * (die sind ja gerade einzeln bearbeitbar, weil man in der Gruppe ist). + * `null` = das Element gehört zu keiner (in diesem Kontext sichtbaren) Gruppe. + */ +export function containingGroupId( + project: Project, + kind: MemberKind, + id: string, + groupPath: string[], +): string | null { + let candidates: Group[]; + if (groupPath.length === 0) { + candidates = topLevelGroups(project); + } else { + const active = (project.groups ?? []).find((g) => g.id === groupPath[groupPath.length - 1]); + if (!active) return null; + candidates = active.groupIds + .map((gid) => (project.groups ?? []).find((g) => g.id === gid)) + .filter((g): g is Group => !!g); + } + for (const g of candidates) { + if (transitiveMembership(project, g.id)[kind].includes(id)) return g.id; + } + return null; +} + +/** + * Ob ein Element im aktuellen Isolations-Kontext editierbar ist: ohne aktive + * Gruppe (`groupPath` leer) immer `true`; isoliert nur, wenn das Element + * TRANSITIV zur aktiven (innersten) Gruppe gehört — alles andere ist „grau + * und gesperrt", analog zum bestehenden Ebenen-Sperren-Modus. + */ +export function isInsideActiveGroup( + project: Project, + kind: MemberKind, + id: string, + groupPath: string[], +): boolean { + if (groupPath.length === 0) return true; + const active = groupPath[groupPath.length - 1]; + return transitiveMembership(project, active)[kind].includes(id); +} + +/** + * Erweitert eine rohe Treffer-Mitgliedschaft (aus Einzelklick ODER Marquee, + * beliebig viele IDs je Art) um Gruppen: jede ID, die zu einer im aktuellen + * Isolations-Kontext (`groupPath`) SICHTBAREN Gruppe gehört (s. + * `containingGroupId`), wird durch die TRANSITIVE Mitgliedschaft dieser + * Gruppe ersetzt; IDs ohne Gruppe bleiben einzeln. Liefert zusätzlich die + * Menge der dabei „getroffenen" Gruppen-IDs (für `selectedGroupIds`/die + * Bounding-Box-Anzeige). Deckt Einzelklick (eine ID) UND Marquee (viele IDs + * über mehrere Arten) mit derselben Logik ab. + */ +export function expandToGroups( + project: Project, + raw: GroupMembership, + groupPath: string[], +): { expanded: GroupMembership; groupIds: string[] } { + let expanded: GroupMembership = { ...EMPTY_MEMBERSHIP }; + const groupIds = new Set(); + for (const k of KEYS) { + for (const id of raw[k]) { + const gid = containingGroupId(project, k, id, groupPath); + if (gid) { + groupIds.add(gid); + expanded = mergeMembership(expanded, transitiveMembership(project, gid)); + } else { + expanded = mergeMembership(expanded, { ...EMPTY_MEMBERSHIP, [k]: [id] }); + } + } + } + return { expanded, groupIds: [...groupIds] }; +} + +/** Primitiv-ID-Feld → zugehörige `MemberKind` (Reihenfolge irrelevant). */ +const ID_FIELD_TO_KIND: [string, MemberKind][] = [ + ["wallId", "wallIds"], + ["drawingId", "drawingIds"], + ["ceilingId", "ceilingIds"], + ["roomId", "roomIds"], + ["stairId", "stairIds"], + ["columnId", "columnIds"], + ["roofId", "roofIds"], + ["openingId", "openingIds"], +]; + +/** + * Dimmt (setzt `greyed: true`) jedes Primitiv, das zu einem ECHTEN Element + * (trägt eines der bekannten ID-Felder) gehört, das NICHT transitiv zur + * aktiven (innersten) Gruppe von `groupPath` gehört — die visuelle Seite der + * Isolation (Vectorworks „Gruppe betreten"), analog zum Sperren beim Klicken + * (`isInsideActiveGroup`). Primitive OHNE jede solche ID (Raster, Massstabs- + * leiste o. Ä.) bleiben unverändert. `groupPath` leer → unverändert (kein + * Isolations-Kontext, alles normal sichtbar). + */ +export function applyGroupDimming( + primitives: Primitive[], + project: Project, + groupPath: string[], +): Primitive[] { + if (groupPath.length === 0) return primitives; + const active = groupPath[groupPath.length - 1]; + const mem = transitiveMembership(project, active); + const sets: Record> = { + wallIds: new Set(mem.wallIds), + drawingIds: new Set(mem.drawingIds), + ceilingIds: new Set(mem.ceilingIds), + roomIds: new Set(mem.roomIds), + stairIds: new Set(mem.stairIds), + columnIds: new Set(mem.columnIds), + roofIds: new Set(mem.roofIds), + openingIds: new Set(mem.openingIds), + }; + return primitives.map((p) => { + const rec = p as unknown as Record; + let hasId = false; + let inside = false; + for (const [field, kind] of ID_FIELD_TO_KIND) { + const v = rec[field]; + if (typeof v !== "string") continue; + hasId = true; + if (sets[kind].has(v)) inside = true; + } + return hasId && !inside ? { ...p, greyed: true } : p; + }); +} + +/** + * Erzeugt eine neue Gruppe aus einer Mengen-Auswahl (+ optional bereits + * gewählte Gruppen, die dabei als Untergruppen verschachtelt werden — „zwei + * Gruppen + Cmd+G" fasst sie in einer neuen äusseren Gruppe zusammen, statt + * ihre Mitglieder platt zu vermischen). Leere Auswahl → `null` (nichts zu tun). + */ +export function groupSelection( + project: Project, + sel: GroupMembership, + selectedGroupIds: string[], +): { project: Project; groupId: string } | null { + const totalCount = + KEYS.reduce((n, k) => n + sel[k].length, 0) + selectedGroupIds.length; + if (totalCount < 2) return null; // Gruppieren braucht ≥ 2 Elemente (Vectorworks-Konvention) + const id = uniqueId("grp"); + const g: Group = { + id, + wallIds: sel.wallIds, + drawingIds: sel.drawingIds, + ceilingIds: sel.ceilingIds, + roomIds: sel.roomIds, + stairIds: sel.stairIds, + columnIds: sel.columnIds, + roofIds: sel.roofIds, + openingIds: sel.openingIds, + groupIds: [...selectedGroupIds], + }; + return { project: { ...project, groups: [...(project.groups ?? []), g] }, groupId: id }; +} + +/** + * Löst EINE Gruppenhülle auf (ein Level, Vectorworks-Ungroup): Untergruppen + * bleiben als eigene Gruppen bestehen (werden nicht rekursiv aufgelöst). Die + * direkten Mitglieder (Elemente + Untergruppen-IDs) der aufgelösten Gruppe + * kommen als Ergebnis zurück, damit der Aufrufer sie als neue Auswahl setzen + * kann. `null`, wenn die ID keine Gruppe ist. + */ +export function ungroup( + project: Project, + groupId: string, +): { project: Project; members: GroupMembership; subGroupIds: string[] } | null { + const g = (project.groups ?? []).find((x) => x.id === groupId); + if (!g) return null; + return { + project: { ...project, groups: (project.groups ?? []).filter((x) => x.id !== groupId) }, + members: { + wallIds: g.wallIds, + drawingIds: g.drawingIds, + ceilingIds: g.ceilingIds, + roomIds: g.roomIds, + stairIds: g.stairIds, + columnIds: g.columnIds, + roofIds: g.roofIds, + openingIds: g.openingIds, + }, + subGroupIds: g.groupIds, + }; +} diff --git a/src/state/selectionSlice.ts b/src/state/selectionSlice.ts index 194fb36..144b119 100644 --- a/src/state/selectionSlice.ts +++ b/src/state/selectionSlice.ts @@ -30,6 +30,16 @@ export interface SelectionSlice { // Gewählte Schnitt-/Ansichtslinie (DrawingLevel-Id) — EINZELauswahl (eine Linie // zur Zeit), getrennt von den übrigen Kanälen. `null` = keine Linie gewählt. selectedSectionLineId: string | null; + // Gewählte Gruppen (Vectorworks-artig) als MENGE von IDs — rein informativ/ + // fürs UI (Bounding-Box, Doppelklick-Ziel); die eigentlichen Mitglieder + // stehen zusätzlich in den übrigen `selected…Ids`-Kanälen (Verschieben/ + // Löschen/etc. brauchen so keine Gruppen-Sonderbehandlung), s. `groupActions.ts`. + selectedGroupIds: string[]; + // Isolations-Pfad (Vectorworks „Gruppe betreten"): Stapel der Gruppen-IDs, + // in die per Doppelklick navigiert wurde — leer = nicht isoliert (normal), + // letztes Element = die AKTIVE (innerste) Gruppe. Alles ausserhalb ihrer + // transitiven Mitgliedschaft wird gedimmt/gesperrt (s. `groupActions.ts`). + groupPath: string[]; setSelectedWallIds: (ids: string[]) => void; setSelectedDrawingIds: (ids: string[]) => void; @@ -42,6 +52,8 @@ export interface SelectionSlice { setSelectedRoofIds: (ids: string[]) => void; setSelectedContextObjectIds: (ids: string[]) => void; setSelectedSectionLineId: (id: string | null) => void; + setSelectedGroupIds: (ids: string[]) => void; + setGroupPath: (path: string[]) => void; /** Auswahl (Wände + Decken + Öffnungen + Treppen + Dächer + 2D-Elemente) leeren. */ clearSelection: () => void; } @@ -62,6 +74,8 @@ export function createSelectionSlice( selectedRoofIds: [], selectedContextObjectIds: [], selectedSectionLineId: null, + selectedGroupIds: [], + groupPath: [], setSelectedWallIds: (ids) => set({ selectedWallIds: ids }), setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }), setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }), @@ -73,6 +87,8 @@ export function createSelectionSlice( setSelectedRoofIds: (ids) => set({ selectedRoofIds: ids }), setSelectedContextObjectIds: (ids) => set({ selectedContextObjectIds: ids }), setSelectedSectionLineId: (id) => set({ selectedSectionLineId: id }), + setSelectedGroupIds: (ids) => set({ selectedGroupIds: ids }), + setGroupPath: (path) => set({ groupPath: path }), clearSelection: () => set({ selectedWallIds: [], @@ -86,6 +102,11 @@ export function createSelectionSlice( selectedRoofIds: [], selectedContextObjectIds: [], selectedSectionLineId: null, + selectedGroupIds: [], + // groupPath (Isolation) bleibt bewusst erhalten — Leerraum-Klick INNERHALB + // einer betretenen Gruppe soll nur deren Auswahl leeren, nicht aus der + // Gruppe hinauswerfen (Vectorworks-Verhalten). Verlassen ist ein + // expliziter Schritt (Knopf/Escape, s. `useGroupHandlers.ts`). }), }; } diff --git a/src/state/useKeyboardShortcuts.ts b/src/state/useKeyboardShortcuts.ts index dc3c705..8294de8 100644 --- a/src/state/useKeyboardShortcuts.ts +++ b/src/state/useKeyboardShortcuts.ts @@ -20,6 +20,8 @@ import type { CommandLineHandle } from "../ui/CommandLine"; import type { Drawing2D, Project, Vec2 } from "../model/types"; import { differenceDrawings, intersectionDrawings, unionDrawings } from "../editors/booleanOps"; import type { BooleanResult } from "../editors/booleanOps"; +import { groupSelection, mergeMembership, ungroup, EMPTY_MEMBERSHIP } from "./groupActions"; +import type { GroupMembership } from "./groupActions"; /** Aktive Transformation (Bewegen/Spiegeln/Drehen) — Form aus useDrawingToolState. */ type ActiveTransform = { @@ -570,3 +572,53 @@ export function useTransformShortcuts( engine, ]); } + +/** + * Ctrl/Cmd+G = Auswahl gruppieren (Vectorworks-artig); Ctrl/Cmd+Shift+G = + * gewählte Gruppe(n) auflösen (EIN Level — Untergruppen bleiben bestehen). + * `applySelection` setzt danach alle betroffenen `selected…Ids`-Kanäle + + * `selectedGroupIds` auf einen Schlag (s. App.tsx, dieselbe Form wie die + * Gruppen-Erweiterung beim Klicken in `useMouseSelectionHandlers.ts`). + */ +export function useGroupShortcut( + project: Project, + setProject: (updater: (p: Project) => Project) => void, + currentSelection: GroupMembership, + selectedGroupIds: string[], + applySelection: (mem: GroupMembership, groupIds: string[]) => void, +): void { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!(e.ctrlKey || e.metaKey)) return; + if (e.key.toLowerCase() !== "g") return; + if (isEditableTarget(e)) return; + e.preventDefault(); + if (e.shiftKey) { + // Ungruppieren: für jede gewählte Gruppe eine Hülle auflösen; ihre + // direkten Mitglieder (+ Untergruppen) werden die neue Auswahl. + if (selectedGroupIds.length === 0) return; + let p = project; + let members: GroupMembership = { ...EMPTY_MEMBERSHIP }; + const newGroupIds: string[] = []; + for (const gid of selectedGroupIds) { + const res = ungroup(p, gid); + if (!res) continue; + p = res.project; + members = mergeMembership(members, res.members); + newGroupIds.push(...res.subGroupIds); + } + setProject(() => p); + applySelection(members, newGroupIds); + return; + } + // Gruppieren: aktuelle Auswahl (+ bereits gewählte Gruppen als Untergruppen). + const res = groupSelection(project, currentSelection, selectedGroupIds); + if (!res) return; + setProject(() => res.project); + applySelection(currentSelection, [res.groupId]); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [project, currentSelection, selectedGroupIds]); +} diff --git a/src/state/useMouseSelectionHandlers.ts b/src/state/useMouseSelectionHandlers.ts index e75620f..a0a59a0 100644 --- a/src/state/useMouseSelectionHandlers.ts +++ b/src/state/useMouseSelectionHandlers.ts @@ -16,6 +16,26 @@ import { plainText } from "../text/richText"; import { hitTestWall } from "../viewport/planHitTest"; import type { useSelectionState } from "./useSelectionState"; import type { useContextMenuState } from "../menus/useContextMenuState"; +import { + EMPTY_MEMBERSHIP, + expandToGroups, + isInsideActiveGroup, + type GroupMembership, + type MemberKind, +} from "./groupActions"; + +/** `PlanSelection`s einzelnes Treffer-Feld als (Art, ID) — oder `null` (Leerraum). */ +function selHit(sel: PlanSelection): { kind: MemberKind; id: string } | null { + if (sel.wallId) return { kind: "wallIds", id: sel.wallId }; + if (sel.drawingId) return { kind: "drawingIds", id: sel.drawingId }; + if (sel.ceilingId) return { kind: "ceilingIds", id: sel.ceilingId }; + if (sel.openingId) return { kind: "openingIds", id: sel.openingId }; + if (sel.stairId) return { kind: "stairIds", id: sel.stairId }; + if (sel.roomId) return { kind: "roomIds", id: sel.roomId }; + if (sel.columnId) return { kind: "columnIds", id: sel.columnId }; + if (sel.roofId) return { kind: "roofIds", id: sel.roofId }; + return null; +} export function useMouseSelectionHandlers(opts: { project: Project; @@ -48,6 +68,9 @@ export function useMouseSelectionHandlers(opts: { | "setSelectedContextObjectIds" | "selectedSectionLineId" | "setSelectedSectionLineId" + | "selectedGroupIds" + | "setSelectedGroupIds" + | "groupPath" >; contextMenuState: Pick< ReturnType, @@ -79,6 +102,8 @@ export function useMouseSelectionHandlers(opts: { setSelectedContextObjectIds, selectedSectionLineId, setSelectedSectionLineId, + setSelectedGroupIds, + groupPath, } = selectionState; const { setMenu, setStampEditorRoomId, editTextId, setEditTextId, setEditTextMinHeightM } = contextMenuState; @@ -187,6 +212,11 @@ export function useMouseSelectionHandlers(opts: { // Klick wird ignoriert, als läge dort nichts. const hitCode = categoryCodeOfSelection(sel); if (hitCode !== undefined && categoryDisplay(hitCode).locked) return; + // Gruppen-Isolation (Vectorworks „Gruppe betreten"): ausserhalb der + // aktiven Gruppe ist alles gesperrt (gedimmt gerendert, s. PlanView) — + // derselbe Ignorier-Kurzschluss wie beim normalen Sperren oben. + const hit = selHit(sel); + if (hit && !isInsideActiveGroup(project, hit.kind, hit.id, groupPath)) return; // Shift+Klick: Mehrfach-Auswahl — getroffenes Element zu-/abschalten, den // Rest behalten (Wände und 2D-Zeichnungen unabhängig, gemischte Auswahl // erlaubt). Leerraum-Klick mit Shift lässt die Auswahl unverändert. @@ -271,6 +301,34 @@ export function useMouseSelectionHandlers(opts: { } return; } + // Gruppen-Erweiterung: gehört das getroffene Element zu einer (im + // aktuellen Isolations-Kontext sichtbaren) Gruppe, wird die GANZE Gruppe + // gewählt statt nur dieses eine Element (Vectorworks: eine Gruppe + // verhält sich wie ein einzelnes Objekt, bis man per Doppelklick + // hineingeht — s. `onEnterGroup`/`groupPath`). + if (hit) { + const { expanded, groupIds } = expandToGroups( + project, + { ...EMPTY_MEMBERSHIP, [hit.kind]: [hit.id] }, + groupPath, + ); + if (groupIds.length > 0) { + setSelectedWallIds(expanded.wallIds); + setSelectedDrawingIds(expanded.drawingIds); + setSelectedCeilingIds(expanded.ceilingIds); + setSelectedOpeningIds(expanded.openingIds); + setSelectedStairIds(expanded.stairIds); + setSelectedRoomIds(expanded.roomIds); + setSelectedColumnIds(expanded.columnIds); + setSelectedRoofIds(expanded.roofIds); + setSelectedExtrudedSolidIds([]); + setSelectedContextObjectIds([]); + setSelectedSectionLineId(null); + setSelectedGroupIds(groupIds); + return; + } + } + setSelectedGroupIds([]); // Ohne Shift: ersetzen. Öffnung hat Vorrang (ihr Symbol liegt in der Lücke), // dann Wand, dann 2D-Element, dann Decke; sonst leeren. Die Kanäle schließen // sich gegenseitig aus. @@ -329,11 +387,40 @@ export function useMouseSelectionHandlers(opts: { // Links-Drag (Marquee) im Grundriss: die getroffene Wand-MENGE übernehmen // (nach CAD-Konvention von PlanView bestimmt). Ersetzt die bisherige Auswahl. const onPlanMarquee = (sel: MarqueeSelection) => { - setSelectedWallIds(sel.wallIds); - setSelectedDrawingIds(sel.drawingIds ?? []); - setSelectedCeilingIds(sel.ceilingIds ?? []); - setSelectedStairIds(sel.stairIds ?? []); - setSelectedRoomIds(sel.roomIds ?? []); + const raw: GroupMembership = { + wallIds: sel.wallIds, + drawingIds: sel.drawingIds ?? [], + ceilingIds: sel.ceilingIds ?? [], + roomIds: sel.roomIds ?? [], + stairIds: sel.stairIds ?? [], + columnIds: [], + roofIds: [], + openingIds: [], + }; + // Gruppen-Isolation: ausserhalb der aktiven Gruppe getroffene Elemente + // sind gesperrt (werden ignoriert), wie beim Einzelklick. + const filtered: GroupMembership = + groupPath.length === 0 + ? raw + : { + wallIds: raw.wallIds.filter((id) => isInsideActiveGroup(project, "wallIds", id, groupPath)), + drawingIds: raw.drawingIds.filter((id) => isInsideActiveGroup(project, "drawingIds", id, groupPath)), + ceilingIds: raw.ceilingIds.filter((id) => isInsideActiveGroup(project, "ceilingIds", id, groupPath)), + roomIds: raw.roomIds.filter((id) => isInsideActiveGroup(project, "roomIds", id, groupPath)), + stairIds: raw.stairIds.filter((id) => isInsideActiveGroup(project, "stairIds", id, groupPath)), + columnIds: [], + roofIds: [], + openingIds: [], + }; + // Gruppen-Erweiterung: jedes getroffene Element, das zu einer sichtbaren + // Gruppe gehört, zieht deren ganze (transitive) Mitgliedschaft nach. + const { expanded, groupIds } = expandToGroups(project, filtered, groupPath); + setSelectedWallIds(expanded.wallIds); + setSelectedDrawingIds(expanded.drawingIds); + setSelectedCeilingIds(expanded.ceilingIds); + setSelectedStairIds(expanded.stairIds); + setSelectedRoomIds(expanded.roomIds); + setSelectedGroupIds(groupIds); // Öffnungen/Extrusionen/Stützen/Dächer/Kontext-Objekte sind (noch) nicht // Teil des Marquee (Nutzer-Report betraf nur Decken/Räume/Treppen; Stütze/ // Dach liessen sich mit derselben `marqueeHitByField`-Mechanik ergänzen, diff --git a/src/state/useSelectionState.ts b/src/state/useSelectionState.ts index d452577..1d4b2d8 100644 --- a/src/state/useSelectionState.ts +++ b/src/state/useSelectionState.ts @@ -74,6 +74,12 @@ export function useSelectionState() { selectedColumnIds.length === 1 ? selectedColumnIds[0] : null; const selectedContextObjectIds = useStore((s) => s.selectedContextObjectIds); const setSelectedContextObjectIds = useStore((s) => s.setSelectedContextObjectIds); + // Gruppen (Vectorworks-artig): gewählte Gruppen-IDs + Isolations-Pfad + // ("Gruppe betreten"), s. `groupActions.ts`. + const selectedGroupIds = useStore((s) => s.selectedGroupIds); + const setSelectedGroupIds = useStore((s) => s.setSelectedGroupIds); + const groupPath = useStore((s) => s.groupPath); + const setGroupPath = useStore((s) => s.setGroupPath); return { selectedWallIds, @@ -115,5 +121,9 @@ export function useSelectionState() { selectedColumnId, selectedContextObjectIds, setSelectedContextObjectIds, + selectedGroupIds, + setSelectedGroupIds, + groupPath, + setGroupPath, }; } diff --git a/src/styles.css b/src/styles.css index 678a45e..0547469 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2119,6 +2119,27 @@ body { vector-effect: non-scaling-stroke; } +/* "Gruppe verlassen" (Vectorworks-artig): schwebender Knopf oben links über + dem Grundriss, sichtbar nur solange eine Gruppe betreten ist (Isolation). */ +.plan-leave-group-btn { + position: absolute; + top: 10px; + left: 10px; + z-index: 20; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid var(--accent); + background: var(--field-bg); + color: var(--accent); + font-size: 12px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); +} +.plan-leave-group-btn:hover { + background: var(--accent-dim); +} + /* Auswahl-Rechteck (Marquee, Links-Drag): gestrichelte Akzentkontur, leichte Füllung. Strichstärke + Strichmuster papierkonstant (non-scaling-stroke). Die Default-Variante (links→rechts, „einschließend") ist durchgezogen; die diff --git a/src/views/viewportContent.tsx b/src/views/viewportContent.tsx index d13835b..a7d2333 100644 --- a/src/views/viewportContent.tsx +++ b/src/views/viewportContent.tsx @@ -28,9 +28,11 @@ import type { SectionOutput } from "../plan/toSection"; import { generateElevationPlan } from "../plan/toElevation"; import { pushNativeScene, pushNativeWalls } from "../plan/nativeSync"; import { selectionHighlightLines } from "../plan/toWalls3d"; +import { applyGroupDimming } from "../state/groupActions"; import { PlanView } from "../plan/PlanView"; import type { GripHandlers, + GroupableHit, HudFieldsState, MarqueeSelection, PlanSelection, @@ -94,6 +96,8 @@ export function Content({ onStampMove, onStampEdit, onEditDrawingText, + onEnterGroup, + groupPath, editTextId, editTextMinHeightM, onCommitTextEdit, @@ -187,6 +191,8 @@ export function Content({ onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; onEditDrawingText?: (drawingId: string) => void; + onEnterGroup?: (hit: GroupableHit) => boolean; + groupPath: string[]; editTextId?: string | null; editTextMinHeightM?: number | null; onCommitTextEdit?: (doc: RichTextDoc) => void; @@ -316,6 +322,8 @@ export function Content({ onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + onEnterGroup={onEnterGroup} + groupPath={groupPath} editTextId={editTextId} editTextMinHeightM={editTextMinHeightM} onCommitTextEdit={onCommitTextEdit} @@ -382,6 +390,8 @@ export function Content({ onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + onEnterGroup={onEnterGroup} + groupPath={groupPath} editTextId={editTextId} editTextMinHeightM={editTextMinHeightM} onCommitTextEdit={onCommitTextEdit} @@ -490,6 +500,8 @@ export function Content({ onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + onEnterGroup={onEnterGroup} + groupPath={groupPath} editTextId={editTextId} editTextMinHeightM={editTextMinHeightM} onCommitTextEdit={onCommitTextEdit} @@ -546,6 +558,8 @@ function LevelPlanView({ onStampMove, onStampEdit, onEditDrawingText, + onEnterGroup, + groupPath, editTextId, editTextMinHeightM, onCommitTextEdit, @@ -595,6 +609,8 @@ function LevelPlanView({ onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; onEditDrawingText?: (drawingId: string) => void; + onEnterGroup?: (hit: GroupableHit) => boolean; + groupPath: string[]; editTextId?: string | null; editTextMinHeightM?: number | null; onCommitTextEdit?: (doc: RichTextDoc) => void; @@ -621,19 +637,31 @@ function LevelPlanView({ }) { // Detailgrad + Referenzlinien fließen in die Plan-Erzeugung ein (Symbole, // Schraffuren/Linienfeinheit bzw. Wand-Achslinien). - const plan = useMemo( - () => - generatePlan( - project, - level.id, - visibleCodes, - categoryDisplay, - detail, - referenceLines, - mono, - ), - [project, level.id, visibleCodes, categoryDisplay, detail, referenceLines, mono], - ); + const plan = useMemo(() => { + const p = generatePlan( + project, + level.id, + visibleCodes, + categoryDisplay, + detail, + referenceLines, + mono, + ); + // Gruppen-Isolation ("Gruppe betreten"): alles ausserhalb der aktiven + // Gruppe gedimmt, s. state/groupActions.ts::applyGroupDimming. + return groupPath.length === 0 + ? p + : { ...p, primitives: applyGroupDimming(p.primitives, project, groupPath) }; + }, [ + project, + level.id, + visibleCodes, + categoryDisplay, + detail, + referenceLines, + mono, + groupPath, + ]); // Live-Spiegelung in die nativen wgpu-Fenster (No-op ohne Tauri, debounced): // EXAKT der sichtbare Plan (gleiches Geschoss, gleicher Detailgrad) fürs @@ -681,6 +709,7 @@ function LevelPlanView({ onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + onEnterGroup={onEnterGroup} editTextId={editTextId} editTextMinHeightM={editTextMinHeightM} onCommitTextEdit={onCommitTextEdit} @@ -746,6 +775,8 @@ function SectionPlanView({ onStampMove, onStampEdit, onEditDrawingText, + onEnterGroup, + groupPath, editTextId, editTextMinHeightM, onCommitTextEdit, @@ -790,6 +821,8 @@ function SectionPlanView({ onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; onEditDrawingText?: (drawingId: string) => void; + onEnterGroup?: (hit: GroupableHit) => boolean; + groupPath: string[]; editTextId?: string | null; editTextMinHeightM?: number | null; onCommitTextEdit?: (doc: RichTextDoc) => void; @@ -858,7 +891,16 @@ function SectionPlanView({ : null, [isElevation, output, mono, level.hiddenLines], ); - const plan = isElevation ? elevationPlan : sectionPlan; + const rawPlan = isElevation ? elevationPlan : sectionPlan; + // Gruppen-Isolation ("Gruppe betreten") gilt auch für Schnitt-/Ansichts- + // Pläne, s. state/groupActions.ts::applyGroupDimming. + const plan = useMemo( + () => + rawPlan && groupPath.length > 0 + ? { ...rawPlan, primitives: applyGroupDimming(rawPlan.primitives, project, groupPath) } + : rawPlan, + [rawPlan, project, groupPath], + ); const effStatus = isElevation ? (elevationPlan ? "ready" : "empty") : status; const kindLabel = level.kind === "section" ? t("stub.section") : t("stub.elevation"); @@ -902,6 +944,7 @@ function SectionPlanView({ onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + onEnterGroup={onEnterGroup} editTextId={editTextId} editTextMinHeightM={editTextMinHeightM} onCommitTextEdit={onCommitTextEdit}