Gruppieren + Gruppe betreten/verlassen (Vectorworks-artig)

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.
This commit is contained in:
2026-08-22 11:17:24 +02:00
parent 19beb8e632
commit 91ed2b2454
13 changed files with 940 additions and 19 deletions
+100
View File
@@ -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 && (
<button
type="button"
className="plan-leave-group-btn"
onClick={onLeaveGroup}
title={t("group.leave.hint")}
>
{t("group.leave")}
</button>
)}
{/* Command-Line (Rhino-artig) UNTER dem Viewport — nur Viewport-breit.
Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */}
<CommandLine
+2
View File
@@ -210,6 +210,8 @@ export const de = {
"ribbon.group.custom": "Eigene Leiste",
"tools.view.label": "Darstellung",
"tools.view.list": "Liste",
"group.leave": "Gruppe verlassen",
"group.leave.hint": "Isolation verlassen — zurück zur normalen Ansicht (ein Level)",
"tools.view.icons": "Symbole",
"tools.cat.2d": "2D",
"tools.cat.3d": "3D",
+2
View File
@@ -208,6 +208,8 @@ export const en: Record<TranslationKey, string> = {
"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",
+33
View File
@@ -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).
+60
View File
@@ -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<PlanViewHandle, PlanViewProps>(
onStampMove,
onStampEdit,
onEditDrawingText,
onEnterGroup,
editTextId,
editTextMinHeightM,
onCommitTextEdit,
@@ -1087,6 +1116,30 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
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<PlanViewHandle, PlanViewProps>(
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);
+203
View File
@@ -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();
});
});
+287
View File
@@ -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<string> = 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<string> {
const s = new Set<string>();
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<string>();
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<MemberKind, Set<string>> = {
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<string, unknown>;
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,
};
}
+21
View File
@@ -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`).
}),
};
}
+52
View File
@@ -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]);
}
+92 -5
View File
@@ -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<typeof useContextMenuState>,
@@ -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,
+10
View File
@@ -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,
};
}
+21
View File
@@ -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
+57 -14
View File
@@ -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}