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 { usePanelHostBase } from "./state/usePanelHostBase";
|
||||||
import { useDockDragState } from "./state/useDockDragState";
|
import { useDockDragState } from "./state/useDockDragState";
|
||||||
import { useMouseSelectionHandlers } from "./state/useMouseSelectionHandlers";
|
import { useMouseSelectionHandlers } from "./state/useMouseSelectionHandlers";
|
||||||
|
import { containingGroupId, EMPTY_MEMBERSHIP } from "./state/groupActions";
|
||||||
|
import type { GroupMembership } from "./state/groupActions";
|
||||||
import { useProjectFileActions } from "./state/useProjectFileActions";
|
import { useProjectFileActions } from "./state/useProjectFileActions";
|
||||||
import { useProjectActions } from "./state/useProjectActions";
|
import { useProjectActions } from "./state/useProjectActions";
|
||||||
import {
|
import {
|
||||||
useActiveToolKeys,
|
useActiveToolKeys,
|
||||||
useBooleanOpsShortcut,
|
useBooleanOpsShortcut,
|
||||||
useDrawingClipboardShortcut,
|
useDrawingClipboardShortcut,
|
||||||
|
useGroupShortcut,
|
||||||
useSelectAllAndDeleteShortcut,
|
useSelectAllAndDeleteShortcut,
|
||||||
useSplitJoinShortcut,
|
useSplitJoinShortcut,
|
||||||
useToolNumberShortcuts,
|
useToolNumberShortcuts,
|
||||||
@@ -408,6 +411,10 @@ export default function App() {
|
|||||||
selectedColumnId,
|
selectedColumnId,
|
||||||
selectedContextObjectIds,
|
selectedContextObjectIds,
|
||||||
setSelectedContextObjectIds,
|
setSelectedContextObjectIds,
|
||||||
|
selectedGroupIds,
|
||||||
|
setSelectedGroupIds,
|
||||||
|
groupPath,
|
||||||
|
setGroupPath,
|
||||||
} = useSelectionState();
|
} = useSelectionState();
|
||||||
|
|
||||||
// ── Kontextmenüs (transienter UI-State, bleibt lokal) ─────────────────────
|
// ── Kontextmenüs (transienter UI-State, bleibt lokal) ─────────────────────
|
||||||
@@ -1155,6 +1162,81 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
useBooleanOpsShortcut(selectedDrawingIds, project.drawings2d, applyBoolean);
|
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
|
// Alt+Klick im Grundriss = Segment löschen (Schere): PlanView meldet das
|
||||||
// getroffene Drawing2D + den Modellpunkt; App ermittelt daraus die nächst-
|
// getroffene Drawing2D + den Modellpunkt; App ermittelt daraus die nächst-
|
||||||
// gelegene Kante (nearestEdgeIndex) und entfernt sie. Geschlossene Form →
|
// gelegene Kante (nearestEdgeIndex) und entfernt sie. Geschlossene Form →
|
||||||
@@ -2328,6 +2410,9 @@ export default function App() {
|
|||||||
setSelectedContextObjectIds,
|
setSelectedContextObjectIds,
|
||||||
selectedSectionLineId,
|
selectedSectionLineId,
|
||||||
setSelectedSectionLineId,
|
setSelectedSectionLineId,
|
||||||
|
selectedGroupIds,
|
||||||
|
setSelectedGroupIds,
|
||||||
|
groupPath,
|
||||||
},
|
},
|
||||||
contextMenuState: { setMenu, setStampEditorRoomId, editTextId, setEditTextId, setEditTextMinHeightM },
|
contextMenuState: { setMenu, setStampEditorRoomId, editTextId, setEditTextId, setEditTextMinHeightM },
|
||||||
});
|
});
|
||||||
@@ -2780,6 +2865,8 @@ export default function App() {
|
|||||||
onStampMove={moveRoomStamp}
|
onStampMove={moveRoomStamp}
|
||||||
onStampEdit={onOpenStampEditor}
|
onStampEdit={onOpenStampEditor}
|
||||||
onEditDrawingText={onEditDrawingText}
|
onEditDrawingText={onEditDrawingText}
|
||||||
|
onEnterGroup={onEnterGroup}
|
||||||
|
groupPath={groupPath}
|
||||||
editTextId={editTextId}
|
editTextId={editTextId}
|
||||||
editTextMinHeightM={editTextMinHeightM}
|
editTextMinHeightM={editTextMinHeightM}
|
||||||
onCommitTextEdit={commitTextEdit}
|
onCommitTextEdit={commitTextEdit}
|
||||||
@@ -2819,6 +2906,19 @@ export default function App() {
|
|||||||
onEdit3dWallTop={onEdit3dWallTop}
|
onEdit3dWallTop={onEdit3dWallTop}
|
||||||
onEdit3dEnd={onEdit3dEnd}
|
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.
|
{/* Command-Line (Rhino-artig) UNTER dem Viewport — nur Viewport-breit.
|
||||||
Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */}
|
Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */}
|
||||||
<CommandLine
|
<CommandLine
|
||||||
|
|||||||
@@ -210,6 +210,8 @@ export const de = {
|
|||||||
"ribbon.group.custom": "Eigene Leiste",
|
"ribbon.group.custom": "Eigene Leiste",
|
||||||
"tools.view.label": "Darstellung",
|
"tools.view.label": "Darstellung",
|
||||||
"tools.view.list": "Liste",
|
"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.view.icons": "Symbole",
|
||||||
"tools.cat.2d": "2D",
|
"tools.cat.2d": "2D",
|
||||||
"tools.cat.3d": "3D",
|
"tools.cat.3d": "3D",
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"ribbon.group.custom": "Custom bar",
|
"ribbon.group.custom": "Custom bar",
|
||||||
"tools.view.label": "Display",
|
"tools.view.label": "Display",
|
||||||
"tools.view.list": "List",
|
"tools.view.list": "List",
|
||||||
|
"group.leave": "Leave group",
|
||||||
|
"group.leave.hint": "Leave isolation — back to normal view (one level)",
|
||||||
"tools.view.icons": "Icons",
|
"tools.view.icons": "Icons",
|
||||||
"tools.cat.2d": "2D",
|
"tools.cat.2d": "2D",
|
||||||
"tools.cat.3d": "3D",
|
"tools.cat.3d": "3D",
|
||||||
|
|||||||
@@ -2232,6 +2232,27 @@ export interface LayoutFolder {
|
|||||||
kind?: "layout" | "master";
|
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 {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2318,6 +2339,18 @@ export interface Project {
|
|||||||
* Projekte/Tests ohne `columns` gültig bleiben (Default: leer behandeln).
|
* Projekte/Tests ohne `columns` gültig bleiben (Default: leer behandeln).
|
||||||
*/
|
*/
|
||||||
columns?: Column[];
|
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
|
* Bibliothek parametrischer Wand-Regelwerke. Optional, damit bestehende
|
||||||
* Projekte ohne `parametricWalls` gültig bleiben (Default: leer behandeln).
|
* Projekte ohne `parametricWalls` gültig bleiben (Default: leer behandeln).
|
||||||
|
|||||||
@@ -223,6 +223,23 @@ export interface MarqueeSelection {
|
|||||||
crossing: boolean;
|
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 {
|
export interface PlanViewProps {
|
||||||
plan: Plan;
|
plan: Plan;
|
||||||
/**
|
/**
|
||||||
@@ -322,6 +339,17 @@ export interface PlanViewProps {
|
|||||||
onStampEdit?: (roomId: string) => void;
|
onStampEdit?: (roomId: string) => void;
|
||||||
/** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */
|
/** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */
|
||||||
onEditDrawingText?: (drawingId: string) => void;
|
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
|
* Drawing2D-ID, deren Inhalt gerade inline auf der Zeichenfläche editiert
|
||||||
* wird (frisch über `text`/`textbox` erzeugt ODER per Doppelklick auf ein
|
* wird (frisch über `text`/`textbox` erzeugt ODER per Doppelklick auf ein
|
||||||
@@ -501,6 +529,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
onStampMove,
|
onStampMove,
|
||||||
onStampEdit,
|
onStampEdit,
|
||||||
onEditDrawingText,
|
onEditDrawingText,
|
||||||
|
onEnterGroup,
|
||||||
editTextId,
|
editTextId,
|
||||||
editTextMinHeightM,
|
editTextMinHeightM,
|
||||||
onCommitTextEdit,
|
onCommitTextEdit,
|
||||||
@@ -1087,6 +1116,30 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
return null;
|
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-
|
// Abstand (Bildschirm-Pixel) eines Klicks vom Ring eines Kreis/Bogen-2D-
|
||||||
// Elements — bei gefüllten Kreisen zählt auch INNERHALB als Treffer (d=0).
|
// 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.
|
// 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();
|
toolHandlers!.onToolCommit();
|
||||||
return;
|
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).
|
// Doppelklick auf ein Text-2D-Element → Inhalt bearbeiten (Vorrang vor Raum).
|
||||||
if (onEditDrawingText) {
|
if (onEditDrawingText) {
|
||||||
const tid = pickDrawingText(e.clientX, e.clientY);
|
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
|
// Gewählte Schnitt-/Ansichtslinie (DrawingLevel-Id) — EINZELauswahl (eine Linie
|
||||||
// zur Zeit), getrennt von den übrigen Kanälen. `null` = keine Linie gewählt.
|
// zur Zeit), getrennt von den übrigen Kanälen. `null` = keine Linie gewählt.
|
||||||
selectedSectionLineId: string | null;
|
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;
|
setSelectedWallIds: (ids: string[]) => void;
|
||||||
setSelectedDrawingIds: (ids: string[]) => void;
|
setSelectedDrawingIds: (ids: string[]) => void;
|
||||||
@@ -42,6 +52,8 @@ export interface SelectionSlice {
|
|||||||
setSelectedRoofIds: (ids: string[]) => void;
|
setSelectedRoofIds: (ids: string[]) => void;
|
||||||
setSelectedContextObjectIds: (ids: string[]) => void;
|
setSelectedContextObjectIds: (ids: string[]) => void;
|
||||||
setSelectedSectionLineId: (id: string | null) => 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. */
|
/** Auswahl (Wände + Decken + Öffnungen + Treppen + Dächer + 2D-Elemente) leeren. */
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
}
|
}
|
||||||
@@ -62,6 +74,8 @@ export function createSelectionSlice(
|
|||||||
selectedRoofIds: [],
|
selectedRoofIds: [],
|
||||||
selectedContextObjectIds: [],
|
selectedContextObjectIds: [],
|
||||||
selectedSectionLineId: null,
|
selectedSectionLineId: null,
|
||||||
|
selectedGroupIds: [],
|
||||||
|
groupPath: [],
|
||||||
setSelectedWallIds: (ids) => set({ selectedWallIds: ids }),
|
setSelectedWallIds: (ids) => set({ selectedWallIds: ids }),
|
||||||
setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }),
|
setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }),
|
||||||
setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }),
|
setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }),
|
||||||
@@ -73,6 +87,8 @@ export function createSelectionSlice(
|
|||||||
setSelectedRoofIds: (ids) => set({ selectedRoofIds: ids }),
|
setSelectedRoofIds: (ids) => set({ selectedRoofIds: ids }),
|
||||||
setSelectedContextObjectIds: (ids) => set({ selectedContextObjectIds: ids }),
|
setSelectedContextObjectIds: (ids) => set({ selectedContextObjectIds: ids }),
|
||||||
setSelectedSectionLineId: (id) => set({ selectedSectionLineId: id }),
|
setSelectedSectionLineId: (id) => set({ selectedSectionLineId: id }),
|
||||||
|
setSelectedGroupIds: (ids) => set({ selectedGroupIds: ids }),
|
||||||
|
setGroupPath: (path) => set({ groupPath: path }),
|
||||||
clearSelection: () =>
|
clearSelection: () =>
|
||||||
set({
|
set({
|
||||||
selectedWallIds: [],
|
selectedWallIds: [],
|
||||||
@@ -86,6 +102,11 @@ export function createSelectionSlice(
|
|||||||
selectedRoofIds: [],
|
selectedRoofIds: [],
|
||||||
selectedContextObjectIds: [],
|
selectedContextObjectIds: [],
|
||||||
selectedSectionLineId: null,
|
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 type { Drawing2D, Project, Vec2 } from "../model/types";
|
||||||
import { differenceDrawings, intersectionDrawings, unionDrawings } from "../editors/booleanOps";
|
import { differenceDrawings, intersectionDrawings, unionDrawings } from "../editors/booleanOps";
|
||||||
import type { BooleanResult } 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. */
|
/** Aktive Transformation (Bewegen/Spiegeln/Drehen) — Form aus useDrawingToolState. */
|
||||||
type ActiveTransform = {
|
type ActiveTransform = {
|
||||||
@@ -570,3 +572,53 @@ export function useTransformShortcuts(
|
|||||||
engine,
|
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 { hitTestWall } from "../viewport/planHitTest";
|
||||||
import type { useSelectionState } from "./useSelectionState";
|
import type { useSelectionState } from "./useSelectionState";
|
||||||
import type { useContextMenuState } from "../menus/useContextMenuState";
|
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: {
|
export function useMouseSelectionHandlers(opts: {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -48,6 +68,9 @@ export function useMouseSelectionHandlers(opts: {
|
|||||||
| "setSelectedContextObjectIds"
|
| "setSelectedContextObjectIds"
|
||||||
| "selectedSectionLineId"
|
| "selectedSectionLineId"
|
||||||
| "setSelectedSectionLineId"
|
| "setSelectedSectionLineId"
|
||||||
|
| "selectedGroupIds"
|
||||||
|
| "setSelectedGroupIds"
|
||||||
|
| "groupPath"
|
||||||
>;
|
>;
|
||||||
contextMenuState: Pick<
|
contextMenuState: Pick<
|
||||||
ReturnType<typeof useContextMenuState>,
|
ReturnType<typeof useContextMenuState>,
|
||||||
@@ -79,6 +102,8 @@ export function useMouseSelectionHandlers(opts: {
|
|||||||
setSelectedContextObjectIds,
|
setSelectedContextObjectIds,
|
||||||
selectedSectionLineId,
|
selectedSectionLineId,
|
||||||
setSelectedSectionLineId,
|
setSelectedSectionLineId,
|
||||||
|
setSelectedGroupIds,
|
||||||
|
groupPath,
|
||||||
} = selectionState;
|
} = selectionState;
|
||||||
const { setMenu, setStampEditorRoomId, editTextId, setEditTextId, setEditTextMinHeightM } = contextMenuState;
|
const { setMenu, setStampEditorRoomId, editTextId, setEditTextId, setEditTextMinHeightM } = contextMenuState;
|
||||||
|
|
||||||
@@ -187,6 +212,11 @@ export function useMouseSelectionHandlers(opts: {
|
|||||||
// Klick wird ignoriert, als läge dort nichts.
|
// Klick wird ignoriert, als läge dort nichts.
|
||||||
const hitCode = categoryCodeOfSelection(sel);
|
const hitCode = categoryCodeOfSelection(sel);
|
||||||
if (hitCode !== undefined && categoryDisplay(hitCode).locked) return;
|
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
|
// Shift+Klick: Mehrfach-Auswahl — getroffenes Element zu-/abschalten, den
|
||||||
// Rest behalten (Wände und 2D-Zeichnungen unabhängig, gemischte Auswahl
|
// Rest behalten (Wände und 2D-Zeichnungen unabhängig, gemischte Auswahl
|
||||||
// erlaubt). Leerraum-Klick mit Shift lässt die Auswahl unverändert.
|
// erlaubt). Leerraum-Klick mit Shift lässt die Auswahl unverändert.
|
||||||
@@ -271,6 +301,34 @@ export function useMouseSelectionHandlers(opts: {
|
|||||||
}
|
}
|
||||||
return;
|
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),
|
// 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
|
// dann Wand, dann 2D-Element, dann Decke; sonst leeren. Die Kanäle schließen
|
||||||
// sich gegenseitig aus.
|
// sich gegenseitig aus.
|
||||||
@@ -329,11 +387,40 @@ export function useMouseSelectionHandlers(opts: {
|
|||||||
// Links-Drag (Marquee) im Grundriss: die getroffene Wand-MENGE übernehmen
|
// Links-Drag (Marquee) im Grundriss: die getroffene Wand-MENGE übernehmen
|
||||||
// (nach CAD-Konvention von PlanView bestimmt). Ersetzt die bisherige Auswahl.
|
// (nach CAD-Konvention von PlanView bestimmt). Ersetzt die bisherige Auswahl.
|
||||||
const onPlanMarquee = (sel: MarqueeSelection) => {
|
const onPlanMarquee = (sel: MarqueeSelection) => {
|
||||||
setSelectedWallIds(sel.wallIds);
|
const raw: GroupMembership = {
|
||||||
setSelectedDrawingIds(sel.drawingIds ?? []);
|
wallIds: sel.wallIds,
|
||||||
setSelectedCeilingIds(sel.ceilingIds ?? []);
|
drawingIds: sel.drawingIds ?? [],
|
||||||
setSelectedStairIds(sel.stairIds ?? []);
|
ceilingIds: sel.ceilingIds ?? [],
|
||||||
setSelectedRoomIds(sel.roomIds ?? []);
|
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
|
// Öffnungen/Extrusionen/Stützen/Dächer/Kontext-Objekte sind (noch) nicht
|
||||||
// Teil des Marquee (Nutzer-Report betraf nur Decken/Räume/Treppen; Stütze/
|
// Teil des Marquee (Nutzer-Report betraf nur Decken/Räume/Treppen; Stütze/
|
||||||
// Dach liessen sich mit derselben `marqueeHitByField`-Mechanik ergänzen,
|
// Dach liessen sich mit derselben `marqueeHitByField`-Mechanik ergänzen,
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ export function useSelectionState() {
|
|||||||
selectedColumnIds.length === 1 ? selectedColumnIds[0] : null;
|
selectedColumnIds.length === 1 ? selectedColumnIds[0] : null;
|
||||||
const selectedContextObjectIds = useStore((s) => s.selectedContextObjectIds);
|
const selectedContextObjectIds = useStore((s) => s.selectedContextObjectIds);
|
||||||
const setSelectedContextObjectIds = useStore((s) => s.setSelectedContextObjectIds);
|
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 {
|
return {
|
||||||
selectedWallIds,
|
selectedWallIds,
|
||||||
@@ -115,5 +121,9 @@ export function useSelectionState() {
|
|||||||
selectedColumnId,
|
selectedColumnId,
|
||||||
selectedContextObjectIds,
|
selectedContextObjectIds,
|
||||||
setSelectedContextObjectIds,
|
setSelectedContextObjectIds,
|
||||||
|
selectedGroupIds,
|
||||||
|
setSelectedGroupIds,
|
||||||
|
groupPath,
|
||||||
|
setGroupPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2119,6 +2119,27 @@ body {
|
|||||||
vector-effect: non-scaling-stroke;
|
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
|
/* Auswahl-Rechteck (Marquee, Links-Drag): gestrichelte Akzentkontur, leichte
|
||||||
Füllung. Strichstärke + Strichmuster papierkonstant (non-scaling-stroke). Die
|
Füllung. Strichstärke + Strichmuster papierkonstant (non-scaling-stroke). Die
|
||||||
Default-Variante (links→rechts, „einschließend") ist durchgezogen; 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 { generateElevationPlan } from "../plan/toElevation";
|
||||||
import { pushNativeScene, pushNativeWalls } from "../plan/nativeSync";
|
import { pushNativeScene, pushNativeWalls } from "../plan/nativeSync";
|
||||||
import { selectionHighlightLines } from "../plan/toWalls3d";
|
import { selectionHighlightLines } from "../plan/toWalls3d";
|
||||||
|
import { applyGroupDimming } from "../state/groupActions";
|
||||||
import { PlanView } from "../plan/PlanView";
|
import { PlanView } from "../plan/PlanView";
|
||||||
import type {
|
import type {
|
||||||
GripHandlers,
|
GripHandlers,
|
||||||
|
GroupableHit,
|
||||||
HudFieldsState,
|
HudFieldsState,
|
||||||
MarqueeSelection,
|
MarqueeSelection,
|
||||||
PlanSelection,
|
PlanSelection,
|
||||||
@@ -94,6 +96,8 @@ export function Content({
|
|||||||
onStampMove,
|
onStampMove,
|
||||||
onStampEdit,
|
onStampEdit,
|
||||||
onEditDrawingText,
|
onEditDrawingText,
|
||||||
|
onEnterGroup,
|
||||||
|
groupPath,
|
||||||
editTextId,
|
editTextId,
|
||||||
editTextMinHeightM,
|
editTextMinHeightM,
|
||||||
onCommitTextEdit,
|
onCommitTextEdit,
|
||||||
@@ -187,6 +191,8 @@ export function Content({
|
|||||||
onStampMove: (roomId: string, delta: Vec2) => void;
|
onStampMove: (roomId: string, delta: Vec2) => void;
|
||||||
onStampEdit: (roomId: string) => void;
|
onStampEdit: (roomId: string) => void;
|
||||||
onEditDrawingText?: (drawingId: string) => void;
|
onEditDrawingText?: (drawingId: string) => void;
|
||||||
|
onEnterGroup?: (hit: GroupableHit) => boolean;
|
||||||
|
groupPath: string[];
|
||||||
editTextId?: string | null;
|
editTextId?: string | null;
|
||||||
editTextMinHeightM?: number | null;
|
editTextMinHeightM?: number | null;
|
||||||
onCommitTextEdit?: (doc: RichTextDoc) => void;
|
onCommitTextEdit?: (doc: RichTextDoc) => void;
|
||||||
@@ -316,6 +322,8 @@ export function Content({
|
|||||||
onStampMove={onStampMove}
|
onStampMove={onStampMove}
|
||||||
onStampEdit={onStampEdit}
|
onStampEdit={onStampEdit}
|
||||||
onEditDrawingText={onEditDrawingText}
|
onEditDrawingText={onEditDrawingText}
|
||||||
|
onEnterGroup={onEnterGroup}
|
||||||
|
groupPath={groupPath}
|
||||||
editTextId={editTextId}
|
editTextId={editTextId}
|
||||||
editTextMinHeightM={editTextMinHeightM}
|
editTextMinHeightM={editTextMinHeightM}
|
||||||
onCommitTextEdit={onCommitTextEdit}
|
onCommitTextEdit={onCommitTextEdit}
|
||||||
@@ -382,6 +390,8 @@ export function Content({
|
|||||||
onStampMove={onStampMove}
|
onStampMove={onStampMove}
|
||||||
onStampEdit={onStampEdit}
|
onStampEdit={onStampEdit}
|
||||||
onEditDrawingText={onEditDrawingText}
|
onEditDrawingText={onEditDrawingText}
|
||||||
|
onEnterGroup={onEnterGroup}
|
||||||
|
groupPath={groupPath}
|
||||||
editTextId={editTextId}
|
editTextId={editTextId}
|
||||||
editTextMinHeightM={editTextMinHeightM}
|
editTextMinHeightM={editTextMinHeightM}
|
||||||
onCommitTextEdit={onCommitTextEdit}
|
onCommitTextEdit={onCommitTextEdit}
|
||||||
@@ -490,6 +500,8 @@ export function Content({
|
|||||||
onStampMove={onStampMove}
|
onStampMove={onStampMove}
|
||||||
onStampEdit={onStampEdit}
|
onStampEdit={onStampEdit}
|
||||||
onEditDrawingText={onEditDrawingText}
|
onEditDrawingText={onEditDrawingText}
|
||||||
|
onEnterGroup={onEnterGroup}
|
||||||
|
groupPath={groupPath}
|
||||||
editTextId={editTextId}
|
editTextId={editTextId}
|
||||||
editTextMinHeightM={editTextMinHeightM}
|
editTextMinHeightM={editTextMinHeightM}
|
||||||
onCommitTextEdit={onCommitTextEdit}
|
onCommitTextEdit={onCommitTextEdit}
|
||||||
@@ -546,6 +558,8 @@ function LevelPlanView({
|
|||||||
onStampMove,
|
onStampMove,
|
||||||
onStampEdit,
|
onStampEdit,
|
||||||
onEditDrawingText,
|
onEditDrawingText,
|
||||||
|
onEnterGroup,
|
||||||
|
groupPath,
|
||||||
editTextId,
|
editTextId,
|
||||||
editTextMinHeightM,
|
editTextMinHeightM,
|
||||||
onCommitTextEdit,
|
onCommitTextEdit,
|
||||||
@@ -595,6 +609,8 @@ function LevelPlanView({
|
|||||||
onStampMove: (roomId: string, delta: Vec2) => void;
|
onStampMove: (roomId: string, delta: Vec2) => void;
|
||||||
onStampEdit: (roomId: string) => void;
|
onStampEdit: (roomId: string) => void;
|
||||||
onEditDrawingText?: (drawingId: string) => void;
|
onEditDrawingText?: (drawingId: string) => void;
|
||||||
|
onEnterGroup?: (hit: GroupableHit) => boolean;
|
||||||
|
groupPath: string[];
|
||||||
editTextId?: string | null;
|
editTextId?: string | null;
|
||||||
editTextMinHeightM?: number | null;
|
editTextMinHeightM?: number | null;
|
||||||
onCommitTextEdit?: (doc: RichTextDoc) => void;
|
onCommitTextEdit?: (doc: RichTextDoc) => void;
|
||||||
@@ -621,19 +637,31 @@ function LevelPlanView({
|
|||||||
}) {
|
}) {
|
||||||
// Detailgrad + Referenzlinien fließen in die Plan-Erzeugung ein (Symbole,
|
// Detailgrad + Referenzlinien fließen in die Plan-Erzeugung ein (Symbole,
|
||||||
// Schraffuren/Linienfeinheit bzw. Wand-Achslinien).
|
// Schraffuren/Linienfeinheit bzw. Wand-Achslinien).
|
||||||
const plan = useMemo(
|
const plan = useMemo(() => {
|
||||||
() =>
|
const p = generatePlan(
|
||||||
generatePlan(
|
project,
|
||||||
project,
|
level.id,
|
||||||
level.id,
|
visibleCodes,
|
||||||
visibleCodes,
|
categoryDisplay,
|
||||||
categoryDisplay,
|
detail,
|
||||||
detail,
|
referenceLines,
|
||||||
referenceLines,
|
mono,
|
||||||
mono,
|
);
|
||||||
),
|
// Gruppen-Isolation ("Gruppe betreten"): alles ausserhalb der aktiven
|
||||||
[project, level.id, visibleCodes, categoryDisplay, detail, referenceLines, mono],
|
// 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):
|
// Live-Spiegelung in die nativen wgpu-Fenster (No-op ohne Tauri, debounced):
|
||||||
// EXAKT der sichtbare Plan (gleiches Geschoss, gleicher Detailgrad) fürs
|
// EXAKT der sichtbare Plan (gleiches Geschoss, gleicher Detailgrad) fürs
|
||||||
@@ -681,6 +709,7 @@ function LevelPlanView({
|
|||||||
onStampMove={onStampMove}
|
onStampMove={onStampMove}
|
||||||
onStampEdit={onStampEdit}
|
onStampEdit={onStampEdit}
|
||||||
onEditDrawingText={onEditDrawingText}
|
onEditDrawingText={onEditDrawingText}
|
||||||
|
onEnterGroup={onEnterGroup}
|
||||||
editTextId={editTextId}
|
editTextId={editTextId}
|
||||||
editTextMinHeightM={editTextMinHeightM}
|
editTextMinHeightM={editTextMinHeightM}
|
||||||
onCommitTextEdit={onCommitTextEdit}
|
onCommitTextEdit={onCommitTextEdit}
|
||||||
@@ -746,6 +775,8 @@ function SectionPlanView({
|
|||||||
onStampMove,
|
onStampMove,
|
||||||
onStampEdit,
|
onStampEdit,
|
||||||
onEditDrawingText,
|
onEditDrawingText,
|
||||||
|
onEnterGroup,
|
||||||
|
groupPath,
|
||||||
editTextId,
|
editTextId,
|
||||||
editTextMinHeightM,
|
editTextMinHeightM,
|
||||||
onCommitTextEdit,
|
onCommitTextEdit,
|
||||||
@@ -790,6 +821,8 @@ function SectionPlanView({
|
|||||||
onStampMove: (roomId: string, delta: Vec2) => void;
|
onStampMove: (roomId: string, delta: Vec2) => void;
|
||||||
onStampEdit: (roomId: string) => void;
|
onStampEdit: (roomId: string) => void;
|
||||||
onEditDrawingText?: (drawingId: string) => void;
|
onEditDrawingText?: (drawingId: string) => void;
|
||||||
|
onEnterGroup?: (hit: GroupableHit) => boolean;
|
||||||
|
groupPath: string[];
|
||||||
editTextId?: string | null;
|
editTextId?: string | null;
|
||||||
editTextMinHeightM?: number | null;
|
editTextMinHeightM?: number | null;
|
||||||
onCommitTextEdit?: (doc: RichTextDoc) => void;
|
onCommitTextEdit?: (doc: RichTextDoc) => void;
|
||||||
@@ -858,7 +891,16 @@ function SectionPlanView({
|
|||||||
: null,
|
: null,
|
||||||
[isElevation, output, mono, level.hiddenLines],
|
[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 effStatus = isElevation ? (elevationPlan ? "ready" : "empty") : status;
|
||||||
|
|
||||||
const kindLabel = level.kind === "section" ? t("stub.section") : t("stub.elevation");
|
const kindLabel = level.kind === "section" ? t("stub.section") : t("stub.elevation");
|
||||||
@@ -902,6 +944,7 @@ function SectionPlanView({
|
|||||||
onStampMove={onStampMove}
|
onStampMove={onStampMove}
|
||||||
onStampEdit={onStampEdit}
|
onStampEdit={onStampEdit}
|
||||||
onEditDrawingText={onEditDrawingText}
|
onEditDrawingText={onEditDrawingText}
|
||||||
|
onEnterGroup={onEnterGroup}
|
||||||
editTextId={editTextId}
|
editTextId={editTextId}
|
||||||
editTextMinHeightM={editTextMinHeightM}
|
editTextMinHeightM={editTextMinHeightM}
|
||||||
onCommitTextEdit={onCommitTextEdit}
|
onCommitTextEdit={onCommitTextEdit}
|
||||||
|
|||||||
Reference in New Issue
Block a user