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:
+100
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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`).
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user