Compare commits
31 Commits
d9cbf2cb07
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 91908b1456 | |||
| 3be728c80e | |||
| ca8e56412f | |||
| 77f1fc4f70 | |||
| 53144c8b06 | |||
| 91ed2b2454 | |||
| 19beb8e632 | |||
| cad544ebd7 | |||
| 467e9580a7 | |||
| 2dc57d01a5 | |||
| 4c7fc99eb8 | |||
| 6fd5a50d77 | |||
| ad16202c12 | |||
| 121cb5a372 | |||
| c8ac5a3d89 | |||
| 4788ca9261 | |||
| 8ab1e5194f | |||
| a917e4149a | |||
| 85be7173e1 | |||
| bfc064796f | |||
| 605e8910d3 | |||
| 2af8d6aa1b | |||
| c5c60d3df5 | |||
| c43573859f | |||
| 0c69162ba6 | |||
| 9ce6d86e82 | |||
| 31a5b64836 | |||
| cdfef949bf | |||
| f9b202ac25 | |||
| a6c984e517 | |||
| 13619e9271 |
+206
-28
@@ -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 { addNewElementsToActiveGroup, 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,
|
||||||
@@ -219,6 +222,11 @@ const TOOL_COMMAND: Partial<Record<ToolId, string>> = {
|
|||||||
rect: "rect",
|
rect: "rect",
|
||||||
circle: "circle",
|
circle: "circle",
|
||||||
arc: "arc",
|
arc: "arc",
|
||||||
|
ellipse: "ellipse",
|
||||||
|
spline: "spline",
|
||||||
|
bezier: "bezier",
|
||||||
|
dimension: "dimension",
|
||||||
|
kote: "kote",
|
||||||
text: "text",
|
text: "text",
|
||||||
textbox: "textbox",
|
textbox: "textbox",
|
||||||
};
|
};
|
||||||
@@ -403,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) ─────────────────────
|
||||||
@@ -609,6 +621,7 @@ export default function App() {
|
|||||||
const transformSel: TransformSelection = {
|
const transformSel: TransformSelection = {
|
||||||
wallIds: selectedWallIds,
|
wallIds: selectedWallIds,
|
||||||
drawingId: selectedDrawingId,
|
drawingId: selectedDrawingId,
|
||||||
|
drawingIds: selectedDrawingIds,
|
||||||
extrudedSolidId: selectedExtrudedSolidId,
|
extrudedSolidId: selectedExtrudedSolidId,
|
||||||
columnId: selectedColumnId,
|
columnId: selectedColumnId,
|
||||||
};
|
};
|
||||||
@@ -733,6 +746,7 @@ export default function App() {
|
|||||||
selectedDrawingIds,
|
selectedDrawingIds,
|
||||||
selectedExtrudedSolidId,
|
selectedExtrudedSolidId,
|
||||||
selectedColumnId,
|
selectedColumnId,
|
||||||
|
groupPath,
|
||||||
setProject,
|
setProject,
|
||||||
setDraft,
|
setDraft,
|
||||||
});
|
});
|
||||||
@@ -749,6 +763,7 @@ export default function App() {
|
|||||||
selectedDrawingIds,
|
selectedDrawingIds,
|
||||||
selectedExtrudedSolidId,
|
selectedExtrudedSolidId,
|
||||||
selectedColumnId,
|
selectedColumnId,
|
||||||
|
groupPath,
|
||||||
setProject,
|
setProject,
|
||||||
setDraft,
|
setDraft,
|
||||||
};
|
};
|
||||||
@@ -796,7 +811,15 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
pxPerMeter: () => planViewRef.current?.pxPerMeter() ?? 90,
|
pxPerMeter: () => planViewRef.current?.pxPerMeter() ?? 90,
|
||||||
setDraft: (d) => cmdLatest.current.setDraft(d),
|
setDraft: (d) => cmdLatest.current.setDraft(d),
|
||||||
commit: (mutate) => cmdLatest.current.setProject(mutate),
|
// Zeichnet man WÄHREND man eine Gruppe betreten hat (Isolation), wird
|
||||||
|
// das neue Element automatisch Mitglied dieser Gruppe (Nutzer-Wunsch:
|
||||||
|
// „wenn man etwas neu zeichnet ... zeichnet man in der Gruppe") — s.
|
||||||
|
// state/groupActions.ts::addNewElementsToActiveGroup (Diff vor/nach
|
||||||
|
// Commit über alle Elementarten, additiv zur eigentlichen Mutation).
|
||||||
|
commit: (mutate) =>
|
||||||
|
cmdLatest.current.setProject((p) =>
|
||||||
|
addNewElementsToActiveGroup(p, mutate(p), cmdLatest.current.groupPath),
|
||||||
|
),
|
||||||
isFloor: () => cmdLatest.current.activeLevel.kind === "floor",
|
isFloor: () => cmdLatest.current.activeLevel.kind === "floor",
|
||||||
// Frisch über `text`/`textbox` erzeugtes Element sofort inline editieren
|
// Frisch über `text`/`textbox` erzeugtes Element sofort inline editieren
|
||||||
// (kein Lookup nötig — der Commit hat es bereits angehängt, s. engine.ts
|
// (kein Lookup nötig — der Commit hat es bereits angehängt, s. engine.ts
|
||||||
@@ -849,18 +872,6 @@ export default function App() {
|
|||||||
// Live-Echo des Befehlszeilen-Texts fürs Cursor-HUD: der getippte Wert
|
// Live-Echo des Befehlszeilen-Texts fürs Cursor-HUD: der getippte Wert
|
||||||
// erscheint sofort im aktiven Tab-Feld am Cursor (VW-Verhalten).
|
// erscheint sofort im aktiven Tab-Feld am Cursor (VW-Verhalten).
|
||||||
const [cmdTyped, setCmdTyped] = useState("");
|
const [cmdTyped, setCmdTyped] = useState("");
|
||||||
const hudFields =
|
|
||||||
commandActive && engineView.fields.length > 0
|
|
||||||
? {
|
|
||||||
fields: engineView.fields.map((f) => ({
|
|
||||||
id: f.id,
|
|
||||||
value: f.value,
|
|
||||||
locked: f.locked,
|
|
||||||
active: f.active,
|
|
||||||
})),
|
|
||||||
typed: cmdTyped,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// ── GUI-Werkzeug ↔ Befehls-Engine (ein Eingabe-Hub) ───────────────────────
|
// ── GUI-Werkzeug ↔ Befehls-Engine (ein Eingabe-Hub) ───────────────────────
|
||||||
// Ein Klick auf ein gekoppeltes Zeichen-Werkzeug (Wand/Linie/Rechteck/Polylinie)
|
// Ein Klick auf ein gekoppeltes Zeichen-Werkzeug (Wand/Linie/Rechteck/Polylinie)
|
||||||
@@ -872,6 +883,9 @@ export default function App() {
|
|||||||
const LAYOUT_ANNOT_TOOLS: ToolId[] = ["line", "rect", "text", "textbox", "image"];
|
const LAYOUT_ANNOT_TOOLS: ToolId[] = ["line", "rect", "text", "textbox", "image"];
|
||||||
|
|
||||||
const onSelectTool = (id: ToolId) => {
|
const onSelectTool = (id: ToolId) => {
|
||||||
|
// Erneuter Klick auf das bereits AKTIVE Werkzeug schaltet zurück auf
|
||||||
|
// „Auswahl" (Nutzer-Wunsch) — statt das Werkzeug einfach neu zu starten.
|
||||||
|
if (id !== "select" && id === activeTool) id = "select";
|
||||||
if (activeLayout) {
|
if (activeLayout) {
|
||||||
// Ein Layout-Blatt ist offen: PlanView/die Befehls-Engine haben keinen
|
// Ein Layout-Blatt ist offen: PlanView/die Befehls-Engine haben keinen
|
||||||
// sichtbaren Canvas (LayoutSheetView ersetzt die Mitte) — Zeichenwerkzeuge
|
// sichtbaren Canvas (LayoutSheetView ersetzt die Mitte) — Zeichenwerkzeuge
|
||||||
@@ -961,11 +975,11 @@ export default function App() {
|
|||||||
// Globaler Undo/Redo-Shortcut — siehe state/useKeyboardShortcuts.ts.
|
// Globaler Undo/Redo-Shortcut — siehe state/useKeyboardShortcuts.ts.
|
||||||
useUndoRedoShortcut(undo, redo);
|
useUndoRedoShortcut(undo, redo);
|
||||||
|
|
||||||
// Nummern-Shortcuts für Zeichenwerkzeuge (Vectorworks-Stil): 1 Text, 2 Linie,
|
// Esc/Enter/Backspace für das aktive Zeichenwerkzeug. Siehe
|
||||||
// 3 Kreis, 4 Rechteck, 5 Polylinie, 6 Wand, 7 Decke, 8 Fenster, 9 Türe, 0 Raum
|
// state/useKeyboardShortcuts.ts. Die Zifferntasten-Werkzeugwahl
|
||||||
// — sowie Esc/Enter/Backspace für das aktive Zeichenwerkzeug. Siehe
|
// (useToolNumberShortcuts) folgt weiter unten, NACH useGripEditing() — sie
|
||||||
// state/useKeyboardShortcuts.ts.
|
// braucht dessen gripDragInfoRef, um Zifferntasten während einer Griff-
|
||||||
useToolNumberShortcuts(onSelectTool, activeLevel.kind === "floor", engineRef);
|
// Bearbeitung nicht als Werkzeug-Wechsel misszuverstehen.
|
||||||
useActiveToolKeys(activeTool, toolCtx, engineRef, toolStateRef, setDraft, setActiveTool, setProject);
|
useActiveToolKeys(activeTool, toolCtx, engineRef, toolStateRef, setDraft, setActiveTool, setProject);
|
||||||
|
|
||||||
// ── Editier-Griffe (Grips) des einzeln selektierten Elements ──────────────
|
// ── Editier-Griffe (Grips) des einzeln selektierten Elements ──────────────
|
||||||
@@ -1159,6 +1173,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 →
|
||||||
@@ -1192,6 +1281,7 @@ export default function App() {
|
|||||||
setActiveTransform,
|
setActiveTransform,
|
||||||
selectedWallIds,
|
selectedWallIds,
|
||||||
selectedDrawingId,
|
selectedDrawingId,
|
||||||
|
selectedDrawingIds,
|
||||||
selectedExtrudedSolidId,
|
selectedExtrudedSolidId,
|
||||||
selectedColumnId,
|
selectedColumnId,
|
||||||
engine,
|
engine,
|
||||||
@@ -1299,13 +1389,21 @@ export default function App() {
|
|||||||
// Auswahl — siehe state/useGripEditing.ts.
|
// Auswahl — siehe state/useGripEditing.ts.
|
||||||
const {
|
const {
|
||||||
grips,
|
grips,
|
||||||
|
gripHandleLines,
|
||||||
edgeGrips,
|
edgeGrips,
|
||||||
gripHandlers,
|
gripHandlers,
|
||||||
gripEdit,
|
gripEdit,
|
||||||
setGripEdit,
|
|
||||||
gripEditFields,
|
gripEditFields,
|
||||||
cycleGripEditField,
|
cycleGripEditField,
|
||||||
submitGripEditValue,
|
submitGripEditValue,
|
||||||
|
closeGripEditField,
|
||||||
|
edgeEdit,
|
||||||
|
edgeEditFields,
|
||||||
|
cycleEdgeEditField,
|
||||||
|
submitEdgeEditValue,
|
||||||
|
closeEdgeEditField,
|
||||||
|
gripDragInfoRef,
|
||||||
|
edgeDragInfoRef,
|
||||||
} = useGripEditing({
|
} = useGripEditing({
|
||||||
project,
|
project,
|
||||||
selectedDrawing,
|
selectedDrawing,
|
||||||
@@ -1339,6 +1437,42 @@ export default function App() {
|
|||||||
moveElementByOf,
|
moveElementByOf,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nummern-Shortcuts für Zeichenwerkzeuge (Vectorworks-Stil): 1 Text, 2 Linie,
|
||||||
|
// 3 Kreis, 4 Rechteck, 5 Polylinie, 6 Wand, 7 Decke, 8 Fenster, 9 Türe, 0 Raum.
|
||||||
|
// Siehe state/useKeyboardShortcuts.ts. Braucht gripDragInfoRef/edgeDragInfoRef
|
||||||
|
// (s. o.), daher erst hier nach useGripEditing() registriert.
|
||||||
|
useToolNumberShortcuts(
|
||||||
|
onSelectTool,
|
||||||
|
activeLevel.kind === "floor",
|
||||||
|
engineRef,
|
||||||
|
gripDragInfoRef,
|
||||||
|
edgeDragInfoRef,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Feld-Zustand fürs Cursor-HUD (neben der Maus, VW-Stil): läuft ein Punkt-
|
||||||
|
// ODER Kanten-Griff-Drag, hat er Vorrang (dieselben Felder wie unten in der
|
||||||
|
// Befehlszeile — Länge/Winkel- bzw. Abstand-Tab-Zyklus), sonst die Befehls-
|
||||||
|
// Engine-Felder beim Zeichnen. Vorher zeigte das Cursor-HUD beim Griff-Ziehen
|
||||||
|
// GAR KEINE Länge/Winkel-Werte an (nur die Befehlszeile unten) — Nutzer-
|
||||||
|
// Wunsch: „du kannst [Länge/Winkel] gleich bei der Maus anzeigen wie beim
|
||||||
|
// Erstellen".
|
||||||
|
const hudFields =
|
||||||
|
gripEdit
|
||||||
|
? { fields: gripEditFields(), typed: cmdTyped }
|
||||||
|
: edgeEdit
|
||||||
|
? { fields: edgeEditFields(), typed: cmdTyped }
|
||||||
|
: commandActive && engineView.fields.length > 0
|
||||||
|
? {
|
||||||
|
fields: engineView.fields.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
value: f.value,
|
||||||
|
locked: f.locked,
|
||||||
|
active: f.active,
|
||||||
|
})),
|
||||||
|
typed: cmdTyped,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
// Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen — siehe
|
// Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen — siehe
|
||||||
// state/useKeyboardShortcuts.ts.
|
// state/useKeyboardShortcuts.ts.
|
||||||
useDrawingClipboardShortcut(
|
useDrawingClipboardShortcut(
|
||||||
@@ -2288,6 +2422,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 },
|
||||||
});
|
});
|
||||||
@@ -2708,6 +2845,8 @@ export default function App() {
|
|||||||
hairline={lineMode === "display"}
|
hairline={lineMode === "display"}
|
||||||
marqueeColor={marqueeColor}
|
marqueeColor={marqueeColor}
|
||||||
snapColor={snapColor}
|
snapColor={snapColor}
|
||||||
|
hoverDivideDelayMs={snap.hoverDivideDelayMs}
|
||||||
|
hoverDivideCount={snap.hoverDivideCount}
|
||||||
renderMode={renderMode}
|
renderMode={renderMode}
|
||||||
view3d={view3d}
|
view3d={view3d}
|
||||||
fov={fov}
|
fov={fov}
|
||||||
@@ -2730,6 +2869,7 @@ export default function App() {
|
|||||||
selectedStairIds={selectedStairIds}
|
selectedStairIds={selectedStairIds}
|
||||||
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
|
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
|
||||||
selectedColumnIds={selectedColumnIds}
|
selectedColumnIds={selectedColumnIds}
|
||||||
|
selectedGroupIds={selectedGroupIds}
|
||||||
selectedRoomIds={selectedRoomIds}
|
selectedRoomIds={selectedRoomIds}
|
||||||
selectedRoofIds={selectedRoofIds}
|
selectedRoofIds={selectedRoofIds}
|
||||||
selectedContextObjectIds={selectedContextObjectIds}
|
selectedContextObjectIds={selectedContextObjectIds}
|
||||||
@@ -2738,6 +2878,9 @@ export default function App() {
|
|||||||
onStampMove={moveRoomStamp}
|
onStampMove={moveRoomStamp}
|
||||||
onStampEdit={onOpenStampEditor}
|
onStampEdit={onOpenStampEditor}
|
||||||
onEditDrawingText={onEditDrawingText}
|
onEditDrawingText={onEditDrawingText}
|
||||||
|
onEnterGroup={onEnterGroup}
|
||||||
|
onLeaveGroup={onLeaveGroup}
|
||||||
|
groupPath={groupPath}
|
||||||
editTextId={editTextId}
|
editTextId={editTextId}
|
||||||
editTextMinHeightM={editTextMinHeightM}
|
editTextMinHeightM={editTextMinHeightM}
|
||||||
onCommitTextEdit={commitTextEdit}
|
onCommitTextEdit={commitTextEdit}
|
||||||
@@ -2761,6 +2904,7 @@ export default function App() {
|
|||||||
toolHandlers={toolHandlers}
|
toolHandlers={toolHandlers}
|
||||||
hudFields={hudFields}
|
hudFields={hudFields}
|
||||||
grips={grips}
|
grips={grips}
|
||||||
|
gripHandleLines={gripHandleLines}
|
||||||
edgeGrips={edgeGrips}
|
edgeGrips={edgeGrips}
|
||||||
selectedDrawingId={selectedDrawingId}
|
selectedDrawingId={selectedDrawingId}
|
||||||
selectedDrawingIds={selectedDrawingIds}
|
selectedDrawingIds={selectedDrawingIds}
|
||||||
@@ -2780,14 +2924,17 @@ export default function App() {
|
|||||||
Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */}
|
Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */}
|
||||||
<CommandLine
|
<CommandLine
|
||||||
ref={commandLineRef}
|
ref={commandLineRef}
|
||||||
// (c) Während eines Vertex-Griff-Drags belegt der Feld-Controller das
|
// (c) Während eines Vertex- ODER Kanten-Griff-Drags belegt der Feld-
|
||||||
// Befehlsfeld (Länge/Winkel). Läuft eine Auswahl-Transformation (Drehen
|
// Controller das Befehlsfeld (Länge/Winkel bzw. Abstand). Läuft eine
|
||||||
// etc.), belegt sie Prompt + U/I/O/P + Winkel-/Distanzfeld. Sonst gilt die
|
// Auswahl-Transformation (Drehen etc.), belegt sie Prompt + U/I/O/P +
|
||||||
// Engine (Zeichnen/Ruhe). Griff-Drag hat Vorrang (schließen sich sonst aus).
|
// Winkel-/Distanzfeld. Sonst gilt die Engine (Zeichnen/Ruhe). Griff-/
|
||||||
active={gripEdit ? true : activeTransform ? true : engineView.active}
|
// Kanten-Drag haben Vorrang (schließen sich sonst aus).
|
||||||
|
active={gripEdit ? true : edgeEdit ? true : activeTransform ? true : engineView.active}
|
||||||
promptKey={
|
promptKey={
|
||||||
gripEdit
|
gripEdit
|
||||||
? "cmd.edit.point"
|
? "cmd.edit.point"
|
||||||
|
: edgeEdit
|
||||||
|
? "cmd.edit.edge"
|
||||||
: activeTransform
|
: activeTransform
|
||||||
? `transform.hint.${activeTransform.op}.${Math.min(
|
? `transform.hint.${activeTransform.op}.${Math.min(
|
||||||
activeTransform.points.length,
|
activeTransform.points.length,
|
||||||
@@ -2795,14 +2942,33 @@ export default function App() {
|
|||||||
)}`
|
)}`
|
||||||
: engineView.promptKey
|
: engineView.promptKey
|
||||||
}
|
}
|
||||||
options={gripEdit ? [] : activeTransform ? transformOptions() : engineView.options}
|
options={
|
||||||
|
gripEdit || edgeEdit ? [] : activeTransform ? transformOptions() : engineView.options
|
||||||
|
}
|
||||||
fields={
|
fields={
|
||||||
gripEdit ? gripEditFields() : activeTransform ? transformFields() : engineView.fields
|
gripEdit
|
||||||
|
? gripEditFields()
|
||||||
|
: edgeEdit
|
||||||
|
? edgeEditFields()
|
||||||
|
: activeTransform
|
||||||
|
? transformFields()
|
||||||
|
: engineView.fields
|
||||||
}
|
}
|
||||||
onSubmit={(text) => {
|
onSubmit={(text) => {
|
||||||
if (gripEdit) {
|
if (gripEdit) {
|
||||||
const v = parseFloat(text.replace(",", "."));
|
const v = parseFloat(text.replace(",", "."));
|
||||||
if (isFinite(v)) submitGripEditValue(v);
|
if (isFinite(v)) submitGripEditValue(v);
|
||||||
|
// Leere Eingabe: kein neuer Wert zu locken — Enter bestätigt
|
||||||
|
// stattdessen die aktuelle Position (Maus- oder bereits
|
||||||
|
// gelockte Werte) und schließt die Griff-Bearbeitung ab. Vorher
|
||||||
|
// ein Totdruck (Nutzer-Report: Enter tat nichts).
|
||||||
|
else planViewRef.current?.confirmGripEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (edgeEdit) {
|
||||||
|
const v = parseFloat(text.replace(",", "."));
|
||||||
|
if (isFinite(v)) submitEdgeEditValue(v);
|
||||||
|
else planViewRef.current?.confirmEdgeEdit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeTransform) {
|
if (activeTransform) {
|
||||||
@@ -2823,10 +2989,22 @@ export default function App() {
|
|||||||
activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id)
|
activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id)
|
||||||
}
|
}
|
||||||
onCycleField={() =>
|
onCycleField={() =>
|
||||||
gripEdit ? cycleGripEditField() : activeTransform ? undefined : engine.cycleField()
|
gripEdit
|
||||||
|
? cycleGripEditField()
|
||||||
|
: edgeEdit
|
||||||
|
? cycleEdgeEditField()
|
||||||
|
: activeTransform
|
||||||
|
? undefined
|
||||||
|
: engine.cycleField()
|
||||||
}
|
}
|
||||||
onCancel={() =>
|
onCancel={() =>
|
||||||
gripEdit ? setGripEdit(null) : activeTransform ? cancelTransform() : engine.cancel()
|
gripEdit
|
||||||
|
? closeGripEditField()
|
||||||
|
: edgeEdit
|
||||||
|
? closeEdgeEditField()
|
||||||
|
: activeTransform
|
||||||
|
? cancelTransform()
|
||||||
|
: engine.cancel()
|
||||||
}
|
}
|
||||||
suggest={(prefix) => engine.suggest(prefix)}
|
suggest={(prefix) => engine.suggest(prefix)}
|
||||||
onTextChange={setCmdTyped}
|
onTextChange={setCmdTyped}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* `arrayCommand` — dupliziert die Auswahl `count`-mal entlang eines Vektors
|
||||||
|
* in einer Geste. Baut auf dem bestehenden `mode:"array"` von
|
||||||
|
* `tools/transform.ts` auf (bisher ohne eigenen Befehl erreichbar).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { arrayCommand } from "./array";
|
||||||
|
import type { CommandContext, Project } from "../types";
|
||||||
|
import type { Drawing2D } from "../../model/types";
|
||||||
|
|
||||||
|
function project(extra: Drawing2D[] = []): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||||
|
],
|
||||||
|
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: extra,
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(p: Project, drawingId: string | null): CommandContext {
|
||||||
|
return {
|
||||||
|
project: p,
|
||||||
|
level: p.drawingLevels[0],
|
||||||
|
defaultCategoryCode: "20",
|
||||||
|
activeLineStyleId: "solid",
|
||||||
|
activeWallTypeId: "aw",
|
||||||
|
lastPoint: null,
|
||||||
|
selection: { wallIds: [], drawingId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const source: Drawing2D = {
|
||||||
|
id: "src",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom: { shape: "circle", center: { x: 0, y: 0 }, r: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("arrayCommand", () => {
|
||||||
|
it("4 Kopien entlang +x im Abstand 3m (Anzahl per Option auf 4 gesetzt)", () => {
|
||||||
|
const p = project([source]);
|
||||||
|
const ctx = makeCtx(p, "src");
|
||||||
|
let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
|
||||||
|
// Anzahl-Option 3× drücken: 3(default egal)→…→4 (zyklt 2..12, wir setzen sie
|
||||||
|
// deterministisch, indem wir bis 4 zyklen unabhängig vom Startwert testen wir
|
||||||
|
// stattdessen direkt über den committeten Output).
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
[state] = arrayCommand.onInput(state, { kind: "option", id: "count" }, ctx);
|
||||||
|
const opts = arrayCommand.options(state);
|
||||||
|
if (opts[0].value === "4") break;
|
||||||
|
}
|
||||||
|
const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 3, y: 0 } }, ctx);
|
||||||
|
expect(result.commit).toBeDefined();
|
||||||
|
const next = result.commit!(p);
|
||||||
|
// Original bleibt + 4 neue Kopien.
|
||||||
|
expect(next.drawings2d.length).toBe(5);
|
||||||
|
const centers = next.drawings2d
|
||||||
|
.map((d) => d.geom)
|
||||||
|
.filter((g) => g.shape === "circle")
|
||||||
|
.map((g) => (g as { center: { x: number; y: number } }).center.x)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
expect(centers).toEqual([0, 3, 6, 9, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leere Auswahl committet nichts", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p, null);
|
||||||
|
let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
|
||||||
|
const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 5, y: 0 } }, ctx);
|
||||||
|
expect(result.commit).toBeUndefined();
|
||||||
|
expect(result.done).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Zielpunkt gleich Basispunkt (Nullvektor) committet nichts", () => {
|
||||||
|
const p = project([source]);
|
||||||
|
const ctx = makeCtx(p, "src");
|
||||||
|
let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 2, y: 2 } }, ctx);
|
||||||
|
const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 2, y: 2 } }, ctx);
|
||||||
|
expect(result.commit).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Array (Feld) — dupliziert die AKTUELLE Auswahl `count`-mal entlang eines
|
||||||
|
// Vektors, in EINER Geste (baut auf dem bestehenden Transform-Modus
|
||||||
|
// `"array"` auf, der bisher nie über einen Befehl erreichbar war — s.
|
||||||
|
// `tools/transform.ts::copyTs`). Schritte:
|
||||||
|
// 1) „Basispunkt:" → Punkt
|
||||||
|
// 2) „Zielpunkt ( Anzahl ):" → Punkt/Zahl (Tab length/angle) → committet
|
||||||
|
// ALLE `count` Kopien auf einmal (Original bleibt). „Anzahl" (Option,
|
||||||
|
// persistent über Aufrufe wie bei Multiline) zyklt 2..12.
|
||||||
|
//
|
||||||
|
// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl,
|
||||||
|
// wie move/copy). Leere Auswahl → No-op.
|
||||||
|
|
||||||
|
import { commitTransform, transformPreview } from "../../tools/transform";
|
||||||
|
import type { TransformSelection } from "../../tools/transform";
|
||||||
|
import type {
|
||||||
|
CmdOption,
|
||||||
|
Command,
|
||||||
|
CommandField,
|
||||||
|
CommandResult,
|
||||||
|
CommandSelection,
|
||||||
|
CommandState,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
const MIN_COUNT = 2;
|
||||||
|
const MAX_COUNT = 12;
|
||||||
|
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||||
|
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||||
|
|
||||||
|
/** Persistente Anzahl über Aufrufe hinweg (wie `lastCount` bei Multiline). */
|
||||||
|
let lastCount = 3;
|
||||||
|
|
||||||
|
const ARRAY_FIELDS: CommandField[] = [
|
||||||
|
{ id: "length", labelKey: "cmd.field.length" },
|
||||||
|
{ id: "angle", labelKey: "cmd.field.angle" },
|
||||||
|
];
|
||||||
|
const COUNT_OPTION: CmdOption = { id: "count", labelKey: "cmd.array.count" };
|
||||||
|
|
||||||
|
function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
|
||||||
|
const ref = cursor ?? base;
|
||||||
|
const length = "length" in locks ? locks.length : segLen(base, ref);
|
||||||
|
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref);
|
||||||
|
const ang = angleDeg * DEG;
|
||||||
|
return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSelection(sel: CommandSelection): boolean {
|
||||||
|
return (
|
||||||
|
sel.wallIds.length > 0 ||
|
||||||
|
sel.drawingId !== null ||
|
||||||
|
(sel.drawingIds?.length ?? 0) > 0 ||
|
||||||
|
!!sel.extrudedSolidId ||
|
||||||
|
!!sel.columnId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function toTransformSel(sel: CommandSelection): TransformSelection {
|
||||||
|
return {
|
||||||
|
wallIds: sel.wallIds,
|
||||||
|
drawingId: sel.drawingId,
|
||||||
|
drawingIds: sel.drawingIds,
|
||||||
|
extrudedSolidId: sel.extrudedSolidId,
|
||||||
|
columnId: sel.columnId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArrBase extends CommandState {
|
||||||
|
phase: "base";
|
||||||
|
}
|
||||||
|
interface ArrTarget extends CommandState {
|
||||||
|
phase: "target";
|
||||||
|
sel: CommandSelection;
|
||||||
|
base: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
type ArrState = ArrBase | ArrTarget;
|
||||||
|
|
||||||
|
/** Vorschau ALLER `lastCount` Kopien bei der aktuellen Cursor-Lage. */
|
||||||
|
function arrayDraft(project: Project, sel: CommandSelection, base: Vec2, target: Vec2): ToolDraft {
|
||||||
|
const preview = transformPreview(project, toTransformSel(sel), "move", [base, target], "array", lastCount);
|
||||||
|
return {
|
||||||
|
preview,
|
||||||
|
vertices: [base],
|
||||||
|
hud: {
|
||||||
|
at: target,
|
||||||
|
text: `${segLen(base, target).toFixed(2)} m × ${lastCount}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "base", lastPoint: null } as ArrBase,
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const arrayCommand: Command = {
|
||||||
|
name: "array",
|
||||||
|
labelKey: "cmd.array.label",
|
||||||
|
prompt: (s) => ((s as ArrState).phase === "target" ? "cmd.array.target" : "cmd.array.base"),
|
||||||
|
accepts: (s) => ((s as ArrState).phase === "target" ? ["point", "number", "option"] : ["point"]),
|
||||||
|
options: (s) =>
|
||||||
|
(s as ArrState).phase === "target" ? [{ ...COUNT_OPTION, value: String(lastCount) }] : [],
|
||||||
|
init: (): ArrBase => ({ phase: "base", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as ArrState;
|
||||||
|
if (!hasSelection(ctx.selection)) return idle();
|
||||||
|
|
||||||
|
if (s.phase !== "target") {
|
||||||
|
if (input.kind !== "point") return [s, { draft: null }];
|
||||||
|
const next: ArrTarget = {
|
||||||
|
phase: "target",
|
||||||
|
sel: ctx.selection,
|
||||||
|
base: input.point,
|
||||||
|
cursor: input.point,
|
||||||
|
lastPoint: input.point,
|
||||||
|
};
|
||||||
|
return [next, { draft: { preview: [], vertices: [input.point] } }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.kind === "option") {
|
||||||
|
if (input.id === "count") {
|
||||||
|
lastCount = lastCount >= MAX_COUNT ? MIN_COUNT : lastCount + 1;
|
||||||
|
return [s, { draft: s.cursor ? arrayDraft(ctx.project, s.sel, s.base, s.cursor) : null }];
|
||||||
|
}
|
||||||
|
return [s, { draft: null }];
|
||||||
|
}
|
||||||
|
|
||||||
|
let target: Vec2 | null = null;
|
||||||
|
if (input.kind === "point") target = input.point;
|
||||||
|
else if (input.kind === "number") target = { x: s.base.x + input.value, y: s.base.y };
|
||||||
|
if (!target || segLen(s.base, target) < 1e-6) return idle();
|
||||||
|
|
||||||
|
const { base, sel } = s;
|
||||||
|
return [
|
||||||
|
idle()[0],
|
||||||
|
{ draft: null, done: true, commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, target!], "array", lastCount) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as ArrState;
|
||||||
|
if (s.phase !== "target") return [s, { draft: null }];
|
||||||
|
const ns: ArrTarget = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: arrayDraft(ctx.project, s.sel, s.base, point) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
|
||||||
|
fields: (state) => ((state as ArrState).phase === "target" ? ARRAY_FIELDS : []),
|
||||||
|
pointFromFields: (state, locks, cursor) => {
|
||||||
|
const s = state as ArrState;
|
||||||
|
if (s.phase !== "target") return null;
|
||||||
|
return targetFromFields(s.base, locks, cursor);
|
||||||
|
},
|
||||||
|
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||||
|
const s = state as ArrState;
|
||||||
|
if (s.phase !== "target" || !cursor) return {};
|
||||||
|
return {
|
||||||
|
length: segLen(s.base, cursor),
|
||||||
|
angle: ((segAngleDeg(s.base, cursor) % 360) + 360) % 360,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* `bezierCommand` — manuelles Kontrollpunkt-Werkzeug: Anker → Griff 1 →
|
||||||
|
* Griff 2 → Endpunkt je Segment, „Fertig" committet als `shape:"bezier"`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { bezierCommand } from "./bezier";
|
||||||
|
import type { CommandContext, Project } from "../types";
|
||||||
|
|
||||||
|
function project(): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||||
|
],
|
||||||
|
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(p: Project): CommandContext {
|
||||||
|
return {
|
||||||
|
project: p,
|
||||||
|
level: p.drawingLevels[0],
|
||||||
|
defaultCategoryCode: "20",
|
||||||
|
activeLineStyleId: "solid",
|
||||||
|
activeWallTypeId: "aw",
|
||||||
|
lastPoint: null,
|
||||||
|
selection: { wallIds: [], drawingId: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bezierCommand", () => {
|
||||||
|
it("ein Segment (4 Klicks) + Fertig → shape:bezier mit 4 Kontrollpunkten", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
let [state] = bezierCommand.onInput(bezierCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
|
||||||
|
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 1, y: 3 } }, ctx);
|
||||||
|
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 3, y: 3 } }, ctx);
|
||||||
|
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
|
||||||
|
const [, result] = bezierCommand.onConfirm(state, ctx);
|
||||||
|
expect(result.commit).toBeDefined();
|
||||||
|
const next = result.commit!(p);
|
||||||
|
expect(next.drawings2d.length).toBe(1);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
expect(g.shape).toBe("bezier");
|
||||||
|
if (g.shape === "bezier") {
|
||||||
|
expect(g.pts).toEqual([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 3 },
|
||||||
|
{ x: 3, y: 3 },
|
||||||
|
{ x: 4, y: 0 },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zwei Segmente hängen am gemeinsamen Anker (7 Punkte)", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
const clicks = [
|
||||||
|
{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 1 }, { x: 3, y: 0 },
|
||||||
|
{ x: 4, y: -1 }, { x: 5, y: -1 }, { x: 6, y: 0 },
|
||||||
|
];
|
||||||
|
let [state] = bezierCommand.onInput(bezierCommand.init(), { kind: "point", point: clicks[0] }, ctx);
|
||||||
|
for (let i = 1; i < clicks.length; i++) {
|
||||||
|
[state] = bezierCommand.onInput(state, { kind: "point", point: clicks[i] }, ctx);
|
||||||
|
}
|
||||||
|
const [, result] = bezierCommand.onInput(state, { kind: "option", id: "finish" }, ctx);
|
||||||
|
expect(result.commit).toBeDefined();
|
||||||
|
const next = result.commit!(p);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
expect(g.shape).toBe("bezier");
|
||||||
|
if (g.shape === "bezier") expect(g.pts).toEqual(clicks);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weniger als ein volles Segment: Enter bricht ohne commit ab", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
let [state] = bezierCommand.onInput(bezierCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
|
||||||
|
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 1, y: 1 } }, ctx);
|
||||||
|
const [, result] = bezierCommand.onConfirm(state, ctx);
|
||||||
|
expect(result.commit).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Bezier — klassisches Kontrollpunkt-Werkzeug (manuell gesetzte Griffe, kein
|
||||||
|
// Auto-Glätten wie `spline`). Jedes Segment braucht 3 Klicks nach dem Anker:
|
||||||
|
// 1) „Startpunkt:" → Punkt (erster Anker)
|
||||||
|
// 2) „Griff 1:" → Punkt (Kontrollpunkt 1 dieses Segments)
|
||||||
|
// 3) „Griff 2:" → Punkt (Kontrollpunkt 2 dieses Segments)
|
||||||
|
// 4) „Endpunkt:" → Punkt (Segment-Ende, = Anker des nächsten Segments)
|
||||||
|
// Nach ≥1 fertigem Segment kann „Fertig" (Enter) den Zug abschliessen; jeder
|
||||||
|
// weitere Anker startet automatisch ein neues Segment (Schritte 2–4).
|
||||||
|
|
||||||
|
import type { Drawing2D } from "../../model/types";
|
||||||
|
import { uniqueId } from "../../tools/types";
|
||||||
|
import { sampleBezierChain } from "../../tools/curves";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
CmdOption,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
interface BezierStart extends CommandState {
|
||||||
|
phase: "start";
|
||||||
|
}
|
||||||
|
interface BezierC1 extends CommandState {
|
||||||
|
phase: "c1";
|
||||||
|
pts: Vec2[]; // abgeschlossene Punkte (Anker·Griff·Griff·Anker·…)
|
||||||
|
anchor: Vec2; // letzter Anker (= pts[pts.length-1])
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
interface BezierC2 extends CommandState {
|
||||||
|
phase: "c2";
|
||||||
|
pts: Vec2[];
|
||||||
|
anchor: Vec2;
|
||||||
|
c1: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
interface BezierEnd extends CommandState {
|
||||||
|
phase: "end";
|
||||||
|
pts: Vec2[];
|
||||||
|
anchor: Vec2;
|
||||||
|
c1: Vec2;
|
||||||
|
c2: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
type BezierState = BezierStart | BezierC1 | BezierC2 | BezierEnd;
|
||||||
|
|
||||||
|
const FINISH: CmdOption = { id: "finish", labelKey: "cmd.bezier.finish" };
|
||||||
|
|
||||||
|
/** Vorschau: abgeschlossene Kurve (tessellierte Segmente) + Gummiband-Linien des laufenden Segments. */
|
||||||
|
function bezierDraft(pts: Vec2[], rubber: Vec2[]): ToolDraft {
|
||||||
|
const preview: DraftShape[] = [];
|
||||||
|
if (pts.length >= 4) preview.push({ kind: "poly", pts: sampleBezierChain(pts), closed: false });
|
||||||
|
for (let i = 0; i < rubber.length - 1; i++) preview.push({ kind: "line", a: rubber[i], b: rubber[i + 1] });
|
||||||
|
return { preview, vertices: pts.length > 0 ? [pts[0]] : [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendBezier(p: Project, pts: Vec2[], ctx: CommandContext): Project {
|
||||||
|
if (pts.length < 4) return p;
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: uniqueId("dr2d"),
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: ctx.level.id,
|
||||||
|
categoryCode: ctx.defaultCategoryCode,
|
||||||
|
geom: { shape: "bezier", pts },
|
||||||
|
};
|
||||||
|
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "start", lastPoint: null } as BezierStart,
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
const finish = (pts: Vec2[], ctx: CommandContext): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "start", lastPoint: null } as BezierStart,
|
||||||
|
{ draft: null, done: true, commit: (p) => appendBezier(p, pts, ctx) },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const bezierCommand: Command = {
|
||||||
|
name: "bezier",
|
||||||
|
labelKey: "cmd.bezier.label",
|
||||||
|
prompt: (s) => {
|
||||||
|
const phase = (s as BezierState).phase;
|
||||||
|
return phase === "c1"
|
||||||
|
? "cmd.bezier.c1"
|
||||||
|
: phase === "c2"
|
||||||
|
? "cmd.bezier.c2"
|
||||||
|
: phase === "end"
|
||||||
|
? "cmd.bezier.end"
|
||||||
|
: "cmd.bezier.start";
|
||||||
|
},
|
||||||
|
accepts: () => ["point", "option"],
|
||||||
|
options: (s) => ((s as BezierState).phase === "c1" && (s as BezierC1).pts.length >= 4 ? [FINISH] : []),
|
||||||
|
init: (): BezierStart => ({ phase: "start", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as BezierState;
|
||||||
|
|
||||||
|
if (input.kind === "option") {
|
||||||
|
if (input.id === "finish" && s.phase === "c1" && s.pts.length >= 4) return finish(s.pts, ctx);
|
||||||
|
return [s, { draft: null }];
|
||||||
|
}
|
||||||
|
if (input.kind !== "point") return [s, { draft: null }];
|
||||||
|
const pt = input.point;
|
||||||
|
|
||||||
|
if (s.phase === "start") {
|
||||||
|
const ns: BezierC1 = { phase: "c1", pts: [pt], anchor: pt, cursor: pt, lastPoint: pt };
|
||||||
|
return [ns, { draft: bezierDraft(ns.pts, [pt]) }];
|
||||||
|
}
|
||||||
|
if (s.phase === "c1") {
|
||||||
|
const ns: BezierC2 = { phase: "c2", pts: s.pts, anchor: s.anchor, c1: pt, cursor: pt, lastPoint: pt };
|
||||||
|
return [ns, { draft: bezierDraft(ns.pts, [s.anchor, pt]) }];
|
||||||
|
}
|
||||||
|
if (s.phase === "c2") {
|
||||||
|
const ns: BezierEnd = {
|
||||||
|
phase: "end",
|
||||||
|
pts: s.pts,
|
||||||
|
anchor: s.anchor,
|
||||||
|
c1: s.c1,
|
||||||
|
c2: pt,
|
||||||
|
cursor: pt,
|
||||||
|
lastPoint: pt,
|
||||||
|
};
|
||||||
|
return [ns, { draft: bezierDraft(ns.pts, [s.anchor, s.c1, pt]) }];
|
||||||
|
}
|
||||||
|
// phase "end": Segment abschliessen, direkt das nächste Segment beginnen.
|
||||||
|
const pts = [...s.pts, s.c1, s.c2, pt];
|
||||||
|
const ns: BezierC1 = { phase: "c1", pts, anchor: pt, cursor: pt, lastPoint: pt };
|
||||||
|
return [ns, { draft: bezierDraft(pts, [pt]) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as BezierState;
|
||||||
|
if (s.phase === "start") return [s, { draft: null }];
|
||||||
|
if (s.phase === "c1") {
|
||||||
|
const ns: BezierC1 = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: bezierDraft(s.pts, [s.anchor, point]) }];
|
||||||
|
}
|
||||||
|
if (s.phase === "c2") {
|
||||||
|
const ns: BezierC2 = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: bezierDraft(s.pts, [s.anchor, s.c1, point]) }];
|
||||||
|
}
|
||||||
|
const ns: BezierEnd = { ...s, cursor: point };
|
||||||
|
// Endschritt: die tatsächliche Bezier-Kurve bis zum Cursor live anzeigen.
|
||||||
|
return [ns, { draft: bezierDraft([...s.pts, s.c1, s.c2, point], []) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as BezierState;
|
||||||
|
if (s.phase === "c1" && s.pts.length >= 4) return finish(s.pts, ctx);
|
||||||
|
return idle();
|
||||||
|
},
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
};
|
||||||
@@ -45,6 +45,7 @@ function hasSelection(sel: CommandSelection): boolean {
|
|||||||
return (
|
return (
|
||||||
sel.wallIds.length > 0 ||
|
sel.wallIds.length > 0 ||
|
||||||
sel.drawingId !== null ||
|
sel.drawingId !== null ||
|
||||||
|
(sel.drawingIds?.length ?? 0) > 0 ||
|
||||||
!!sel.extrudedSolidId ||
|
!!sel.extrudedSolidId ||
|
||||||
!!sel.columnId
|
!!sel.columnId
|
||||||
);
|
);
|
||||||
@@ -53,6 +54,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
|
|||||||
return {
|
return {
|
||||||
wallIds: sel.wallIds,
|
wallIds: sel.wallIds,
|
||||||
drawingId: sel.drawingId,
|
drawingId: sel.drawingId,
|
||||||
|
drawingIds: sel.drawingIds,
|
||||||
extrudedSolidId: sel.extrudedSolidId,
|
extrudedSolidId: sel.extrudedSolidId,
|
||||||
columnId: sel.columnId,
|
columnId: sel.columnId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* `dimensionCommand` — Punktreihe (wie Polylinie) + EIN Offset-Klick → EIN
|
||||||
|
* `shape:"dimension"`-Element für die ganze Kette (Reihenbemassung, SIA 400
|
||||||
|
* Figur 14). Ist beim Start bereits eine Kette selektiert, ergänzt der erste
|
||||||
|
* Klick sie (verlängert ODER fügt dazwischen ein) statt eine neue zu
|
||||||
|
* beginnen. Die Masszahl wird NICHT gespeichert (aus dist(a,b) beim Rendern
|
||||||
|
* abgeleitet).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { dimensionCommand } from "./dimension";
|
||||||
|
import type { CommandContext, Project } from "../types";
|
||||||
|
import type { Drawing2D } from "../../model/types";
|
||||||
|
|
||||||
|
function project(extra: Drawing2D[] = []): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||||
|
],
|
||||||
|
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: extra,
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(p: Project, selectedDrawingId: string | null = null): CommandContext {
|
||||||
|
return {
|
||||||
|
project: p,
|
||||||
|
level: p.drawingLevels[0],
|
||||||
|
defaultCategoryCode: "20",
|
||||||
|
activeLineStyleId: "solid",
|
||||||
|
activeWallTypeId: "aw",
|
||||||
|
lastPoint: null,
|
||||||
|
selection: { wallIds: [], drawingId: selectedDrawingId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dimensionCommand — neu zeichnen", () => {
|
||||||
|
it("zwei Punkte + Enter + Offset-Klick (links positiv) → ein shape:dimension mit korrektem Vorzeichen", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
let [state] = dimensionCommand.onInput(
|
||||||
|
dimensionCommand.init(),
|
||||||
|
{ kind: "point", point: { x: 0, y: 0 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 10, y: 0 } }, ctx);
|
||||||
|
[state] = dimensionCommand.onConfirm(state, ctx);
|
||||||
|
// Cursor bei y=2 (oberhalb von a→b, entlang +x) → Links-Normale zeigt nach +y.
|
||||||
|
const [, result] = dimensionCommand.onInput(state, { kind: "point", point: { x: 5, y: 2 } }, ctx);
|
||||||
|
expect(result.commit).toBeDefined();
|
||||||
|
const next = result.commit!(p);
|
||||||
|
expect(next.drawings2d.length).toBe(1);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
expect(g.shape).toBe("dimension");
|
||||||
|
if (g.shape === "dimension") {
|
||||||
|
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 10, y: 0 }]);
|
||||||
|
expect(g.offset).toBeCloseTo(2, 6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Fertig-Option (statt Enter) beendet die Punktreihe identisch", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
let [state] = dimensionCommand.onInput(
|
||||||
|
dimensionCommand.init(),
|
||||||
|
{ kind: "point", point: { x: 0, y: 0 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
|
||||||
|
[state] = dimensionCommand.onInput(state, { kind: "option", id: "finish" }, ctx);
|
||||||
|
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1.5 }, ctx);
|
||||||
|
const next = result.commit!(p);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
if (g.shape === "dimension") expect(g.offset).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Kette aus drei Punkten (Reihenbemassung) → EIN Element mit drei Punkten", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
let [state] = dimensionCommand.onInput(
|
||||||
|
dimensionCommand.init(),
|
||||||
|
{ kind: "point", point: { x: 0, y: 0 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
|
||||||
|
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 10, y: 0 } }, ctx);
|
||||||
|
[state] = dimensionCommand.onConfirm(state, ctx);
|
||||||
|
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 2 }, ctx);
|
||||||
|
const next = result.commit!(p);
|
||||||
|
expect(next.drawings2d.length).toBe(1);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
if (g.shape === "dimension") {
|
||||||
|
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
|
||||||
|
expect(g.offset).toBe(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dritter Punkt ausserhalb der Geraden wird auf sie projiziert (keine Polylinie, immer gerade)", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
let [state] = dimensionCommand.onInput(
|
||||||
|
dimensionCommand.init(),
|
||||||
|
{ kind: "point", point: { x: 0, y: 0 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
// Richtung: entlang +x (y=0).
|
||||||
|
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
|
||||||
|
// Dritter Klick liegt bewusst NICHT auf der Geraden (y=3) → wird projiziert.
|
||||||
|
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 10, y: 3 } }, ctx);
|
||||||
|
[state] = dimensionCommand.onConfirm(state, ctx);
|
||||||
|
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
|
||||||
|
const next = result.commit!(p);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
if (g.shape === "dimension") {
|
||||||
|
expect(g.pts[2].y).toBeCloseTo(0, 6); // auf die Gerade y=0 projiziert
|
||||||
|
expect(g.pts[2].x).toBeCloseTo(10, 6); // x-Komponente bleibt (Projektion entlang x)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Zurück-Option entfernt den letzten Punkt", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
let [state] = dimensionCommand.onInput(
|
||||||
|
dimensionCommand.init(),
|
||||||
|
{ kind: "point", point: { x: 0, y: 0 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
|
||||||
|
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 9, y: 0 } }, ctx);
|
||||||
|
[state] = dimensionCommand.onInput(state, { kind: "option", id: "undo" }, ctx);
|
||||||
|
[state] = dimensionCommand.onConfirm(state, ctx);
|
||||||
|
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
|
||||||
|
const next = result.commit!(p);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
if (g.shape === "dimension") expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entartete Kette (identische Punkte) committet nichts", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
let [state] = dimensionCommand.onInput(
|
||||||
|
dimensionCommand.init(),
|
||||||
|
{ kind: "point", point: { x: 3, y: 3 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 3, y: 3 } }, ctx);
|
||||||
|
[state] = dimensionCommand.onConfirm(state, ctx);
|
||||||
|
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
|
||||||
|
const next = result.commit!(p);
|
||||||
|
expect(next.drawings2d.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter mit nur einem Punkt bricht ohne commit ab", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
const [state] = dimensionCommand.onInput(
|
||||||
|
dimensionCommand.init(),
|
||||||
|
{ kind: "point", point: { x: 0, y: 0 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const [, result] = dimensionCommand.onConfirm(state, ctx);
|
||||||
|
expect(result.commit).toBeUndefined();
|
||||||
|
expect(result.done).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dimensionCommand — bestehende Kette ergänzen (erst selektieren, dann Befehl)", () => {
|
||||||
|
function chain(pts: { x: number; y: number }[], offset = 1): Drawing2D {
|
||||||
|
return {
|
||||||
|
id: "dim-existing",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom: { shape: "dimension", pts, offset },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("Klick jenseits des letzten Punkts verlängert die Kette (dasselbe Element)", () => {
|
||||||
|
const existing = chain([{ x: 0, y: 0 }, { x: 4, y: 0 }]);
|
||||||
|
const p = project([existing]);
|
||||||
|
const ctx = makeCtx(p, "dim-existing");
|
||||||
|
let [state] = dimensionCommand.onInput(
|
||||||
|
dimensionCommand.init(),
|
||||||
|
{ kind: "point", point: { x: 10, y: 0 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
[state] = dimensionCommand.onConfirm(state, ctx);
|
||||||
|
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
|
||||||
|
const next = result.commit!(p);
|
||||||
|
expect(next.drawings2d.length).toBe(1); // kein neues Element
|
||||||
|
expect(next.drawings2d[0].id).toBe("dim-existing");
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
if (g.shape === "dimension") {
|
||||||
|
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Klick vor dem ersten Punkt verlängert rückwärts (wird vorangestellt)", () => {
|
||||||
|
const existing = chain([{ x: 4, y: 0 }, { x: 10, y: 0 }]);
|
||||||
|
const p = project([existing]);
|
||||||
|
const ctx = makeCtx(p, "dim-existing");
|
||||||
|
let [state] = dimensionCommand.onInput(
|
||||||
|
dimensionCommand.init(),
|
||||||
|
{ kind: "point", point: { x: 0, y: 0 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
[state] = dimensionCommand.onConfirm(state, ctx);
|
||||||
|
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
|
||||||
|
const next = result.commit!(p);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
if (g.shape === "dimension") {
|
||||||
|
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Klick zwischen zwei bestehenden Punkten fügt DAZWISCHEN ein", () => {
|
||||||
|
const existing = chain([{ x: 0, y: 0 }, { x: 10, y: 0 }]);
|
||||||
|
const p = project([existing]);
|
||||||
|
const ctx = makeCtx(p, "dim-existing");
|
||||||
|
let [state] = dimensionCommand.onInput(
|
||||||
|
dimensionCommand.init(),
|
||||||
|
{ kind: "point", point: { x: 4, y: 0 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
[state] = dimensionCommand.onConfirm(state, ctx);
|
||||||
|
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
|
||||||
|
const next = result.commit!(p);
|
||||||
|
expect(next.drawings2d.length).toBe(1);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
if (g.shape === "dimension") {
|
||||||
|
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keine Selektion (drawingId null) startet stattdessen eine neue Kette", () => {
|
||||||
|
const existing = chain([{ x: 0, y: 0 }, { x: 4, y: 0 }]);
|
||||||
|
const p = project([existing]);
|
||||||
|
const ctx = makeCtx(p, null);
|
||||||
|
let [state] = dimensionCommand.onInput(
|
||||||
|
dimensionCommand.init(),
|
||||||
|
{ kind: "point", point: { x: 20, y: 20 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 25, y: 20 } }, ctx);
|
||||||
|
[state] = dimensionCommand.onConfirm(state, ctx);
|
||||||
|
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
|
||||||
|
const next = result.commit!(p);
|
||||||
|
expect(next.drawings2d.length).toBe(2); // bestehendes Element bleibt, neues kommt dazu
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// Bemassung (lineare Masslinie(n), SIA 400 B.5.3, Figur 14 „Reihenbemassung").
|
||||||
|
// Bedienung wie eine Polylinie (beliebig viele Klicks, Doppelklick/Enter
|
||||||
|
// beendet), das ERGEBNIS ist aber IMMER eine GERADE Punktreihe: der erste
|
||||||
|
// Klick setzt den Startpunkt, der zweite legt die Richtung fest — jeder
|
||||||
|
// weitere Klick wird auf diese Gerade projiziert (Reihenbemassung misst
|
||||||
|
// mehrere Punkte entlang EINER Referenzlinie, z. B. einer Wand, nie einen
|
||||||
|
// Zickzack-Pfad). Die ganze Kette ist EIN Element (`pts.length` ≥ 2).
|
||||||
|
//
|
||||||
|
// Neu zeichnen:
|
||||||
|
// 1) „Erster Punkt:" → Punkt
|
||||||
|
// 2) „Nächster Punkt ( Fertig Zurück ):" → Punkt … (ab dem 3. Punkt auf die
|
||||||
|
// durch Punkt 1/2 definierte Gerade projiziert)
|
||||||
|
// 3) „Lage der Masslinie:" → Punkt/Zahl → commit neues Element
|
||||||
|
//
|
||||||
|
// Ergänzen einer bestehenden Kette (Rhino-Muster: erst selektieren, dann
|
||||||
|
// Befehl — s. `ctx.selection`): ist beim Start eine `dimension`-Kette
|
||||||
|
// gewählt, lädt der Befehl deren Punkte/Offset vor und der erste Klick wird
|
||||||
|
// per Geraden-Projektion in die Kette EINSORTIERT (`insertIntoChain`) — vor
|
||||||
|
// dem ersten, nach dem letzten (verlängert) ODER zwischen zwei bestehenden
|
||||||
|
// Punkten (fügt ein). Der Commit ersetzt dasselbe Element (keine Kopie).
|
||||||
|
//
|
||||||
|
// Die Masszahl wird beim Rendern aus dist(a,b) abgeleitet (nicht gespeichert)
|
||||||
|
// — bleibt beim späteren Griff-Ziehen automatisch korrekt.
|
||||||
|
|
||||||
|
import type { Drawing2D } from "../../model/types";
|
||||||
|
import { segmentHud, uniqueId } from "../../tools/types";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandField,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
CmdOption,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
|
||||||
|
/** Vorzeichenbehafteter Normalabstand von `pt` zur Geraden a→b (links positiv). */
|
||||||
|
function signedOffset(a: Vec2, b: Vec2, pt: Vec2): number {
|
||||||
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < EPS) return 0;
|
||||||
|
const nx = -dy / len, ny = dx / len;
|
||||||
|
return (pt.x - a.x) * nx + (pt.y - a.y) * ny;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Projiziert `pt` auf die Gerade durch `p0` in Richtung `p1` (Kette bleibt gerade ab Punkt 3). */
|
||||||
|
function projectOnLine(p0: Vec2, p1: Vec2, pt: Vec2): Vec2 {
|
||||||
|
const dx = p1.x - p0.x, dy = p1.y - p0.y;
|
||||||
|
const len2 = dx * dx + dy * dy;
|
||||||
|
if (len2 < EPS) return pt;
|
||||||
|
const t = ((pt.x - p0.x) * dx + (pt.y - p0.y) * dy) / len2;
|
||||||
|
return { x: p0.x + dx * t, y: p0.y + dy * t };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sortiert `raw` (auf die Kettengerade projiziert) an die richtige Stelle in
|
||||||
|
* `pts` ein — VOR dem ersten (verlängert rückwärts), NACH dem letzten
|
||||||
|
* (verlängert vorwärts) oder ZWISCHEN zwei bestehenden Punkten (fügt ein).
|
||||||
|
* `pts` muss bereits kollinear sein (≥ 2 Punkte); die Skalarposition entlang
|
||||||
|
* der durch `pts[0]`/`pts[1]` festgelegten Geraden entscheidet die Reihenfolge.
|
||||||
|
*/
|
||||||
|
function insertIntoChain(pts: Vec2[], raw: Vec2): Vec2[] {
|
||||||
|
if (pts.length < 2) return [...pts, raw];
|
||||||
|
const p0 = pts[0], p1 = pts[1];
|
||||||
|
const dx = p1.x - p0.x, dy = p1.y - p0.y;
|
||||||
|
const len2 = dx * dx + dy * dy || 1;
|
||||||
|
const tOf = (p: Vec2): number => ((p.x - p0.x) * dx + (p.y - p0.y) * dy) / len2;
|
||||||
|
const placed = projectOnLine(p0, p1, raw);
|
||||||
|
const newT = tOf(placed);
|
||||||
|
let idx = pts.findIndex((p) => newT < tOf(p));
|
||||||
|
if (idx === -1) idx = pts.length;
|
||||||
|
const out = [...pts];
|
||||||
|
out.splice(idx, 0, placed);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bestehende, selektierte Masskette (Rhino: erst selektieren, dann Befehl) oder null. */
|
||||||
|
function selectedChain(ctx: CommandContext): { id: string; pts: Vec2[]; offset: number } | null {
|
||||||
|
const id = ctx.selection.drawingId;
|
||||||
|
if (!id) return null;
|
||||||
|
const d = ctx.project.drawings2d.find((x) => x.id === id);
|
||||||
|
if (!d || d.geom.shape !== "dimension") return null;
|
||||||
|
return { id: d.id, pts: d.geom.pts, offset: d.geom.offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Endpunkte der um `offset` parallel verschobenen Masslinie (Strecke a-b). */
|
||||||
|
function offsetPoints(a: Vec2, b: Vec2, offset: number): { a: Vec2; b: Vec2 } {
|
||||||
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||||||
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
|
const nx = (-dy / len) * offset, ny = (dx / len) * offset;
|
||||||
|
return { a: { x: a.x + nx, y: a.y + ny }, b: { x: b.x + nx, y: b.y + ny } };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DimStart extends CommandState {
|
||||||
|
phase: "start";
|
||||||
|
}
|
||||||
|
interface DimNext extends CommandState {
|
||||||
|
phase: "next";
|
||||||
|
pts: Vec2[];
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
/** ID der bearbeiteten Kette (Ergänzen-Modus) — null = neue Kette. */
|
||||||
|
editId: string | null;
|
||||||
|
}
|
||||||
|
interface DimOffset extends CommandState {
|
||||||
|
phase: "offset";
|
||||||
|
pts: Vec2[];
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
editId: string | null;
|
||||||
|
}
|
||||||
|
type DimState = DimStart | DimNext | DimOffset;
|
||||||
|
|
||||||
|
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.dimension.undo" };
|
||||||
|
const FINISH: CmdOption = { id: "finish", labelKey: "cmd.dimension.finish" };
|
||||||
|
const OFFSET_FIELDS: CommandField[] = [{ id: "offset", labelKey: "cmd.field.offset" }];
|
||||||
|
|
||||||
|
/** Vorschau: Punktreihe (Gummiband) während des Sammelns. */
|
||||||
|
function chainDraft(pts: Vec2[], cursor: Vec2 | null): ToolDraft {
|
||||||
|
const shown = cursor ? [...pts, cursor] : pts;
|
||||||
|
const draft: ToolDraft = { preview: [{ kind: "poly", pts: shown, closed: false }], vertices: pts };
|
||||||
|
const last = pts[pts.length - 1];
|
||||||
|
if (cursor && last && dist(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau: alle Masslinien-Segmente bei gegebenem Offset (grob — Massstriche/Text rendert generatePlan). */
|
||||||
|
function offsetDraft(pts: Vec2[], offset: number, at: Vec2 | null): ToolDraft {
|
||||||
|
const preview: DraftShape[] = [];
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
const a = pts[i], b = pts[i + 1];
|
||||||
|
if (Math.abs(offset) > EPS) {
|
||||||
|
const off = offsetPoints(a, b, offset);
|
||||||
|
preview.push({ kind: "line", a, b: off.a });
|
||||||
|
preview.push({ kind: "line", a: b, b: off.b });
|
||||||
|
preview.push({ kind: "line", a: off.a, b: off.b });
|
||||||
|
} else {
|
||||||
|
preview.push({ kind: "line", a, b });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const draft: ToolDraft = { preview, vertices: pts };
|
||||||
|
if (at) {
|
||||||
|
const total = pts.reduce((sum, p, i) => (i === 0 ? 0 : sum + dist(pts[i - 1], p)), 0);
|
||||||
|
draft.hud = { at, text: `Σ${total.toFixed(3)}m · Δ${offset.toFixed(3)}m` };
|
||||||
|
}
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Committet eine Kette: neu anhängen (editId null) ODER dasselbe Element ersetzen. */
|
||||||
|
function commitChain(p: Project, pts: Vec2[], offset: number, editId: string | null, ctx: CommandContext): Project {
|
||||||
|
const cleaned = pts.filter((pt, i) => i === 0 || dist(pts[i - 1], pt) >= EPS);
|
||||||
|
if (cleaned.length < 2) return p; // zu wenig gültige Punkte → nichts committen/ändern
|
||||||
|
if (editId) {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
drawings2d: p.drawings2d.map((d) =>
|
||||||
|
d.id === editId ? { ...d, geom: { shape: "dimension", pts: cleaned, offset } } : d,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: uniqueId("dr2d"),
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: ctx.level.id,
|
||||||
|
categoryCode: ctx.defaultCategoryCode,
|
||||||
|
geom: { shape: "dimension", pts: cleaned, offset },
|
||||||
|
};
|
||||||
|
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "start", lastPoint: null },
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const dimensionCommand: Command = {
|
||||||
|
name: "dimension",
|
||||||
|
labelKey: "cmd.dimension.label",
|
||||||
|
prompt: (s) => {
|
||||||
|
const phase = (s as DimState).phase;
|
||||||
|
return phase === "next" ? "cmd.dimension.next" : phase === "offset" ? "cmd.dimension.offset" : "cmd.dimension.start";
|
||||||
|
},
|
||||||
|
accepts: (s) => ((s as DimState).phase === "offset" ? ["point", "number"] : ["point", "option"]),
|
||||||
|
options: (s) => {
|
||||||
|
const ds = s as DimState;
|
||||||
|
if (ds.phase !== "next") return [];
|
||||||
|
return ds.pts.length >= 2 ? [FINISH, UNDO] : [UNDO];
|
||||||
|
},
|
||||||
|
init: (): DimStart => ({ phase: "start", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as DimState;
|
||||||
|
|
||||||
|
if (input.kind === "option" && s.phase === "next") {
|
||||||
|
if (input.id === "undo") {
|
||||||
|
const pts = s.pts.slice(0, -1);
|
||||||
|
if (pts.length === 0) return [{ phase: "start", lastPoint: null }, { draft: null }];
|
||||||
|
const ns: DimNext = { phase: "next", pts, cursor: s.cursor, editId: s.editId, lastPoint: pts[pts.length - 1] };
|
||||||
|
return [ns, { draft: chainDraft(pts, s.cursor) }];
|
||||||
|
}
|
||||||
|
if (input.id === "finish" && s.pts.length >= 2) {
|
||||||
|
const ns: DimOffset = { phase: "offset", pts: s.pts, cursor: s.cursor, editId: s.editId, lastPoint: s.lastPoint };
|
||||||
|
return [ns, { draft: offsetDraft(s.pts, 0, null) }];
|
||||||
|
}
|
||||||
|
return [s, { draft: chainDraft(s.pts, s.cursor) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.phase !== "offset") {
|
||||||
|
if (input.kind !== "point") return [s, { draft: null }];
|
||||||
|
const pt = input.point;
|
||||||
|
if (s.phase !== "next") {
|
||||||
|
// Erster Klick: bei selektierter Kette wird er EINSORTIERT (Ergänzen);
|
||||||
|
// sonst startet er eine neue Kette.
|
||||||
|
const existing = selectedChain(ctx);
|
||||||
|
const pts = existing ? insertIntoChain(existing.pts, pt) : [pt];
|
||||||
|
const editId = existing ? existing.id : null;
|
||||||
|
const ns: DimNext = { phase: "next", pts, cursor: pt, editId, lastPoint: pt };
|
||||||
|
return [ns, { draft: chainDraft(pts, pt) }];
|
||||||
|
}
|
||||||
|
// Ab dem dritten Punkt (bzw. jedem weiteren Klick im Ergänzen-Modus):
|
||||||
|
// auf die durch Punkt 1/2 festgelegte Gerade projizieren/einsortieren.
|
||||||
|
const pts = s.pts.length >= 2 ? insertIntoChain(s.pts, pt) : [...s.pts, pt];
|
||||||
|
const ns: DimNext = { phase: "next", pts, cursor: pt, editId: s.editId, lastPoint: pt };
|
||||||
|
return [ns, { draft: chainDraft(pts, pt) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offset-Schritt: Zahl = direkter Abstand, Punkt = Abstand zum Cursor.
|
||||||
|
let offset: number;
|
||||||
|
if (input.kind === "number") offset = input.value;
|
||||||
|
else if (input.kind === "point") offset = signedOffset(s.pts[0], s.pts[1], input.point);
|
||||||
|
else return [s, { draft: offsetDraft(s.pts, 0, null) }];
|
||||||
|
const pts = s.pts;
|
||||||
|
const editId = s.editId;
|
||||||
|
return [
|
||||||
|
{ phase: "start", lastPoint: null },
|
||||||
|
{ draft: null, done: true, commit: (p) => commitChain(p, pts, offset, editId, ctx) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as DimState;
|
||||||
|
if (s.phase === "next") {
|
||||||
|
const cursor = s.pts.length >= 2 ? projectOnLine(s.pts[0], s.pts[1], point) : point;
|
||||||
|
const ns: DimNext = { ...s, cursor };
|
||||||
|
return [ns, { draft: chainDraft(s.pts, cursor) }];
|
||||||
|
}
|
||||||
|
if (s.phase === "offset") {
|
||||||
|
// Offset relativ zum ERSTEN Segment (repräsentativ für die ganze Kette).
|
||||||
|
const off = signedOffset(s.pts[0], s.pts[1], point);
|
||||||
|
const ns: DimOffset = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: offsetDraft(s.pts, off, point) }];
|
||||||
|
}
|
||||||
|
return [s, { draft: null }];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Enter/Space/Rechtsklick: im "next"-Schritt (≥2 Punkte) wie Doppelklick →
|
||||||
|
// Punktreihe abschliessen; im "offset"-Schritt bricht es ab (kein sinnvoller
|
||||||
|
// Default-Abstand ohne Zeigegeste).
|
||||||
|
onConfirm: (state): [CommandState, CommandResult] => {
|
||||||
|
const s = state as DimState;
|
||||||
|
if (s.phase === "next" && s.pts.length >= 2) {
|
||||||
|
const ns: DimOffset = { phase: "offset", pts: s.pts, cursor: s.cursor, editId: s.editId, lastPoint: s.lastPoint };
|
||||||
|
return [ns, { draft: offsetDraft(s.pts, 0, null) }];
|
||||||
|
}
|
||||||
|
return idle();
|
||||||
|
},
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
|
||||||
|
fields: (state) => ((state as DimState).phase === "offset" ? OFFSET_FIELDS : []),
|
||||||
|
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||||
|
const s = state as DimState;
|
||||||
|
if (s.phase !== "offset" || !cursor) return {};
|
||||||
|
return { offset: signedOffset(s.pts[0], s.pts[1], cursor) };
|
||||||
|
},
|
||||||
|
pointFromFields: (state, locks, cursor) => {
|
||||||
|
const s = state as DimState;
|
||||||
|
if (s.phase !== "offset") return null;
|
||||||
|
const offset = "offset" in locks ? locks.offset : cursor ? signedOffset(s.pts[0], s.pts[1], cursor) : 0;
|
||||||
|
return offsetPoints(s.pts[0], s.pts[1], offset).a;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* `ellipseCommand` — zwei Bounding-Box-Ecken → achsparallele Ellipse
|
||||||
|
* (shape:"ellipse", rotation 0).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { ellipseCommand } from "./ellipse";
|
||||||
|
import type { CommandContext, Project } from "../types";
|
||||||
|
|
||||||
|
function project(): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||||
|
],
|
||||||
|
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(p: Project): CommandContext {
|
||||||
|
return {
|
||||||
|
project: p,
|
||||||
|
level: p.drawingLevels[0],
|
||||||
|
defaultCategoryCode: "20",
|
||||||
|
activeLineStyleId: "solid",
|
||||||
|
activeWallTypeId: "aw",
|
||||||
|
lastPoint: null,
|
||||||
|
selection: { wallIds: [], drawingId: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ellipseCommand", () => {
|
||||||
|
it("zwei Ecken → Zentrum + Halbachsen aus der Bounding-Box, rotation 0", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
const [afterCorner1] = ellipseCommand.onInput(
|
||||||
|
ellipseCommand.init(),
|
||||||
|
{ kind: "point", point: { x: 0, y: 0 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const [, result] = ellipseCommand.onInput(
|
||||||
|
afterCorner1,
|
||||||
|
{ kind: "point", point: { x: 8, y: 4 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
expect(result.commit).toBeDefined();
|
||||||
|
const next = result.commit!(p);
|
||||||
|
expect(next.drawings2d.length).toBe(1);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
expect(g.shape).toBe("ellipse");
|
||||||
|
if (g.shape === "ellipse") {
|
||||||
|
expect(g.center).toEqual({ x: 4, y: 2 });
|
||||||
|
expect(g.rx).toBeCloseTo(4, 6);
|
||||||
|
expect(g.ry).toBeCloseTo(2, 6);
|
||||||
|
expect(g.rotation).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entartete Bbox (Punkt=Punkt) committet nichts", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
const [afterCorner1] = ellipseCommand.onInput(
|
||||||
|
ellipseCommand.init(),
|
||||||
|
{ kind: "point", point: { x: 5, y: 5 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const [, result] = ellipseCommand.onInput(
|
||||||
|
afterCorner1,
|
||||||
|
{ kind: "point", point: { x: 5, y: 5 } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const next = result.commit!(p);
|
||||||
|
expect(next.drawings2d.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Ellipse — zwei gegenüberliegende Ecken einer Bounding-Box (achsparallel,
|
||||||
|
// wie das 2-Punkt-Rechteck). Schritte:
|
||||||
|
// 1) „Erste Ecke:" → Punkt
|
||||||
|
// 2) „Gegenüberliegende Ecke:" → Punkt → commit (shape:"ellipse", rotation 0)
|
||||||
|
//
|
||||||
|
// Bewusst nur die einfache Bounding-Box-Methode (kein Dreh-Winkel über das
|
||||||
|
// Werkzeug) — `rotation` bleibt 0, das Feld existiert im Modell für spätere
|
||||||
|
// Dreh-Bearbeitung/andere Quellen (s. `model/types.ts`).
|
||||||
|
|
||||||
|
import type { Drawing2D } from "../../model/types";
|
||||||
|
import { uniqueId } from "../../tools/types";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
import { ellipsePts } from "../../tools/curves";
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
|
||||||
|
interface EllipseIdle extends CommandState {
|
||||||
|
phase: "corner1";
|
||||||
|
}
|
||||||
|
interface EllipseDrawing extends CommandState {
|
||||||
|
phase: "corner2";
|
||||||
|
p0: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
type EllipseState = EllipseIdle | EllipseDrawing;
|
||||||
|
|
||||||
|
/** Zentrum + Halbachsen aus zwei gegenüberliegenden Bounding-Box-Ecken. */
|
||||||
|
function ellipseFromCorners(p0: Vec2, p1: Vec2): { center: Vec2; rx: number; ry: number } {
|
||||||
|
return {
|
||||||
|
center: { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 },
|
||||||
|
rx: Math.abs(p1.x - p0.x) / 2,
|
||||||
|
ry: Math.abs(p1.y - p0.y) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau (tessellierte Ellipse) + HUD (Breite×Höhe). */
|
||||||
|
function ellipseDraft(p0: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
|
if (!cursor) return { preview: [], vertices: [p0] };
|
||||||
|
const { center, rx, ry } = ellipseFromCorners(p0, cursor);
|
||||||
|
if (rx < EPS || ry < EPS) return { preview: [], vertices: [p0] };
|
||||||
|
const preview: DraftShape[] = [{ kind: "poly", pts: ellipsePts(center, rx, ry, 0), closed: true }];
|
||||||
|
return {
|
||||||
|
preview,
|
||||||
|
vertices: [p0],
|
||||||
|
hud: { at: cursor, text: `${(rx * 2).toFixed(3)}×${(ry * 2).toFixed(3)}m` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendEllipse(p: Project, center: Vec2, rx: number, ry: number, ctx: CommandContext): Project {
|
||||||
|
if (rx < EPS || ry < EPS) return p;
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: uniqueId("dr2d"),
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: ctx.level.id,
|
||||||
|
categoryCode: ctx.defaultCategoryCode,
|
||||||
|
geom: { shape: "ellipse", center, rx, ry, rotation: 0 },
|
||||||
|
};
|
||||||
|
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "corner1", lastPoint: null },
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ellipseCommand: Command = {
|
||||||
|
name: "ellipse",
|
||||||
|
labelKey: "cmd.ellipse.label",
|
||||||
|
prompt: (s) => ((s as EllipseState).phase === "corner2" ? "cmd.ellipse.corner2" : "cmd.ellipse.corner1"),
|
||||||
|
accepts: () => ["point"],
|
||||||
|
options: () => [],
|
||||||
|
init: (): EllipseIdle => ({ phase: "corner1", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as EllipseState;
|
||||||
|
if (input.kind !== "point") return [s, { draft: null }];
|
||||||
|
if (s.phase !== "corner2") {
|
||||||
|
const ns: EllipseDrawing = { phase: "corner2", p0: input.point, cursor: input.point, lastPoint: input.point };
|
||||||
|
return [ns, { draft: ellipseDraft(input.point, null) }];
|
||||||
|
}
|
||||||
|
const { center, rx, ry } = ellipseFromCorners(s.p0, input.point);
|
||||||
|
return [
|
||||||
|
{ phase: "corner1", lastPoint: null },
|
||||||
|
{ draft: null, done: true, commit: (p) => appendEllipse(p, center, rx, ry, ctx) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as EllipseState;
|
||||||
|
if (s.phase !== "corner2") return [s, { draft: null }];
|
||||||
|
const ns: EllipseDrawing = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: ellipseDraft(s.p0, point) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* `koteCommand` — Punkt + getippter Höhenwert → `shape:"kote"` (SIA 400
|
||||||
|
* B.5.4, Figur 18). Default-Art „OK fertig"; per Option zyklisch umschaltbar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { koteCommand } from "./kote";
|
||||||
|
import type { CommandContext, Project } from "../types";
|
||||||
|
|
||||||
|
function project(): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||||
|
],
|
||||||
|
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(p: Project): CommandContext {
|
||||||
|
return {
|
||||||
|
project: p,
|
||||||
|
level: p.drawingLevels[0],
|
||||||
|
defaultCategoryCode: "20",
|
||||||
|
activeLineStyleId: "solid",
|
||||||
|
activeWallTypeId: "aw",
|
||||||
|
lastPoint: null,
|
||||||
|
selection: { wallIds: [], drawingId: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("koteCommand", () => {
|
||||||
|
it("Punkt + Zahl → shape:kote mit Default-Art OK fertig, rotation 0", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
let [state] = koteCommand.onInput(koteCommand.init(), { kind: "point", point: { x: 2, y: 3 } }, ctx);
|
||||||
|
const [, result] = koteCommand.onInput(state, { kind: "number", value: 3.25 }, ctx);
|
||||||
|
expect(result.commit).toBeDefined();
|
||||||
|
const next = result.commit!(p);
|
||||||
|
expect(next.drawings2d.length).toBe(1);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
expect(g.shape).toBe("kote");
|
||||||
|
if (g.shape === "kote") {
|
||||||
|
expect(g.at).toEqual({ x: 2, y: 3 });
|
||||||
|
expect(g.value).toBe(3.25);
|
||||||
|
expect(g.kind).toBe("okFertig");
|
||||||
|
expect(g.rotation).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Art-Option zyklt OK fertig → UK fertig → OK roh → UK roh → OK fertig", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
let [state] = koteCommand.onInput(koteCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
|
||||||
|
const seen: string[] = [];
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
[state] = koteCommand.onInput(state, { kind: "option", id: "kind" }, ctx);
|
||||||
|
const opts = koteCommand.options(state);
|
||||||
|
seen.push(opts[0].value!);
|
||||||
|
}
|
||||||
|
expect(seen).toEqual(["ukFertig", "okRoh", "ukRoh", "okFertig"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("negativer Wert wird unverändert übernommen (Formatierung passiert erst beim Rendern)", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
let [state] = koteCommand.onInput(koteCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
|
||||||
|
const [, result] = koteCommand.onInput(state, { kind: "number", value: -0.1 }, ctx);
|
||||||
|
const next = result.commit!(p);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
if (g.shape === "kote") expect(g.value).toBe(-0.1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Höhenkote (SIA 400 B.5.4, Figur 18) — Dreieck-Symbol + Höhenwert an einem
|
||||||
|
// Punkt. Schritte:
|
||||||
|
// 1) „Punkt:" → Punkt (Dreieck-Spitze)
|
||||||
|
// 2) „Höhenwert ( Art ):" → Zahl (Meter, z. B. 3.25 oder -0.1) →
|
||||||
|
// commit. „Art" (Option, zyklisch: OK fertig/UK fertig/OK roh/UK roh)
|
||||||
|
// wechselt VOR dem Tippen die Dreieck-Variante (Default OK fertig).
|
||||||
|
|
||||||
|
import type { Drawing2D } from "../../model/types";
|
||||||
|
import { uniqueId } from "../../tools/types";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
CmdOption,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
import type { TranslationKey } from "../../i18n";
|
||||||
|
|
||||||
|
type KoteKind = "okFertig" | "ukFertig" | "okRoh" | "ukRoh";
|
||||||
|
const KIND_ORDER: KoteKind[] = ["okFertig", "ukFertig", "okRoh", "ukRoh"];
|
||||||
|
const nextKind = (k: KoteKind): KoteKind => KIND_ORDER[(KIND_ORDER.indexOf(k) + 1) % KIND_ORDER.length];
|
||||||
|
const KIND_LABEL: Record<KoteKind, TranslationKey> = {
|
||||||
|
okFertig: "cmd.kote.kind.okFertig",
|
||||||
|
ukFertig: "cmd.kote.kind.ukFertig",
|
||||||
|
okRoh: "cmd.kote.kind.okRoh",
|
||||||
|
ukRoh: "cmd.kote.kind.ukRoh",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRI_SIZE = 0.15; // Modell-Meter, Vorschau-Grösse (Rendering: DIM_… in drawing2d.ts)
|
||||||
|
const HALF_W = TRI_SIZE * 0.6;
|
||||||
|
|
||||||
|
/** Dreieck-Punkte für die Vorschau: `at` = Spitze; OK zeigt nach unten, UK nach oben. */
|
||||||
|
function triPts(at: Vec2, kind: KoteKind): Vec2[] {
|
||||||
|
const up = kind === "okFertig" || kind === "okRoh" ? 1 : -1; // Basis liegt in +up-Richtung von `at`
|
||||||
|
return [
|
||||||
|
at,
|
||||||
|
{ x: at.x - HALF_W, y: at.y + up * TRI_SIZE },
|
||||||
|
{ x: at.x + HALF_W, y: at.y + up * TRI_SIZE },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KotePoint extends CommandState {
|
||||||
|
phase: "point";
|
||||||
|
}
|
||||||
|
interface KoteValue extends CommandState {
|
||||||
|
phase: "value";
|
||||||
|
at: Vec2;
|
||||||
|
kind: KoteKind;
|
||||||
|
}
|
||||||
|
type KoteState = KotePoint | KoteValue;
|
||||||
|
|
||||||
|
function kindOption(kind: KoteKind): CmdOption {
|
||||||
|
return { id: "kind", labelKey: KIND_LABEL[kind], value: kind };
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueDraft(at: Vec2, kind: KoteKind): ToolDraft {
|
||||||
|
const preview: DraftShape[] = [{ kind: "poly", pts: triPts(at, kind), closed: true }];
|
||||||
|
return { preview, vertices: [at] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendKote(p: Project, at: Vec2, value: number, kind: KoteKind, ctx: CommandContext): Project {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: uniqueId("dr2d"),
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: ctx.level.id,
|
||||||
|
categoryCode: ctx.defaultCategoryCode,
|
||||||
|
geom: { shape: "kote", at, value, kind, rotation: 0 },
|
||||||
|
};
|
||||||
|
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "point", lastPoint: null },
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const koteCommand: Command = {
|
||||||
|
name: "kote",
|
||||||
|
labelKey: "cmd.kote.label",
|
||||||
|
prompt: (s) => ((s as KoteState).phase === "value" ? "cmd.kote.value" : "cmd.kote.point"),
|
||||||
|
accepts: (s) => ((s as KoteState).phase === "value" ? ["number", "option"] : ["point"]),
|
||||||
|
options: (s) => ((s as KoteState).phase === "value" ? [kindOption((s as KoteValue).kind)] : []),
|
||||||
|
init: (): KotePoint => ({ phase: "point", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as KoteState;
|
||||||
|
if (s.phase === "point") {
|
||||||
|
if (input.kind !== "point") return [s, { draft: null }];
|
||||||
|
const ns: KoteValue = { phase: "value", at: input.point, kind: "okFertig", lastPoint: input.point };
|
||||||
|
return [ns, { draft: valueDraft(input.point, "okFertig") }];
|
||||||
|
}
|
||||||
|
if (input.kind === "option" && input.id === "kind") {
|
||||||
|
const ns: KoteValue = { ...s, kind: nextKind(s.kind) };
|
||||||
|
return [ns, { draft: valueDraft(s.at, ns.kind) }];
|
||||||
|
}
|
||||||
|
if (input.kind !== "number") return [s, { draft: valueDraft(s.at, s.kind) }];
|
||||||
|
const { at, kind } = s;
|
||||||
|
return [
|
||||||
|
{ phase: "point", lastPoint: null },
|
||||||
|
{ draft: null, done: true, commit: (p) => appendKote(p, at, input.value, kind, ctx) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
};
|
||||||
@@ -35,6 +35,7 @@ function hasSelection(sel: CommandSelection): boolean {
|
|||||||
return (
|
return (
|
||||||
sel.wallIds.length > 0 ||
|
sel.wallIds.length > 0 ||
|
||||||
sel.drawingId !== null ||
|
sel.drawingId !== null ||
|
||||||
|
(sel.drawingIds?.length ?? 0) > 0 ||
|
||||||
!!sel.extrudedSolidId ||
|
!!sel.extrudedSolidId ||
|
||||||
!!sel.columnId
|
!!sel.columnId
|
||||||
);
|
);
|
||||||
@@ -45,6 +46,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
|
|||||||
return {
|
return {
|
||||||
wallIds: sel.wallIds,
|
wallIds: sel.wallIds,
|
||||||
drawingId: sel.drawingId,
|
drawingId: sel.drawingId,
|
||||||
|
drawingIds: sel.drawingIds,
|
||||||
extrudedSolidId: sel.extrudedSolidId,
|
extrudedSolidId: sel.extrudedSolidId,
|
||||||
columnId: sel.columnId,
|
columnId: sel.columnId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function hasSelection(sel: CommandSelection): boolean {
|
|||||||
return (
|
return (
|
||||||
sel.wallIds.length > 0 ||
|
sel.wallIds.length > 0 ||
|
||||||
sel.drawingId !== null ||
|
sel.drawingId !== null ||
|
||||||
|
(sel.drawingIds?.length ?? 0) > 0 ||
|
||||||
!!sel.extrudedSolidId ||
|
!!sel.extrudedSolidId ||
|
||||||
!!sel.columnId
|
!!sel.columnId
|
||||||
);
|
);
|
||||||
@@ -62,6 +63,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
|
|||||||
return {
|
return {
|
||||||
wallIds: sel.wallIds,
|
wallIds: sel.wallIds,
|
||||||
drawingId: sel.drawingId,
|
drawingId: sel.drawingId,
|
||||||
|
drawingIds: sel.drawingIds,
|
||||||
extrudedSolidId: sel.extrudedSolidId,
|
extrudedSolidId: sel.extrudedSolidId,
|
||||||
columnId: sel.columnId,
|
columnId: sel.columnId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* `splineCommand` — Wegpunkte klicken (wie polyline), Enter/„Schliessen"
|
||||||
|
* committet als `shape:"spline"` mit den ROHEN Wegpunkten (Tessellierung
|
||||||
|
* passiert erst beim Rendern/Snapping, s. `tools/curves.ts`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { splineCommand } from "./spline";
|
||||||
|
import type { CommandContext, Project } from "../types";
|
||||||
|
|
||||||
|
function project(): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||||
|
],
|
||||||
|
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(p: Project): CommandContext {
|
||||||
|
return {
|
||||||
|
project: p,
|
||||||
|
level: p.drawingLevels[0],
|
||||||
|
defaultCategoryCode: "20",
|
||||||
|
activeLineStyleId: "solid",
|
||||||
|
activeWallTypeId: "aw",
|
||||||
|
lastPoint: null,
|
||||||
|
selection: { wallIds: [], drawingId: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("splineCommand", () => {
|
||||||
|
it("drei Punkte + Enter → offene Spline mit den drei Wegpunkten", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
let [state] = splineCommand.onInput(splineCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
|
||||||
|
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 2, y: 3 } }, ctx);
|
||||||
|
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 5, y: 1 } }, ctx);
|
||||||
|
const [, result] = splineCommand.onConfirm(state, ctx);
|
||||||
|
expect(result.commit).toBeDefined();
|
||||||
|
const next = result.commit!(p);
|
||||||
|
expect(next.drawings2d.length).toBe(1);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
expect(g.shape).toBe("spline");
|
||||||
|
if (g.shape === "spline") {
|
||||||
|
expect(g.closed).toBe(false);
|
||||||
|
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 5, y: 1 }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Schliessen-Option committet als Ring (closed: true)", () => {
|
||||||
|
const p = project();
|
||||||
|
const ctx = makeCtx(p);
|
||||||
|
let [state] = splineCommand.onInput(splineCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
|
||||||
|
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
|
||||||
|
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 4, y: 4 } }, ctx);
|
||||||
|
const [, result] = splineCommand.onInput(state, { kind: "option", id: "close" }, ctx);
|
||||||
|
const next = result.commit!(p);
|
||||||
|
const g = next.drawings2d[0].geom;
|
||||||
|
expect(g.shape).toBe("spline");
|
||||||
|
if (g.shape === "spline") expect(g.closed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Spline — glatte Kurve DURCH mehrere Wegpunkte (uniformer Catmull-Rom),
|
||||||
|
// Bedienung 1:1 wie `polyline` (s. dort), nur dass die Vorschau/das Ergebnis
|
||||||
|
// zwischen den Punkten rundet statt gerade Kanten zu ziehen. Schritte:
|
||||||
|
// 1) „Startpunkt:" → Punkt
|
||||||
|
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … (Enter beendet offen,
|
||||||
|
// „Schliessen" schließt; Klick auf den Startpunkt schließt ebenfalls)
|
||||||
|
|
||||||
|
import type { Drawing2D } from "../../model/types";
|
||||||
|
import { segmentHud, uniqueId } from "../../tools/types";
|
||||||
|
import { sampleCatmullRom } from "../../tools/curves";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
CmdOption,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Zug
|
||||||
|
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
|
||||||
|
interface SplineIdle extends CommandState {
|
||||||
|
phase: "start";
|
||||||
|
closed: boolean;
|
||||||
|
}
|
||||||
|
interface SplineDrawing extends CommandState {
|
||||||
|
phase: "next";
|
||||||
|
points: Vec2[];
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
closed: boolean;
|
||||||
|
}
|
||||||
|
type SplineState = SplineIdle | SplineDrawing;
|
||||||
|
|
||||||
|
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
|
||||||
|
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
|
||||||
|
const closedOption = (closed: boolean): CmdOption => ({
|
||||||
|
id: "mode",
|
||||||
|
labelKey: "cmd.polyline.closedOpt",
|
||||||
|
value: closed ? "on" : "off",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Vorschau (geglätteter Zug + Gummiband zum Cursor) + HUD. */
|
||||||
|
function splineDraft(points: Vec2[], cursor: Vec2 | null, closed = false): ToolDraft {
|
||||||
|
const pts = cursor ? [...points, cursor] : points;
|
||||||
|
const showClosed = closed && pts.length >= 3;
|
||||||
|
const curve = sampleCatmullRom(pts, showClosed);
|
||||||
|
const preview: DraftShape[] = [{ kind: "poly", pts: curve, closed: showClosed }];
|
||||||
|
const draft: ToolDraft = { preview, vertices: points };
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
if (cursor && last && segLen(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSpline(p: Project, pts: Vec2[], closed: boolean, ctx: CommandContext): Project {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: uniqueId("dr2d"),
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: ctx.level.id,
|
||||||
|
categoryCode: ctx.defaultCategoryCode,
|
||||||
|
geom: { shape: "spline", pts, closed },
|
||||||
|
};
|
||||||
|
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = (closed = false): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "start", lastPoint: null, closed } as SplineIdle,
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const splineCommand: Command = {
|
||||||
|
name: "spline",
|
||||||
|
labelKey: "cmd.spline.label",
|
||||||
|
prompt: (s) => ((s as SplineState).phase === "next" ? "cmd.spline.next" : "cmd.spline.start"),
|
||||||
|
accepts: () => ["point", "option"],
|
||||||
|
options: (s) => {
|
||||||
|
const ps = s as SplineState;
|
||||||
|
const toggle = closedOption(ps.closed);
|
||||||
|
if (ps.phase !== "next") return [toggle];
|
||||||
|
return ps.points.length >= 2 ? [CLOSE, toggle, UNDO] : [toggle, UNDO];
|
||||||
|
},
|
||||||
|
init: (): SplineIdle => ({ phase: "start", lastPoint: null, closed: false }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as SplineState;
|
||||||
|
|
||||||
|
if (input.kind === "option") {
|
||||||
|
if (input.id === "mode") {
|
||||||
|
const ns = { ...s, closed: !s.closed } as SplineState;
|
||||||
|
return [ns, { draft: s.phase === "next" ? splineDraft(s.points, s.cursor, ns.closed) : null }];
|
||||||
|
}
|
||||||
|
if (s.phase !== "next") return [s, { draft: null }];
|
||||||
|
if (input.id === "undo") {
|
||||||
|
const pts = s.points.slice(0, -1);
|
||||||
|
if (pts.length === 0)
|
||||||
|
return [{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle, { draft: null }];
|
||||||
|
const ns: SplineDrawing = {
|
||||||
|
phase: "next",
|
||||||
|
points: pts,
|
||||||
|
cursor: s.cursor,
|
||||||
|
lastPoint: pts[pts.length - 1],
|
||||||
|
closed: s.closed,
|
||||||
|
};
|
||||||
|
return [ns, { draft: splineDraft(pts, s.cursor, s.closed) }];
|
||||||
|
}
|
||||||
|
if (input.id === "close" && s.points.length >= 3) {
|
||||||
|
const pts = s.points;
|
||||||
|
return [
|
||||||
|
{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle,
|
||||||
|
{ draft: null, done: true, commit: (p) => appendSpline(p, pts, true, ctx) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [s, { draft: splineDraft(s.points, s.cursor, s.closed) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.kind !== "point") {
|
||||||
|
return [s, { draft: s.phase === "next" ? splineDraft(s.points, s.cursor, s.closed) : null }];
|
||||||
|
}
|
||||||
|
const pt = input.point;
|
||||||
|
if (s.phase !== "next") {
|
||||||
|
const ns: SplineDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt, closed: s.closed };
|
||||||
|
return [ns, { draft: splineDraft([pt], pt, s.closed) }];
|
||||||
|
}
|
||||||
|
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
|
||||||
|
const pts = s.points;
|
||||||
|
return [
|
||||||
|
{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle,
|
||||||
|
{ draft: null, done: true, commit: (p) => appendSpline(p, pts, true, ctx) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const points = [...s.points, pt];
|
||||||
|
const ns: SplineDrawing = { phase: "next", points, cursor: pt, lastPoint: pt, closed: s.closed };
|
||||||
|
return [ns, { draft: splineDraft(points, pt, s.closed) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as SplineState;
|
||||||
|
if (s.phase !== "next") return [s, { draft: null }];
|
||||||
|
const ns: SplineDrawing = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: splineDraft(s.points, point, s.closed) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as SplineState;
|
||||||
|
if (s.phase === "next" && s.points.length >= 2) {
|
||||||
|
const pts = s.points;
|
||||||
|
const close = s.closed && pts.length >= 3;
|
||||||
|
return [
|
||||||
|
{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle,
|
||||||
|
{ draft: null, done: true, commit: (p) => appendSpline(p, pts, close, ctx) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return idle(s.closed);
|
||||||
|
},
|
||||||
|
onCancel: (state): [CommandState, CommandResult] => idle((state as SplineState).closed),
|
||||||
|
};
|
||||||
@@ -17,12 +17,18 @@ import { roomCommand } from "./cmds/room";
|
|||||||
import { rectCommand } from "./cmds/rect";
|
import { rectCommand } from "./cmds/rect";
|
||||||
import { circleCommand } from "./cmds/circle";
|
import { circleCommand } from "./cmds/circle";
|
||||||
import { arcCommand } from "./cmds/arc";
|
import { arcCommand } from "./cmds/arc";
|
||||||
|
import { ellipseCommand } from "./cmds/ellipse";
|
||||||
|
import { splineCommand } from "./cmds/spline";
|
||||||
|
import { bezierCommand } from "./cmds/bezier";
|
||||||
|
import { dimensionCommand } from "./cmds/dimension";
|
||||||
|
import { koteCommand } from "./cmds/kote";
|
||||||
import { textCommand } from "./cmds/text";
|
import { textCommand } from "./cmds/text";
|
||||||
import { textboxCommand } from "./cmds/textbox";
|
import { textboxCommand } from "./cmds/textbox";
|
||||||
import { moveCommand } from "./cmds/move";
|
import { moveCommand } from "./cmds/move";
|
||||||
import { mirrorCommand } from "./cmds/mirror";
|
import { mirrorCommand } from "./cmds/mirror";
|
||||||
import { joinCommand } from "./cmds/join";
|
import { joinCommand } from "./cmds/join";
|
||||||
import { copyCommand } from "./cmds/copy";
|
import { copyCommand } from "./cmds/copy";
|
||||||
|
import { arrayCommand } from "./cmds/array";
|
||||||
import { offsetCommand } from "./cmds/offset";
|
import { offsetCommand } from "./cmds/offset";
|
||||||
import { extrudeCommand } from "./cmds/extrude";
|
import { extrudeCommand } from "./cmds/extrude";
|
||||||
import { trimCommand } from "./cmds/trim";
|
import { trimCommand } from "./cmds/trim";
|
||||||
@@ -53,12 +59,18 @@ export const COMMANDS: Record<string, Command> = {
|
|||||||
rect: rectCommand,
|
rect: rectCommand,
|
||||||
circle: circleCommand,
|
circle: circleCommand,
|
||||||
arc: arcCommand,
|
arc: arcCommand,
|
||||||
|
ellipse: ellipseCommand,
|
||||||
|
spline: splineCommand,
|
||||||
|
bezier: bezierCommand,
|
||||||
|
dimension: dimensionCommand,
|
||||||
|
kote: koteCommand,
|
||||||
text: textCommand,
|
text: textCommand,
|
||||||
textbox: textboxCommand,
|
textbox: textboxCommand,
|
||||||
move: moveCommand,
|
move: moveCommand,
|
||||||
mirror: mirrorCommand,
|
mirror: mirrorCommand,
|
||||||
join: joinCommand,
|
join: joinCommand,
|
||||||
copy: copyCommand,
|
copy: copyCommand,
|
||||||
|
array: arrayCommand,
|
||||||
offset: offsetCommand,
|
offset: offsetCommand,
|
||||||
extrude: extrudeCommand,
|
extrude: extrudeCommand,
|
||||||
trim: trimCommand,
|
trim: trimCommand,
|
||||||
@@ -104,6 +116,13 @@ export const ALIASES: Record<string, string> = {
|
|||||||
c: "circle",
|
c: "circle",
|
||||||
a: "arc",
|
a: "arc",
|
||||||
bogen: "arc",
|
bogen: "arc",
|
||||||
|
el: "ellipse",
|
||||||
|
sp: "spline",
|
||||||
|
dim: "dimension",
|
||||||
|
bemassung: "dimension",
|
||||||
|
kt: "kote",
|
||||||
|
hoehenkote: "kote",
|
||||||
|
bz: "bezier",
|
||||||
tx: "text",
|
tx: "text",
|
||||||
txt: "text",
|
txt: "text",
|
||||||
beschriftung: "text",
|
beschriftung: "text",
|
||||||
@@ -113,6 +132,8 @@ export const ALIASES: Record<string, string> = {
|
|||||||
j: "join",
|
j: "join",
|
||||||
verbinden: "join",
|
verbinden: "join",
|
||||||
cp: "copy",
|
cp: "copy",
|
||||||
|
ar: "array",
|
||||||
|
feld: "array",
|
||||||
o: "offset",
|
o: "offset",
|
||||||
ex: "extrude",
|
ex: "extrude",
|
||||||
tr: "trim",
|
tr: "trim",
|
||||||
|
|||||||
@@ -178,6 +178,16 @@ export const de = {
|
|||||||
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
|
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
|
||||||
"tool.arc": "Bogen",
|
"tool.arc": "Bogen",
|
||||||
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
|
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
|
||||||
|
"tool.ellipse": "Ellipse",
|
||||||
|
"tool.ellipse.hint": "Ellipse: zwei gegenüberliegende Ecken der Bounding-Box",
|
||||||
|
"tool.spline": "Spline",
|
||||||
|
"tool.spline.hint": "Spline: Wegpunkte klicken, Enter beendet (glatte Kurve durch die Punkte)",
|
||||||
|
"tool.bezier": "Bezier",
|
||||||
|
"tool.bezier.hint": "Bezier: Anker, Griff 1, Griff 2, Endpunkt — je Segment; Enter beendet",
|
||||||
|
"tool.dimension": "Bemassung",
|
||||||
|
"tool.dimension.hint": "Bemassung: Punkte wie Polylinie klicken (ab Punkt 3 auf einer Geraden), Doppelklick/Enter beendet, dann Lage der Masslinie (Massketten in einer Geste)",
|
||||||
|
"tool.kote": "Höhenkote",
|
||||||
|
"tool.kote.hint": "Höhenkote: Punkt setzen, dann Höhenwert eintippen (Art umschaltbar: OK/UK, fertig/roh)",
|
||||||
"tool.text": "Text",
|
"tool.text": "Text",
|
||||||
"tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen",
|
"tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen",
|
||||||
"tool.textbox": "Textspalte",
|
"tool.textbox": "Textspalte",
|
||||||
@@ -200,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",
|
||||||
@@ -233,6 +245,7 @@ export const de = {
|
|||||||
"snap.enabled": "Fang aktiv",
|
"snap.enabled": "Fang aktiv",
|
||||||
"snap.endpoint": "Endpunkt",
|
"snap.endpoint": "Endpunkt",
|
||||||
"snap.midpoint": "Mittelpunkt",
|
"snap.midpoint": "Mittelpunkt",
|
||||||
|
"snap.center": "Kreismitte/Quadranten (0/90/180/270°)",
|
||||||
"snap.intersection": "Schnittpunkt",
|
"snap.intersection": "Schnittpunkt",
|
||||||
"snap.onEdge": "Auf Kante (Lot)",
|
"snap.onEdge": "Auf Kante (Lot)",
|
||||||
"snap.grid": "Raster",
|
"snap.grid": "Raster",
|
||||||
@@ -241,6 +254,8 @@ export const de = {
|
|||||||
"snap.angleStep": "Winkelraster",
|
"snap.angleStep": "Winkelraster",
|
||||||
"snap.commonAngles": "Gängige Winkel",
|
"snap.commonAngles": "Gängige Winkel",
|
||||||
"snap.commonAngleTolerance": "Winkeltoleranz (°)",
|
"snap.commonAngleTolerance": "Winkeltoleranz (°)",
|
||||||
|
"snap.hoverDivideDelayMs": "Hover-Verzögerung (ms)",
|
||||||
|
"snap.hoverDivideCount": "Teilpunkte (Anzahl)",
|
||||||
|
|
||||||
// ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ───────────────
|
// ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ───────────────
|
||||||
"transform.bar": "Transformation",
|
"transform.bar": "Transformation",
|
||||||
@@ -1139,6 +1154,32 @@ export const de = {
|
|||||||
"cmd.arc.center": "Mittelpunkt:",
|
"cmd.arc.center": "Mittelpunkt:",
|
||||||
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
|
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
|
||||||
"cmd.arc.end": "Endpunkt (Endwinkel):",
|
"cmd.arc.end": "Endpunkt (Endwinkel):",
|
||||||
|
"cmd.ellipse.label": "Ellipse",
|
||||||
|
"cmd.ellipse.corner1": "Erste Ecke:",
|
||||||
|
"cmd.ellipse.corner2": "Gegenüberliegende Ecke:",
|
||||||
|
"cmd.spline.label": "Spline",
|
||||||
|
"cmd.spline.start": "Startpunkt:",
|
||||||
|
"cmd.spline.next": "Nächster Punkt ( Schliessen Zurück ):",
|
||||||
|
"cmd.bezier.label": "Bezier",
|
||||||
|
"cmd.bezier.start": "Startpunkt:",
|
||||||
|
"cmd.bezier.c1": "Griff 1:",
|
||||||
|
"cmd.bezier.c2": "Griff 2:",
|
||||||
|
"cmd.bezier.end": "Endpunkt ( Fertig ):",
|
||||||
|
"cmd.bezier.finish": "Fertig",
|
||||||
|
"cmd.dimension.label": "Bemassung",
|
||||||
|
"cmd.dimension.start": "Erster Punkt:",
|
||||||
|
"cmd.dimension.next": "Nächster Punkt ( Fertig Zurück ):",
|
||||||
|
"cmd.dimension.offset": "Lage der Masslinie:",
|
||||||
|
"cmd.dimension.undo": "Zurück",
|
||||||
|
"cmd.dimension.finish": "Fertig",
|
||||||
|
"cmd.kote.label": "Höhenkote",
|
||||||
|
"cmd.kote.point": "Punkt:",
|
||||||
|
"cmd.kote.value": "Höhenwert ( Art ):",
|
||||||
|
"cmd.kote.kind.okFertig": "OK fertig",
|
||||||
|
"cmd.kote.kind.ukFertig": "UK fertig",
|
||||||
|
"cmd.kote.kind.okRoh": "OK roh",
|
||||||
|
"cmd.kote.kind.ukRoh": "UK roh",
|
||||||
|
"cmd.field.offset": "Abstand",
|
||||||
"cmd.text.label": "Text",
|
"cmd.text.label": "Text",
|
||||||
"cmd.text.point": "Ankerpunkt:",
|
"cmd.text.point": "Ankerpunkt:",
|
||||||
"cmd.textbox.label": "Textspalte",
|
"cmd.textbox.label": "Textspalte",
|
||||||
@@ -1170,6 +1211,10 @@ export const de = {
|
|||||||
"cmd.copy.base": "Basispunkt:",
|
"cmd.copy.base": "Basispunkt:",
|
||||||
"cmd.copy.target": "Zielpunkt (Enter beendet):",
|
"cmd.copy.target": "Zielpunkt (Enter beendet):",
|
||||||
"cmd.copy.empty": "Nichts gewählt (erst Objekte wählen).",
|
"cmd.copy.empty": "Nichts gewählt (erst Objekte wählen).",
|
||||||
|
"cmd.array.label": "Feld",
|
||||||
|
"cmd.array.base": "Basispunkt:",
|
||||||
|
"cmd.array.target": "Zielpunkt ( Anzahl ):",
|
||||||
|
"cmd.array.count": "Anzahl",
|
||||||
"cmd.offset.label": "Versatz",
|
"cmd.offset.label": "Versatz",
|
||||||
"cmd.offset.pick": "Kurve wählen:",
|
"cmd.offset.pick": "Kurve wählen:",
|
||||||
"cmd.offset.side": "Seite oder Distanz:",
|
"cmd.offset.side": "Seite oder Distanz:",
|
||||||
@@ -1211,6 +1256,7 @@ export const de = {
|
|||||||
"cmd.field.distance": "Distanz",
|
"cmd.field.distance": "Distanz",
|
||||||
"cmd.field.thickness": "Dicke",
|
"cmd.field.thickness": "Dicke",
|
||||||
"cmd.edit.point": "Griff ziehen (Tab: Länge/Winkel):",
|
"cmd.edit.point": "Griff ziehen (Tab: Länge/Winkel):",
|
||||||
|
"cmd.edit.edge": "Seite verschieben (Tab: Abstand):",
|
||||||
|
|
||||||
// ── Befehle: Import / Gelände ────────────────────────────────────────────
|
// ── Befehle: Import / Gelände ────────────────────────────────────────────
|
||||||
"cmd.import.label": "Importieren",
|
"cmd.import.label": "Importieren",
|
||||||
|
|||||||
@@ -176,6 +176,16 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"tool.circle.hint": "Circle: set center, then radius",
|
"tool.circle.hint": "Circle: set center, then radius",
|
||||||
"tool.arc": "Arc",
|
"tool.arc": "Arc",
|
||||||
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
|
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
|
||||||
|
"tool.ellipse": "Ellipse",
|
||||||
|
"tool.ellipse.hint": "Ellipse: two opposite corners of the bounding box",
|
||||||
|
"tool.spline": "Spline",
|
||||||
|
"tool.spline.hint": "Spline: click waypoints, Enter finishes (smooth curve through the points)",
|
||||||
|
"tool.bezier": "Bezier",
|
||||||
|
"tool.bezier.hint": "Bezier: anchor, handle 1, handle 2, end point — per segment; Enter finishes",
|
||||||
|
"tool.dimension": "Dimension",
|
||||||
|
"tool.dimension.hint": "Dimension: click points like a polyline (point 3+ snaps to a straight line), double-click/Enter finishes, then set the dimension line position (chains in one gesture)",
|
||||||
|
"tool.kote": "Elevation mark",
|
||||||
|
"tool.kote.hint": "Elevation mark: set a point, then type the height value (type toggle: top/bottom, finished/raw)",
|
||||||
"tool.text": "Text",
|
"tool.text": "Text",
|
||||||
"tool.text.hint": "Text: set anchor point, then type the text",
|
"tool.text.hint": "Text: set anchor point, then type the text",
|
||||||
"tool.textbox": "Text column",
|
"tool.textbox": "Text column",
|
||||||
@@ -198,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",
|
||||||
@@ -231,6 +243,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"snap.enabled": "Snap on",
|
"snap.enabled": "Snap on",
|
||||||
"snap.endpoint": "Endpoint",
|
"snap.endpoint": "Endpoint",
|
||||||
"snap.midpoint": "Midpoint",
|
"snap.midpoint": "Midpoint",
|
||||||
|
"snap.center": "Circle center/quadrants (0/90/180/270°)",
|
||||||
"snap.intersection": "Intersection",
|
"snap.intersection": "Intersection",
|
||||||
"snap.onEdge": "On edge (perp.)",
|
"snap.onEdge": "On edge (perp.)",
|
||||||
"snap.grid": "Grid",
|
"snap.grid": "Grid",
|
||||||
@@ -239,6 +252,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"snap.angleStep": "Angle step",
|
"snap.angleStep": "Angle step",
|
||||||
"snap.commonAngles": "Common angles",
|
"snap.commonAngles": "Common angles",
|
||||||
"snap.commonAngleTolerance": "Angle tolerance (°)",
|
"snap.commonAngleTolerance": "Angle tolerance (°)",
|
||||||
|
"snap.hoverDivideDelayMs": "Hover delay (ms)",
|
||||||
|
"snap.hoverDivideCount": "Division points (count)",
|
||||||
|
|
||||||
// Transform (move/mirror/rotate) + mode bar.
|
// Transform (move/mirror/rotate) + mode bar.
|
||||||
"transform.bar": "Transform",
|
"transform.bar": "Transform",
|
||||||
@@ -1128,6 +1143,32 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.arc.center": "Center:",
|
"cmd.arc.center": "Center:",
|
||||||
"cmd.arc.start": "Start point (radius + start angle):",
|
"cmd.arc.start": "Start point (radius + start angle):",
|
||||||
"cmd.arc.end": "End point (end angle):",
|
"cmd.arc.end": "End point (end angle):",
|
||||||
|
"cmd.ellipse.label": "Ellipse",
|
||||||
|
"cmd.ellipse.corner1": "First corner:",
|
||||||
|
"cmd.ellipse.corner2": "Opposite corner:",
|
||||||
|
"cmd.spline.label": "Spline",
|
||||||
|
"cmd.spline.start": "Start point:",
|
||||||
|
"cmd.spline.next": "Next point ( Close Undo ):",
|
||||||
|
"cmd.bezier.label": "Bezier",
|
||||||
|
"cmd.bezier.start": "Start point:",
|
||||||
|
"cmd.bezier.c1": "Handle 1:",
|
||||||
|
"cmd.bezier.c2": "Handle 2:",
|
||||||
|
"cmd.bezier.end": "End point ( Finish ):",
|
||||||
|
"cmd.bezier.finish": "Finish",
|
||||||
|
"cmd.dimension.label": "Dimension",
|
||||||
|
"cmd.dimension.start": "First point:",
|
||||||
|
"cmd.dimension.next": "Next point ( Finish Undo ):",
|
||||||
|
"cmd.dimension.offset": "Dimension line position:",
|
||||||
|
"cmd.dimension.undo": "Undo",
|
||||||
|
"cmd.dimension.finish": "Finish",
|
||||||
|
"cmd.kote.label": "Elevation mark",
|
||||||
|
"cmd.kote.point": "Point:",
|
||||||
|
"cmd.kote.value": "Height value ( Type ):",
|
||||||
|
"cmd.kote.kind.okFertig": "Top, finished",
|
||||||
|
"cmd.kote.kind.ukFertig": "Bottom, finished",
|
||||||
|
"cmd.kote.kind.okRoh": "Top, raw",
|
||||||
|
"cmd.kote.kind.ukRoh": "Bottom, raw",
|
||||||
|
"cmd.field.offset": "Offset",
|
||||||
"cmd.text.label": "Text",
|
"cmd.text.label": "Text",
|
||||||
"cmd.text.point": "Anchor point:",
|
"cmd.text.point": "Anchor point:",
|
||||||
"cmd.textbox.label": "Text column",
|
"cmd.textbox.label": "Text column",
|
||||||
@@ -1158,6 +1199,10 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.copy.base": "Base point:",
|
"cmd.copy.base": "Base point:",
|
||||||
"cmd.copy.target": "Target point (Enter to finish):",
|
"cmd.copy.target": "Target point (Enter to finish):",
|
||||||
"cmd.copy.empty": "Nothing selected (select objects first).",
|
"cmd.copy.empty": "Nothing selected (select objects first).",
|
||||||
|
"cmd.array.label": "Array",
|
||||||
|
"cmd.array.base": "Base point:",
|
||||||
|
"cmd.array.target": "Target point ( Count ):",
|
||||||
|
"cmd.array.count": "Count",
|
||||||
"cmd.offset.label": "Offset",
|
"cmd.offset.label": "Offset",
|
||||||
"cmd.offset.pick": "Select curve:",
|
"cmd.offset.pick": "Select curve:",
|
||||||
"cmd.offset.side": "Side or distance:",
|
"cmd.offset.side": "Side or distance:",
|
||||||
@@ -1199,6 +1244,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.field.distance": "Distance",
|
"cmd.field.distance": "Distance",
|
||||||
"cmd.field.thickness": "Thickness",
|
"cmd.field.thickness": "Thickness",
|
||||||
"cmd.edit.point": "Drag grip (Tab: length/angle):",
|
"cmd.edit.point": "Drag grip (Tab: length/angle):",
|
||||||
|
"cmd.edit.edge": "Move side (Tab: offset):",
|
||||||
|
|
||||||
// Commands: import / terrain.
|
// Commands: import / terrain.
|
||||||
"cmd.import.label": "Import",
|
"cmd.import.label": "Import",
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ export function translateProject(project: Project, dx: number, dy: number): Proj
|
|||||||
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
|
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
|
||||||
case "arc":
|
case "arc":
|
||||||
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
|
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
|
||||||
|
case "ellipse":
|
||||||
|
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
|
||||||
|
case "bezier":
|
||||||
|
return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } };
|
||||||
|
case "spline":
|
||||||
|
return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } };
|
||||||
|
case "dimension":
|
||||||
|
return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } };
|
||||||
|
case "kote":
|
||||||
|
return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } };
|
||||||
case "text":
|
case "text":
|
||||||
return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } };
|
return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } };
|
||||||
case "image":
|
case "image":
|
||||||
|
|||||||
+137
-3
@@ -833,10 +833,18 @@ export interface LayerCategory {
|
|||||||
* • "layer" — „Nach Ebene": die LayerCategory des Elements erzwingt den Wert
|
* • "layer" — „Nach Ebene": die LayerCategory des Elements erzwingt den Wert
|
||||||
* (`color`/`lw`/`hatch`).
|
* (`color`/`lw`/`hatch`).
|
||||||
* • "object" — „Nach Bauteil": erbt vom Bauteil (Component) bzw. dessen
|
* • "object" — „Nach Bauteil": erbt vom Bauteil (Component) bzw. dessen
|
||||||
* bisheriger Fallback-Kette. Das ist auch der Default, wenn das Source-Feld
|
* bisheriger Fallback-Kette (bei Wand/Decke u. a. die neutrale SIA-Poché-
|
||||||
* fehlt (`undefined`) — damit bleibt das heutige Verhalten unverändert.
|
* Konvention, s. `resolveBackground`). Das ist auch der DEFAULT bei Wand/
|
||||||
|
* Decke, wenn das Source-Feld fehlt (`undefined`) — bewusst NICHT „Nach
|
||||||
|
* Ebene": eine frisch erzeugte Wand soll die neutrale Bauteil-Konvention
|
||||||
|
* zeigen (weisser Grund, schwarzer Vordergrund), nicht die rohe
|
||||||
|
* Ebenenfarbe.
|
||||||
|
* Ein Drawing2D hat KEIN eigenes Bauteil — „Nach Bauteil" ist dort im
|
||||||
|
* Attribut-Panel gar nicht erst wählbar, ein fehlendes Source-Feld wird dort
|
||||||
|
* vom Aufrufer (`selectionInfo.ts`) auf „Nach Ebene" gemappt (die einzig
|
||||||
|
* sinnvolle Alternative zum expliziten Wert bei einem bauteillosen Element).
|
||||||
* Ein gesetzter expliziter Wert (z. B. `foreground`) gewinnt IMMER, unabhängig
|
* Ein gesetzter expliziter Wert (z. B. `foreground`) gewinnt IMMER, unabhängig
|
||||||
* von der Source (s. Resolve-Reihenfolge in `plan/generatePlan.ts`).
|
* von der Source (s. Resolve-Reihenfolge in `plan/generatePlan/shared.ts`).
|
||||||
*/
|
*/
|
||||||
export type AttributeSource = "layer" | "object";
|
export type AttributeSource = "layer" | "object";
|
||||||
|
|
||||||
@@ -1494,6 +1502,62 @@ export type Drawing2DGeom =
|
|||||||
| { shape: "rect"; min: Vec2; max: Vec2 }
|
| { shape: "rect"; min: Vec2; max: Vec2 }
|
||||||
| { shape: "circle"; center: Vec2; r: number }
|
| { shape: "circle"; center: Vec2; r: number }
|
||||||
| { shape: "arc"; center: Vec2; r: number; a0: number; a1: number }
|
| { shape: "arc"; center: Vec2; r: number; a0: number; a1: number }
|
||||||
|
/**
|
||||||
|
* Ellipse — achsparalleles Zentrum + zwei Halbachsen + Drehung (Radiant,
|
||||||
|
* CCW). Das Zeichen-Werkzeug erzeugt bewusst nur `rotation: 0`; das Feld
|
||||||
|
* existiert für zukünftige Dreh-Bearbeitung bzw. gedrehte Ellipsen aus
|
||||||
|
* anderen Quellen. Rendering/Snapping/Griffe tessellieren sie (wie ehemals
|
||||||
|
* `circle`), kein eigenes glattes SVG-Primitiv (s. `generatePlan/drawing2d.ts`).
|
||||||
|
*/
|
||||||
|
| { shape: "ellipse"; center: Vec2; rx: number; ry: number; rotation: number }
|
||||||
|
/**
|
||||||
|
* Kubische Bezier-Kette: `pts.length` ≡ 1 (mod 3), ≥ 4 — Punkte im Muster
|
||||||
|
* Anker·Griff·Griff·Anker·Griff·Griff·Anker·… (jedes weitere Segment fügt
|
||||||
|
* 3 Punkte an). Manuell gesetzte Kontrollpunkte (klassisches Bezier-
|
||||||
|
* Werkzeug), im Unterschied zu `spline` (Kurve DURCH Punkte, automatisch
|
||||||
|
* geglättet). Immer offen (kein `closed`-Feld).
|
||||||
|
*/
|
||||||
|
| { shape: "bezier"; pts: Vec2[] }
|
||||||
|
/**
|
||||||
|
* Glatte Kurve DURCH `pts` (uniformer Catmull-Rom), analog `polyline`, aber
|
||||||
|
* mit gerundetem statt geradem Verlauf zwischen den Wegpunkten. `closed`
|
||||||
|
* schliesst den Ring (letzter Wegpunkt → erster).
|
||||||
|
*/
|
||||||
|
| { shape: "spline"; pts: Vec2[]; closed: boolean }
|
||||||
|
/**
|
||||||
|
* Masslinie (lineare Bemassung, SIA 400 B.5.3): misst den Abstand `a`↔`b`.
|
||||||
|
* `offset` ist der VORZEICHENBEHAFTETE Normalabstand (Modell-Meter, links
|
||||||
|
* von a→b positiv), in dem die eigentliche Masslinie parallel zu a-b liegt
|
||||||
|
* — Masshilfslinien laufen von a/b bis zu dieser Linie. Die Masszahl wird
|
||||||
|
* beim Rendern aus `dist(a,b)` abgeleitet (nicht gespeichert), bleibt also
|
||||||
|
* beim Verschieben der Griffe automatisch korrekt. Rein 2D (kein Bezug auf
|
||||||
|
* andere Elemente — Assoziativität nur über die eigenen Endpunkte).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* `pts.length` ≥ 2, IMMER kollinear (Werkzeug erzwingt das beim Zeichnen/
|
||||||
|
* Ergänzen — eine Masskette ist eine Gerade, kein Zickzack-Pfad). Jedes
|
||||||
|
* aufeinanderfolgende Punktpaar ist EIN gemessenes Segment (Reihenbemassung,
|
||||||
|
* SIA 400 Figur 14), alle teilen denselben `offset`. EIN Element für die
|
||||||
|
* ganze Kette (nicht mehrere) — spätere Bearbeitung kann Punkte ergänzen
|
||||||
|
* (verlängern ODER dazwischen einfügen, s. `commands/cmds/dimension.ts`).
|
||||||
|
*/
|
||||||
|
| { shape: "dimension"; pts: Vec2[]; offset: number }
|
||||||
|
/**
|
||||||
|
* Höhenkote (SIA 400 B.5.4, Figur 18): Dreieck-Symbol + Höhenwert (Meter,
|
||||||
|
* ±0.00-Referenz frei wählbar, s. Norm) an einem Punkt. `kind` bestimmt
|
||||||
|
* Dreieck-Richtung (OK = Spitze nach unten, UK = Spitze nach oben) UND
|
||||||
|
* Füllung (fertig = Kontur/hohl, roh = voll gefüllt) — vier norm-genaue
|
||||||
|
* Varianten, die fünfte („OK fertig UND roh kombiniert") ist nicht gebaut.
|
||||||
|
* `at` ist die SPITZE des Dreiecks (der exakte referenzierte Punkt).
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
shape: "kote";
|
||||||
|
at: Vec2;
|
||||||
|
value: number;
|
||||||
|
kind: "okFertig" | "ukFertig" | "okRoh" | "ukRoh";
|
||||||
|
/** Drehung (Radiant); Werkzeug erzeugt bewusst nur 0, s. Ellipse-Muster. */
|
||||||
|
rotation: number;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
shape: "text";
|
shape: "text";
|
||||||
at: Vec2;
|
at: Vec2;
|
||||||
@@ -1616,6 +1680,13 @@ export interface EdgeGrip {
|
|||||||
* entlang der Außennormale wie bisher.
|
* entlang der Außennormale wie bisher.
|
||||||
*/
|
*/
|
||||||
free?: boolean;
|
free?: boolean;
|
||||||
|
/**
|
||||||
|
* Zu welchem Element diese Kante gehört (für Mehrfachauswahl — mehrere
|
||||||
|
* gewählte Elemente liefern je eigene Kanten-Griffe). Fehlt bei den
|
||||||
|
* exklusiven Einzel-Fällen (Decke/Raum), die weiterhin über die separat
|
||||||
|
* gehaltene `selectedCeilingId`/`selectedRoomId` aufgelöst werden.
|
||||||
|
*/
|
||||||
|
owner?: { drawingId?: string; wallId?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Extrudierter Körper (truck-Integration, docs/design/truck-plan.md) ─────
|
// ── Extrudierter Körper (truck-Integration, docs/design/truck-plan.md) ─────
|
||||||
@@ -1706,6 +1777,36 @@ export interface Column {
|
|||||||
* Kategorie-Farbe.
|
* Kategorie-Farbe.
|
||||||
*/
|
*/
|
||||||
color?: string;
|
color?: string;
|
||||||
|
/**
|
||||||
|
* Attribut-Override der Muster-/Schraffurfarbe (Vordergrund) DIESER Stütze.
|
||||||
|
* `undefined` = „Nach System" (erben → Component.foreground), analog `Wall.
|
||||||
|
* foreground`.
|
||||||
|
*/
|
||||||
|
foreground?: string;
|
||||||
|
/**
|
||||||
|
* Attribut-Override der Füllfarbe (Hintergrund/Poché) DIESER Stütze.
|
||||||
|
* `undefined` = „Nach System" (neutrale SIA-Poché HATCH_INK/HATCH_PAPER je
|
||||||
|
* nach Schraffur-Muster), analog `Wall.background`.
|
||||||
|
*/
|
||||||
|
background?: string;
|
||||||
|
/**
|
||||||
|
* Attribut-Override der Strichstärke (mm Papier) DIESER Stütze. `undefined`
|
||||||
|
* = „Nach System" (erben → `strokeWeightSource`), analog `Wall.strokeWeight`.
|
||||||
|
*/
|
||||||
|
strokeWeight?: number;
|
||||||
|
/**
|
||||||
|
* Attribut-Override der Schnitt-Schraffur (Hatch Manager) DIESER Stütze.
|
||||||
|
* `undefined` = „Nach System" (erben → `hatchSource`), analog `Wall.hatchId`.
|
||||||
|
*/
|
||||||
|
hatchId?: string;
|
||||||
|
/** Quelle des Vordergrunds, wenn kein explizites `foreground` gesetzt ist — analog `Wall.foregroundSource`. */
|
||||||
|
foregroundSource?: AttributeSource;
|
||||||
|
/** Quelle des Hintergrunds, analog {@link Column.foregroundSource}. */
|
||||||
|
backgroundSource?: AttributeSource;
|
||||||
|
/** Quelle der Strichstärke, analog {@link Column.foregroundSource}. */
|
||||||
|
strokeWeightSource?: AttributeSource;
|
||||||
|
/** Quelle der Schraffur, analog {@link Column.foregroundSource}. */
|
||||||
|
hatchSource?: AttributeSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Kontext-Geometrie (importiert / abgeleitet, NICHT semantisch) ───────────
|
// ── Kontext-Geometrie (importiert / abgeleitet, NICHT semantisch) ───────────
|
||||||
@@ -2131,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;
|
||||||
@@ -2217,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).
|
||||||
|
|||||||
@@ -50,10 +50,19 @@ import {
|
|||||||
* selbst gespeichert). */
|
* selbst gespeichert). */
|
||||||
type UiSource = "layer" | "object" | "custom";
|
type UiSource = "layer" | "object" | "custom";
|
||||||
|
|
||||||
/** Leitet den UI-Quellen-Zustand ab: ein gesetzter Wert gewinnt immer („eigener
|
/**
|
||||||
* Wert"), sonst das rohe Source-Feld (`undefined` ⇒ „Nach Bauteil", Default). */
|
* Leitet den UI-Quellen-Zustand ab: ein gesetzter Wert gewinnt immer („eigener
|
||||||
function uiSourceOf(hasValue: boolean, raw: AttributeSource | undefined): UiSource {
|
* Wert"), sonst das rohe Source-Feld — dessen DEFAULT unterscheidet sich nach
|
||||||
return hasValue ? "custom" : raw ?? "object";
|
* Elementart (s. AttributeSource in model/types.ts): Wand/Decke „Nach Bauteil"
|
||||||
|
* (neutrale SIA-Poché), Drawing2D „Nach Ebene" (kein eigenes Bauteil, „Nach
|
||||||
|
* Bauteil" wird dort im Dropdown gar nicht erst angeboten).
|
||||||
|
*/
|
||||||
|
function uiSourceOf(
|
||||||
|
hasValue: boolean,
|
||||||
|
raw: AttributeSource | undefined,
|
||||||
|
isDrawing: boolean,
|
||||||
|
): UiSource {
|
||||||
|
return hasValue ? "custom" : raw ?? (isDrawing ? "layer" : "object");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttributesPanel() {
|
export function AttributesPanel() {
|
||||||
@@ -112,32 +121,47 @@ export function AttributesPanel() {
|
|||||||
// sind IMMER ein eigener Wert (kein 3-Quellen-Dropdown, keine Füllung/
|
// sind IMMER ein eigener Wert (kein 3-Quellen-Dropdown, keine Füllung/
|
||||||
// Schraffur/Masse — s. Ausblendungen weiter unten).
|
// Schraffur/Masse — s. Ausblendungen weiter unten).
|
||||||
const isAnnotation = sel.kind === "layoutAnnotation";
|
const isAnnotation = sel.kind === "layoutAnnotation";
|
||||||
// Strichstärke: Wand/Decke/Drawing2D tragen einen eigenen Override
|
// Strichstärke: Wand/Decke/Stütze/Drawing2D tragen einen eigenen Override
|
||||||
// (`strokeWeight`/`weightMm`) + Quellen-Dropdown; andere Elementarten erben
|
// (`strokeWeight`/`weightMm`) + Quellen-Dropdown; andere Elementarten erben
|
||||||
// stets aus der Kategorie (kein Setter im Kontrakt → nicht editierbar). Bei
|
// stets aus der Kategorie (kein Setter im Kontrakt → nicht editierbar). Bei
|
||||||
// Annotationen nur Linie/Rechteck (Text/Bild kennen keine Strichstärke).
|
// Annotationen nur Linie/Rechteck (Text/Bild kennen keine Strichstärke).
|
||||||
const weightEditable =
|
const weightEditable =
|
||||||
sel.kind === "wall" ||
|
sel.kind === "wall" ||
|
||||||
sel.kind === "ceiling" ||
|
sel.kind === "ceiling" ||
|
||||||
|
sel.kind === "column" ||
|
||||||
isDrawing ||
|
isDrawing ||
|
||||||
(isAnnotation && (sel.annotKind === "line" || sel.annotKind === "rect"));
|
(isAnnotation && (sel.annotKind === "line" || sel.annotKind === "rect"));
|
||||||
// Schraffur: Wand/Decke (Schnitt-/Ansichts-Schraffur) oder geschlossene 2D-Form.
|
// Schraffur: Wand/Decke/Stütze (Schnitt-/Ansichts-Schraffur) oder
|
||||||
|
// geschlossene 2D-Form.
|
||||||
const fillEditable =
|
const fillEditable =
|
||||||
sel.kind === "wall" || sel.kind === "ceiling" || (isDrawing && sel.closed === true);
|
sel.kind === "wall" ||
|
||||||
// Vordergrund/Hintergrund (Muster-/Füllfarbe) tragen Wand, Decke und
|
sel.kind === "ceiling" ||
|
||||||
|
sel.kind === "column" ||
|
||||||
|
(isDrawing && sel.closed === true);
|
||||||
|
// Vordergrund/Hintergrund (Muster-/Füllfarbe) tragen Wand, Decke, Stütze und
|
||||||
// geschlossene 2D-Formen als Override; `undefined` = „Nach System".
|
// geschlossene 2D-Formen als Override; `undefined` = „Nach System".
|
||||||
const pocheEditable =
|
const pocheEditable =
|
||||||
sel.kind === "wall" ||
|
sel.kind === "wall" ||
|
||||||
sel.kind === "ceiling" ||
|
sel.kind === "ceiling" ||
|
||||||
|
sel.kind === "column" ||
|
||||||
(isDrawing && sel.closed === true);
|
(isDrawing && sel.closed === true);
|
||||||
|
|
||||||
// Der 3-Optionen-Quellen-Dropdown, gemeinsam für alle vier Felder. Deaktiviert,
|
// Ein Drawing2D hat KEIN eigenes Bauteil (Component) — „Nach Bauteil" wäre
|
||||||
// wenn das Feld für diese Elementart gar nicht gilt (z. B. Strichstärke bei
|
// dort bedeutungslos (resolveForeground & Co. fallen dann auf gar keinen
|
||||||
// einer Treppe).
|
// Wert zurück, s. selectionInfo.ts `noComponent`). Wand/Decke/Stütze haben
|
||||||
|
// alle ein echtes Bauteil (Schicht-Component bzw. `Column.componentId`) und
|
||||||
|
// bieten die Option daher an.
|
||||||
|
const allowObjectSource = !isDrawing;
|
||||||
|
|
||||||
|
// Der Quellen-Dropdown (Nach Ebene/Nach Bauteil/eigener Wert), gemeinsam für
|
||||||
|
// alle vier Felder. Deaktiviert, wenn das Feld für diese Elementart gar
|
||||||
|
// nicht gilt (z. B. Strichstärke bei einer Treppe); „Nach Bauteil" fehlt bei
|
||||||
|
// Drawing2D (s. o.).
|
||||||
const sourceSelect = (
|
const sourceSelect = (
|
||||||
editable: boolean,
|
editable: boolean,
|
||||||
ui: UiSource,
|
ui: UiSource,
|
||||||
onChange: (next: UiSource) => void,
|
onChange: (next: UiSource) => void,
|
||||||
|
allowObject: boolean,
|
||||||
) => (
|
) => (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={ui}
|
value={ui}
|
||||||
@@ -145,54 +169,71 @@ export function AttributesPanel() {
|
|||||||
onChange={(v) => onChange(v as UiSource)}
|
onChange={(v) => onChange(v as UiSource)}
|
||||||
options={[
|
options={[
|
||||||
{ value: "layer", label: t("attr.source.layer") },
|
{ value: "layer", label: t("attr.source.layer") },
|
||||||
{ value: "object", label: t("attr.source.object") },
|
...(allowObject ? [{ value: "object", label: t("attr.source.object") }] : []),
|
||||||
{ value: "custom", label: t("attr.source.custom") },
|
{ value: "custom", label: t("attr.source.custom") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Farb-Swatch-Eingabe für „eigener Wert" (Vordergrund/Hintergrund). Erscheint
|
// Farb-Swatch — IMMER sichtbar (Nutzer-Wunsch: den aktuell EFFEKTIVEN Wert
|
||||||
// nur, wenn die Quelle „custom" ist — sonst zeigt allein der Dropdown den
|
// (welche Farbe auch immer Ebene/Bauteil/Override gerade liefert) jederzeit
|
||||||
// Zustand.
|
// sehen, nicht nur bei „eigener Wert"). Ändert man ihn direkt, wird
|
||||||
const colorValue = (value: string | undefined, onSet: (color: string) => void) => (
|
// automatisch „eigener Wert": der Setter schreibt den Override, wodurch
|
||||||
<ColorHexField value={value ?? "#808080"} onChange={onSet} />
|
// `uiSourceOf` beim nächsten Render „custom" ableitet (kein separater
|
||||||
);
|
// Umschalt-Schritt nötig).
|
||||||
|
const colorValue = (
|
||||||
|
value: string | undefined,
|
||||||
|
onSet: (color: string) => void,
|
||||||
|
editable: boolean,
|
||||||
|
) => <ColorHexField value={value ?? "#808080"} onChange={onSet} disabled={!editable} />;
|
||||||
|
|
||||||
// Eine Attribut-Zeile: Label + Quellen-Dropdown + (nur bei „eigener Wert")
|
// Eine Attribut-Zeile: Label + IMMER sichtbares Eingabe-Element (zeigt den
|
||||||
// das passende Eingabe-Element.
|
// effektiven Wert) + Quellen-Dropdown.
|
||||||
const sourceRow = (
|
const sourceRow = (
|
||||||
labelKey: string,
|
labelKey: string,
|
||||||
editable: boolean,
|
editable: boolean,
|
||||||
ui: UiSource,
|
ui: UiSource,
|
||||||
onSource: (next: UiSource) => void,
|
onSource: (next: UiSource) => void,
|
||||||
valueEditor: ReactNode,
|
valueEditor: ReactNode,
|
||||||
|
allowObject: boolean = allowObjectSource,
|
||||||
) => (
|
) => (
|
||||||
<>
|
<>
|
||||||
<span className="attr-key">{t(labelKey)}</span>
|
<span className="attr-key">{t(labelKey)}</span>
|
||||||
<span className="attr-val">
|
<span className="attr-val">
|
||||||
{ui === "custom" && valueEditor}
|
{valueEditor}
|
||||||
{sourceSelect(editable, ui, onSource)}
|
{sourceSelect(editable, ui, onSource, allowObject)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Vordergrund/Hintergrund: „eigener Wert" ⇒ auf Quelle "object" (Nach
|
// Vordergrund/Hintergrund: „eigener Wert" ⇒ auf den elementart-abhängigen
|
||||||
// Bauteil, Default) zurückfallen; „Nach Ebene"/„Nach Bauteil" schreiben die
|
// Default zurückfallen (Wand/Decke „Nach Bauteil", Drawing2D „Nach Ebene",
|
||||||
// Quelle direkt (der Host löscht dabei den expliziten Wert).
|
// s. uiSourceOf); „Nach Ebene"/„Nach Bauteil" schreiben die Quelle direkt
|
||||||
const fgUi = uiSourceOf(sel.foreground !== undefined, sel.foregroundSource);
|
// (der Host löscht dabei den expliziten Wert).
|
||||||
const bgUi = uiSourceOf(sel.background !== undefined, sel.backgroundSource);
|
const fgUi = uiSourceOf(sel.foreground !== undefined, sel.foregroundSource, isDrawing);
|
||||||
const weightUi = uiSourceOf(sel.strokeWeightOverride !== undefined, sel.strokeWeightSource);
|
const bgUi = uiSourceOf(sel.background !== undefined, sel.backgroundSource, isDrawing);
|
||||||
|
const weightUi = uiSourceOf(
|
||||||
|
sel.strokeWeightOverride !== undefined,
|
||||||
|
sel.strokeWeightSource,
|
||||||
|
isDrawing,
|
||||||
|
);
|
||||||
const hatchUi = uiSourceOf(
|
const hatchUi = uiSourceOf(
|
||||||
sel.fillHatchId !== undefined && sel.fillHatchId !== null,
|
sel.fillHatchId !== undefined && sel.fillHatchId !== null,
|
||||||
sel.hatchSource,
|
sel.hatchSource,
|
||||||
|
isDrawing,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Wechsel auf „eigener Wert" per Dropdown (nicht durchs Editieren des Felds
|
||||||
|
// selbst, s. colorValue-Kommentar oben) sät den Override mit dem gerade
|
||||||
|
// ANGEZEIGTEN effektiven Wert — sonst würde die Farbe beim Umschalten
|
||||||
|
// unerwartet springen (z. B. auf sel.color statt der tatsächlich sichtbaren
|
||||||
|
// Ebenen-/Bauteilfarbe).
|
||||||
const onForegroundSourceChange = (next: UiSource) => {
|
const onForegroundSourceChange = (next: UiSource) => {
|
||||||
if (next === "custom") host.onSetSelectionForeground(sel.foreground ?? sel.color);
|
if (next === "custom") host.onSetSelectionForeground(sel.effectiveForeground ?? sel.color);
|
||||||
else host.onSetSelectionForegroundSource(next);
|
else host.onSetSelectionForegroundSource(next);
|
||||||
};
|
};
|
||||||
const onBackgroundSourceChange = (next: UiSource) => {
|
const onBackgroundSourceChange = (next: UiSource) => {
|
||||||
if (next === "custom") host.onSetSelectionBackground(sel.background ?? sel.color);
|
if (next === "custom") host.onSetSelectionBackground(sel.effectiveBackground ?? sel.color);
|
||||||
else host.onSetSelectionBackgroundSource(next);
|
else host.onSetSelectionBackgroundSource(next);
|
||||||
};
|
};
|
||||||
const onWeightSourceChange = (next: UiSource) => {
|
const onWeightSourceChange = (next: UiSource) => {
|
||||||
@@ -200,8 +241,11 @@ export function AttributesPanel() {
|
|||||||
else host.onSetSelectionStrokeWeightSource(next);
|
else host.onSetSelectionStrokeWeightSource(next);
|
||||||
};
|
};
|
||||||
const onHatchSourceChange = (next: UiSource) => {
|
const onHatchSourceChange = (next: UiSource) => {
|
||||||
if (next === "custom") host.onSetSelectionFill(project.hatches[0]?.id ?? null);
|
if (next === "custom") {
|
||||||
else host.onSetSelectionHatchSource(next);
|
host.onSetSelectionFill(sel.effectiveHatchId ?? project.hatches[0]?.id ?? null);
|
||||||
|
} else {
|
||||||
|
host.onSetSelectionHatchSource(next);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Effektiver Linienstil rein informativ (kein Setter im Kontrakt → kein Fake).
|
// Effektiver Linienstil rein informativ (kein Setter im Kontrakt → kein Fake).
|
||||||
@@ -272,6 +316,7 @@ export function AttributesPanel() {
|
|||||||
type="number"
|
type="number"
|
||||||
step={0.01}
|
step={0.01}
|
||||||
min={0}
|
min={0}
|
||||||
|
disabled={!weightEditable}
|
||||||
list="attr-pen-weights"
|
list="attr-pen-weights"
|
||||||
value={sel.weightMm}
|
value={sel.weightMm}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -352,7 +397,7 @@ export function AttributesPanel() {
|
|||||||
pocheEditable,
|
pocheEditable,
|
||||||
fgUi,
|
fgUi,
|
||||||
onForegroundSourceChange,
|
onForegroundSourceChange,
|
||||||
colorValue(sel.foreground, host.onSetSelectionForeground),
|
colorValue(sel.effectiveForeground, host.onSetSelectionForeground, pocheEditable),
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sourceRow(
|
{sourceRow(
|
||||||
@@ -360,7 +405,7 @@ export function AttributesPanel() {
|
|||||||
pocheEditable,
|
pocheEditable,
|
||||||
bgUi,
|
bgUi,
|
||||||
onBackgroundSourceChange,
|
onBackgroundSourceChange,
|
||||||
colorValue(sel.background, host.onSetSelectionBackground),
|
colorValue(sel.effectiveBackground, host.onSetSelectionBackground, pocheEditable),
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sourceRow(
|
{sourceRow(
|
||||||
@@ -369,7 +414,8 @@ export function AttributesPanel() {
|
|||||||
hatchUi,
|
hatchUi,
|
||||||
onHatchSourceChange,
|
onHatchSourceChange,
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={sel.fillHatchId ?? ""}
|
value={sel.effectiveHatchId ?? ""}
|
||||||
|
disabled={!fillEditable}
|
||||||
onChange={(v) => host.onSetSelectionFill(v || null)}
|
onChange={(v) => host.onSetSelectionFill(v || null)}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: t("attr.none") },
|
{ value: "", label: t("attr.none") },
|
||||||
|
|||||||
@@ -138,6 +138,56 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
|||||||
<circle cx="13.5" cy="11" r="0.9" fill="currentColor" stroke="none" />
|
<circle cx="13.5" cy="11" r="0.9" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case "ellipse":
|
||||||
|
// Ellipse-Umriss (geneigt, unterscheidbar vom Kreis).
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<ellipse cx="8" cy="8" rx="6" ry="4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "spline":
|
||||||
|
// Glatte S-Kurve durch drei Wegpunkte.
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M 2.5 12.5 C 2.5 6 6 10 8 8 C 10 6 10.5 3.5 13.5 3.5" fill="none" />
|
||||||
|
<circle cx="2.5" cy="12.5" r="0.9" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="8" cy="8" r="0.9" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="13.5" cy="3.5" r="0.9" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "bezier":
|
||||||
|
// Kubische Bezier-Kurve mit sichtbaren Kontrollpunkt-Griffen.
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M 2.5 12.5 C 5 4 8 13 13.5 3.5" fill="none" />
|
||||||
|
<line x1="2.5" y1="12.5" x2="5" y2="4" strokeDasharray="1.5 1.2" />
|
||||||
|
<line x1="13.5" y1="3.5" x2="8" y2="13" strokeDasharray="1.5 1.2" />
|
||||||
|
<circle cx="2.5" cy="12.5" r="0.9" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="13.5" cy="3.5" r="0.9" fill="currentColor" stroke="none" />
|
||||||
|
<rect x="4.2" y="2.7" width="1.6" height="1.6" fill="currentColor" stroke="none" />
|
||||||
|
<rect x="7.2" y="12.2" width="1.6" height="1.6" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "dimension":
|
||||||
|
// Masslinie mit Massstrichen (Diagonalstriche) an beiden Enden.
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<line x1="2.5" y1="4" x2="2.5" y2="11" strokeWidth="0.9" />
|
||||||
|
<line x1="13.5" y1="4" x2="13.5" y2="11" strokeWidth="0.9" />
|
||||||
|
<line x1="2.5" y1="8" x2="13.5" y2="8" />
|
||||||
|
<line x1="1.7" y1="6.6" x2="3.3" y2="9.4" strokeWidth="1.6" />
|
||||||
|
<line x1="12.7" y1="6.6" x2="14.3" y2="9.4" strokeWidth="1.6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "kote":
|
||||||
|
// Höhenkote: Dreieck (Spitze unten, OK) + Zahl.
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<polygon points="8,10 5,5 11,5" fill="none" />
|
||||||
|
<line x1="10.5" y1="6.5" x2="14" y2="6.5" />
|
||||||
|
<line x1="10.5" y1="9" x2="14" y2="9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case "text":
|
case "text":
|
||||||
// Serifen-„A" als Text-Symbol.
|
// Serifen-„A" als Text-Symbol.
|
||||||
return (
|
return (
|
||||||
@@ -174,10 +224,24 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
|||||||
// Werkzeuge/Befehle in drei Rubriken sortiert (Nutzerwunsch: „sortiere nach 2D
|
// Werkzeuge/Befehle in drei Rubriken sortiert (Nutzerwunsch: „sortiere nach 2D
|
||||||
// und 3D und Bearbeiten"): reine Panel-Gruppierung (2D-Zeichnen · 3D/BIM-
|
// und 3D und Bearbeiten"): reine Panel-Gruppierung (2D-Zeichnen · 3D/BIM-
|
||||||
// Bauteile · Bearbeiten-Aktionen).
|
// Bauteile · Bearbeiten-Aktionen).
|
||||||
const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "text", "textbox", "image"];
|
const TOOLS_2D: ToolId[] = [
|
||||||
|
"line",
|
||||||
|
"polyline",
|
||||||
|
"rect",
|
||||||
|
"circle",
|
||||||
|
"arc",
|
||||||
|
"ellipse",
|
||||||
|
"spline",
|
||||||
|
"bezier",
|
||||||
|
"dimension",
|
||||||
|
"kote",
|
||||||
|
"text",
|
||||||
|
"textbox",
|
||||||
|
"image",
|
||||||
|
];
|
||||||
const TOOLS_3D: ToolId[] = ["wall", "ceiling", "window", "door", "stair", "room"];
|
const TOOLS_3D: ToolId[] = ["wall", "ceiling", "window", "door", "stair", "room"];
|
||||||
const COMMANDS_3D = ["column", "ceilingopening", "roof", "extrude", "sectionline", "viewline"];
|
const COMMANDS_3D = ["column", "ceilingopening", "roof", "extrude", "sectionline", "viewline"];
|
||||||
const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "multiline", "trim", "extend", "fillet", "chamfer", "join", "measure"];
|
const COMMANDS_EDIT = ["move", "copy", "array", "rotate", "mirror", "offset", "multiline", "trim", "extend", "fillet", "chamfer", "join", "measure"];
|
||||||
|
|
||||||
type ToolsView = "list" | "icons";
|
type ToolsView = "list" | "icons";
|
||||||
const VIEW_STORAGE_KEY = "dossier.toolsPanel.view";
|
const VIEW_STORAGE_KEY = "dossier.toolsPanel.view";
|
||||||
|
|||||||
+420
-24
@@ -37,6 +37,9 @@ import {
|
|||||||
pointSegDistPx,
|
pointSegDistPx,
|
||||||
marqueeHit,
|
marqueeHit,
|
||||||
marqueeHitDrawings,
|
marqueeHitDrawings,
|
||||||
|
marqueeHitCeilings,
|
||||||
|
marqueeHitRooms,
|
||||||
|
marqueeHitStairs,
|
||||||
} from "./planView/geometry";
|
} from "./planView/geometry";
|
||||||
import {
|
import {
|
||||||
polyUsesPattern,
|
polyUsesPattern,
|
||||||
@@ -111,6 +114,16 @@ export interface PlanViewHandle {
|
|||||||
* Leere Punktmenge → No-op (kein Fit, kein Crash).
|
* Leere Punktmenge → No-op (kein Fit, kein Crash).
|
||||||
*/
|
*/
|
||||||
fitBounds: (pts: Vec2[]) => void;
|
fitBounds: (pts: Vec2[]) => void;
|
||||||
|
/**
|
||||||
|
* Einen bewaffneten Punkt-Griff (s. gripDrag) an seiner AKTUELLEN Position
|
||||||
|
* abschließen — dieselbe Aktion wie ein bestätigender Klick/Enter auf der
|
||||||
|
* Zeichenfläche, aber von der Befehlszeile aus angestoßen (Enter dort MIT
|
||||||
|
* leerem Eingabetext: kein neuer Wert zu locken, nur „fertig, so lassen").
|
||||||
|
* No-op ohne bewaffneten Griff.
|
||||||
|
*/
|
||||||
|
confirmGripEdit: () => void;
|
||||||
|
/** Dasselbe für einen bewaffneten Kanten-Griff (s. edgeDrag). */
|
||||||
|
confirmEdgeEdit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
|
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
|
||||||
@@ -198,6 +211,10 @@ export interface MarqueeSelection {
|
|||||||
wallIds: string[];
|
wallIds: string[];
|
||||||
/** Getroffene 2D-Zeichenelemente (gleiche window/crossing-Konvention). */
|
/** Getroffene 2D-Zeichenelemente (gleiche window/crossing-Konvention). */
|
||||||
drawingIds: string[];
|
drawingIds: string[];
|
||||||
|
/** Getroffene Decken/Räume/Treppen (gleiche window/crossing-Konvention). */
|
||||||
|
ceilingIds: string[];
|
||||||
|
roomIds: string[];
|
||||||
|
stairIds: string[];
|
||||||
/**
|
/**
|
||||||
* Zieh-Richtung: `false` = links→rechts (positiv Δx) → nur VOLLSTÄNDIG
|
* Zieh-Richtung: `false` = links→rechts (positiv Δx) → nur VOLLSTÄNDIG
|
||||||
* eingeschlossene Wände; `true` = rechts→links (negativ Δx) → auch berührte/
|
* eingeschlossene Wände; `true` = rechts→links (negativ Δx) → auch berührte/
|
||||||
@@ -206,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;
|
||||||
/**
|
/**
|
||||||
@@ -293,6 +327,13 @@ export interface PlanViewProps {
|
|||||||
* markiert den Querschnitt-Umriss JEDER dieser Stützen mit einer Akzentkontur.
|
* markiert den Querschnitt-Umriss JEDER dieser Stützen mit einer Akzentkontur.
|
||||||
*/
|
*/
|
||||||
selectedColumnIds?: string[];
|
selectedColumnIds?: string[];
|
||||||
|
/**
|
||||||
|
* IDs der gewählten GRUPPEN (Vectorworks-artig). Nicht-leer → die Ansicht
|
||||||
|
* zeigt statt der einzelnen Mitglieds-Umrisse nur eine Bounding-Box mit vier
|
||||||
|
* Eckpunkten (s. `groupBBox`); die per-Kind-`selected…Ids`-Felder tragen
|
||||||
|
* weiterhin die volle Mitgliedschaft (für Verschieben/Griffe/Löschen).
|
||||||
|
*/
|
||||||
|
selectedGroupIds?: string[];
|
||||||
/**
|
/**
|
||||||
* Der Stempel-Griff des (einzeln) gewählten Raums: sein Anker (Meter) + ID.
|
* Der Stempel-Griff des (einzeln) gewählten Raums: sein Anker (Meter) + ID.
|
||||||
* PlanView zeichnet einen ziehbaren Griff und meldet Bewegung/Doppelklick.
|
* PlanView zeichnet einen ziehbaren Griff und meldet Bewegung/Doppelklick.
|
||||||
@@ -305,6 +346,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
|
||||||
@@ -372,6 +424,12 @@ export interface PlanViewProps {
|
|||||||
* Auswahl-Werkzeug sichtbar/aktiv.
|
* Auswahl-Werkzeug sichtbar/aktiv.
|
||||||
*/
|
*/
|
||||||
grips?: Vec2[];
|
grips?: Vec2[];
|
||||||
|
/**
|
||||||
|
* Bezier-Hilfslinien (Anker → Kontrollpunkt-Griff) des selektierten
|
||||||
|
* Elements — gestrichelt gerendert, damit sichtbar bleibt, welcher Griff
|
||||||
|
* welchen Kurvenabschnitt steuert (s. `useGripEditing.ts`).
|
||||||
|
*/
|
||||||
|
gripHandleLines?: { a: Vec2; b: Vec2 }[];
|
||||||
/**
|
/**
|
||||||
* Kanten-/Seiten-Griffe (Edge Grips) des selektierten Elements: je Seite ein
|
* Kanten-/Seiten-Griffe (Edge Grips) des selektierten Elements: je Seite ein
|
||||||
* dreieckiger Anfasser am Kanten-Mittelpunkt, dessen Spitze nach AUSSEN zeigt.
|
* dreieckiger Anfasser am Kanten-Mittelpunkt, dessen Spitze nach AUSSEN zeigt.
|
||||||
@@ -400,6 +458,19 @@ export interface PlanViewProps {
|
|||||||
* Einstellungs-Fenster (State in ViewSlice); Default = Akzent „Sora".
|
* Einstellungs-Fenster (State in ViewSlice); Default = Akzent „Sora".
|
||||||
*/
|
*/
|
||||||
snapColor?: string;
|
snapColor?: string;
|
||||||
|
/**
|
||||||
|
* Ruht der Cursor (kein Werkzeug/Drag aktiv) länger als diese Zeit (ms) auf
|
||||||
|
* einer 2D-Linie, erscheinen ihre Teilpunkte (s. `hoverDivideCount`) kurz
|
||||||
|
* mit Akzentfarbe — reine Sichtbarkeits-Hilfe, kein Snap-Ziel. Einstellbar
|
||||||
|
* im Footer (`SnapSettings.hoverDivideDelayMs`); Default 300 ms.
|
||||||
|
*/
|
||||||
|
hoverDivideDelayMs?: number;
|
||||||
|
/**
|
||||||
|
* In wie viele gleiche Teile die gehoverte Linie dabei angezeigt wird
|
||||||
|
* (2 = nur der Mittelpunkt). Einstellbar im Footer
|
||||||
|
* (`SnapSettings.hoverDivideCount`); Default 2.
|
||||||
|
*/
|
||||||
|
hoverDivideCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Treiber für das Ziehen von Editier-Griffen (von App bereitgestellt). */
|
/** Treiber für das Ziehen von Editier-Griffen (von App bereitgestellt). */
|
||||||
@@ -424,6 +495,20 @@ export interface GripHandlers {
|
|||||||
*/
|
*/
|
||||||
onEdgeMove(edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods): void;
|
onEdgeMove(edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods): void;
|
||||||
onGripEnd(): void;
|
onGripEnd(): void;
|
||||||
|
/**
|
||||||
|
* Bewaffneten Punkt-Griff verwerfen (Escape): setzt den Punkt exakt auf
|
||||||
|
* `origin` zurück (ohne Snap/Ortho-Neuauswertung) und räumt danach wie
|
||||||
|
* {@link onGripEnd} auf.
|
||||||
|
*/
|
||||||
|
onGripCancel(index: number, origin: Vec2): void;
|
||||||
|
/** Bewaffneten Kanten-Griff an seiner aktuellen Position abschließen. */
|
||||||
|
onEdgeEnd(): void;
|
||||||
|
/**
|
||||||
|
* Bewaffneten Kanten-Griff verwerfen (Escape): setzt ihn exakt auf `anchor`
|
||||||
|
* (den originalen Kanten-Mittelpunkt) zurück und räumt danach wie
|
||||||
|
* {@link onEdgeEnd} auf.
|
||||||
|
*/
|
||||||
|
onEdgeCancel(edge: EdgeGrip, anchor: Vec2): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||||
@@ -447,10 +532,12 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
selectedSectionLineIds,
|
selectedSectionLineIds,
|
||||||
selectedExtrudedSolidIds,
|
selectedExtrudedSolidIds,
|
||||||
selectedColumnIds,
|
selectedColumnIds,
|
||||||
|
selectedGroupIds,
|
||||||
roomStamp,
|
roomStamp,
|
||||||
onStampMove,
|
onStampMove,
|
||||||
onStampEdit,
|
onStampEdit,
|
||||||
onEditDrawingText,
|
onEditDrawingText,
|
||||||
|
onEnterGroup,
|
||||||
editTextId,
|
editTextId,
|
||||||
editTextMinHeightM,
|
editTextMinHeightM,
|
||||||
onCommitTextEdit,
|
onCommitTextEdit,
|
||||||
@@ -462,6 +549,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
showRulers = true,
|
showRulers = true,
|
||||||
hudFields,
|
hudFields,
|
||||||
grips,
|
grips,
|
||||||
|
gripHandleLines,
|
||||||
edgeGrips,
|
edgeGrips,
|
||||||
hairline,
|
hairline,
|
||||||
selectedDrawingId,
|
selectedDrawingId,
|
||||||
@@ -469,6 +557,8 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
gripHandlers,
|
gripHandlers,
|
||||||
marqueeColor = DEFAULT_MARQUEE_COLOR,
|
marqueeColor = DEFAULT_MARQUEE_COLOR,
|
||||||
snapColor = DEFAULT_SNAP_COLOR,
|
snapColor = DEFAULT_SNAP_COLOR,
|
||||||
|
hoverDivideDelayMs = 300,
|
||||||
|
hoverDivideCount = 2,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
@@ -701,12 +791,34 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [resetKey]);
|
}, [resetKey]);
|
||||||
|
|
||||||
|
// Frischer Spiegel der gripHandlers-Prop für das (unten stabil mit leeren
|
||||||
|
// Deps memoisierte) Imperativ-Handle — direkt referenzieren würde die Prop
|
||||||
|
// vom ERSTEN Render einfrieren (stale closure, s. Kommentar dort).
|
||||||
|
const gripHandlersRef = useRef(gripHandlers);
|
||||||
|
gripHandlersRef.current = gripHandlers;
|
||||||
|
|
||||||
// Imperatives Steuerhandle für die Oberleiste (Einpassen / Auswahl / Massstab).
|
// Imperatives Steuerhandle für die Oberleiste (Einpassen / Auswahl / Massstab).
|
||||||
// Stabil (leere Deps); liest den frischen Ausschnitt + die Auswahl aus Refs.
|
// Stabil (leere Deps); liest den frischen Ausschnitt + die Auswahl aus Refs.
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
fit: () => setView(defaultViewRef.current),
|
fit: () => setView(defaultViewRef.current),
|
||||||
|
confirmGripEdit: () => {
|
||||||
|
// Von der Befehlszeile aus angestoßen (Enter mit leerem Eingabetext,
|
||||||
|
// s. App.tsx) — dieselbe Aktion wie ein bestätigender Klick auf der
|
||||||
|
// Zeichenfläche (siehe onPointerDown-Kopf unten), nur eben nicht über
|
||||||
|
// ein Pointer-Event.
|
||||||
|
if (gripDrag.current) {
|
||||||
|
gripDrag.current = null;
|
||||||
|
gripHandlersRef.current?.onGripEnd();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmEdgeEdit: () => {
|
||||||
|
if (edgeDrag.current) {
|
||||||
|
edgeDrag.current = null;
|
||||||
|
gripHandlersRef.current?.onEdgeEnd();
|
||||||
|
}
|
||||||
|
},
|
||||||
fitSelection: () => {
|
fitSelection: () => {
|
||||||
const sel = selectedRef.current;
|
const sel = selectedRef.current;
|
||||||
const prim = sel !== null ? planRef.current.primitives[sel] : undefined;
|
const prim = sel !== null ? planRef.current.primitives[sel] : undefined;
|
||||||
@@ -767,11 +879,66 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
crossing: boolean;
|
crossing: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// Hover-Teilpunkte (Nutzer-Wunsch): ruht der Cursor im Auswahl-Ruhezustand
|
||||||
|
// (kein Werkzeug/Drag/Marquee — s. Aufruf in onPointerMove) länger als
|
||||||
|
// `hoverDivideDelayMs` auf einem 2D-Linien-Segment, werden dessen gleiche
|
||||||
|
// Teilpunkte (Anzahl `hoverDivideCount`, 2 = nur Mitte) kurz mit
|
||||||
|
// Akzentfarbe eingeblendet. Reine Sichtbarkeits-Hilfe, kein Snap-Ziel.
|
||||||
|
const [hoverDivideSeg, setHoverDivideSeg] = useState<{ a: Vec2; b: Vec2 } | null>(null);
|
||||||
|
// Zuletzt erkanntes Segment (Serialisiert als Schlüssel) — erkennt einen
|
||||||
|
// Wechsel, ohne bei jedem Move-Event neu zu timern.
|
||||||
|
const hoverDivideKeyRef = useRef<string | null>(null);
|
||||||
|
const hoverDivideTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Nächstliegendes Linien-Segment unterm Cursor ermitteln und die
|
||||||
|
// Hover-Teilpunkte-Anzeige entsprechend (neu) timern/verwerfen.
|
||||||
|
const updateHoverDivide = (clientX: number, clientY: number) => {
|
||||||
|
const seg = nearestLineSegment(clientX, clientY);
|
||||||
|
const key = seg ? `${seg.a.x},${seg.a.y}|${seg.b.x},${seg.b.y}` : null;
|
||||||
|
if (key === hoverDivideKeyRef.current) return; // dasselbe Segment (oder weiter keins)
|
||||||
|
hoverDivideKeyRef.current = key;
|
||||||
|
if (hoverDivideTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(hoverDivideTimerRef.current);
|
||||||
|
hoverDivideTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setHoverDivideSeg(null); // Segment gewechselt → alte Anzeige sofort weg
|
||||||
|
if (!seg) return;
|
||||||
|
hoverDivideTimerRef.current = window.setTimeout(() => {
|
||||||
|
hoverDivideTimerRef.current = null;
|
||||||
|
setHoverDivideSeg(seg);
|
||||||
|
}, hoverDivideDelayMs);
|
||||||
|
};
|
||||||
|
const clearHoverDivide = () => {
|
||||||
|
if (hoverDivideTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(hoverDivideTimerRef.current);
|
||||||
|
hoverDivideTimerRef.current = null;
|
||||||
|
}
|
||||||
|
hoverDivideKeyRef.current = null;
|
||||||
|
setHoverDivideSeg(null);
|
||||||
|
};
|
||||||
|
// Weg, sobald ein Werkzeug aktiv wird (sonst könnte eine veraltete Anzeige
|
||||||
|
// ohne weiteres Move-Event über einem neuen Werkzeug-Entwurf stehen bleiben).
|
||||||
|
useEffect(() => {
|
||||||
|
if (toolActive) clearHoverDivide();
|
||||||
|
}, [toolActive]);
|
||||||
|
useEffect(() => () => clearHoverDivide(), []);
|
||||||
|
|
||||||
// Transiente Griff-Zieh-Daten (linke Taste auf einem Grip). Kein State je
|
// Transiente Griff-Zieh-Daten (linke Taste auf einem Grip). Kein State je
|
||||||
// Bewegung; die Modell-Mutation läuft live über gripHandlers.onGripMove.
|
// Bewegung; die Modell-Mutation läuft live über gripHandlers.onGripMove.
|
||||||
const gripDrag = useRef<{ pointerId: number; index: number } | null>(null);
|
// Anders als bei Kante/Körper braucht das Loslassen der Maus die Bearbeitung
|
||||||
|
// NICHT mehr zu beenden: der Griff bleibt „bewaffnet" (kein Pointer-Capture),
|
||||||
|
// folgt dem Cursor beim Schweben weiter — wie beim Zeichnen (Klick → Punkt
|
||||||
|
// setzen, Maus bewegt sich frei, Wert tippbar, nächster Klick/Enter bestätigt,
|
||||||
|
// Escape verwirft zur ursprünglichen Position `origin` zurück).
|
||||||
|
const gripDrag = useRef<{ pointerId: number; index: number; origin: Vec2 } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
// Transiente Kanten-Griff-Zieh-Daten (linke Taste auf einem Dreieck-Anfasser).
|
// Transiente Kanten-Griff-Zieh-Daten (linke Taste auf einem Dreieck-Anfasser).
|
||||||
// Hält den gegriffenen EdgeGrip; die Mutation läuft live über onEdgeMove.
|
// Hält den gegriffenen EdgeGrip (Momentaufnahme vom Grab — sein `.mid` ist der
|
||||||
|
// Anker für Feld-Eingabe/Escape-Revert); die Mutation läuft live über
|
||||||
|
// onEdgeMove. Dasselbe „bewaffnet"-Verhalten wie gripDrag oben: Loslassen
|
||||||
|
// beendet die Bearbeitung NICHT, erst ein weiterer Klick/Enter (bestätigt)
|
||||||
|
// oder Escape (verwirft zurück auf `edge.mid`).
|
||||||
const edgeDrag = useRef<{ pointerId: number; edge: EdgeGrip } | null>(null);
|
const edgeDrag = useRef<{ pointerId: number; edge: EdgeGrip } | null>(null);
|
||||||
// Transiente „Körper verschieben"-Daten: letzter Modellpunkt, gegen den das
|
// Transiente „Körper verschieben"-Daten: letzter Modellpunkt, gegen den das
|
||||||
// inkrementelle Delta gebildet wird (parallel-Verschiebung des Elements).
|
// inkrementelle Delta gebildet wird (parallel-Verschiebung des Elements).
|
||||||
@@ -789,6 +956,36 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
// Erkennung auf Raum-Stempel (Pointer-Capture unterdrückt natives dblclick).
|
// Erkennung auf Raum-Stempel (Pointer-Capture unterdrückt natives dblclick).
|
||||||
const lastClickRef = useRef<{ t: number; x: number; y: number } | null>(null);
|
const lastClickRef = useRef<{ t: number; x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
// Tastatur-Abschluss eines bewaffneten Punkt- ODER Kanten-Griffs (s. gripDrag/
|
||||||
|
// edgeDrag oben): Escape verwirft (zurück auf die Ursprungsposition), Enter
|
||||||
|
// bestätigt die aktuelle Position — beide NUR wenn kein Eingabefeld
|
||||||
|
// fokussiert ist (die Befehlszeile behandelt ihr eigenes Enter für getippte
|
||||||
|
// Werte selbst, s. App.tsx).
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const gd = gripDrag.current;
|
||||||
|
const ed = edgeDrag.current;
|
||||||
|
if ((!gd && !ed) || !gripHandlers) return;
|
||||||
|
if (e.key !== "Escape" && e.key !== "Enter") return;
|
||||||
|
const el = e.target as HTMLElement | null;
|
||||||
|
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
if (gd) {
|
||||||
|
gripDrag.current = null;
|
||||||
|
if (e.key === "Escape") gripHandlers.onGripCancel(gd.index, gd.origin);
|
||||||
|
else gripHandlers.onGripEnd();
|
||||||
|
} else if (ed) {
|
||||||
|
edgeDrag.current = null;
|
||||||
|
if (e.key === "Escape") gripHandlers.onEdgeCancel(ed.edge, ed.edge.mid);
|
||||||
|
else gripHandlers.onEdgeEnd();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [gripHandlers]);
|
||||||
|
|
||||||
/** Bildschirm-px-Distanz Cursor → Stempel-Griff; null = kein einzelner Raum. */
|
/** Bildschirm-px-Distanz Cursor → Stempel-Griff; null = kein einzelner Raum. */
|
||||||
const stampHandleAt = (clientX: number, clientY: number): string | null => {
|
const stampHandleAt = (clientX: number, clientY: number): string | null => {
|
||||||
if (!roomStamp) return null;
|
if (!roomStamp) return null;
|
||||||
@@ -927,6 +1124,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.
|
||||||
@@ -980,6 +1201,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
return pickDrawingText(clientX, clientY) ?? pickDrawingImage(clientX, clientY);
|
return pickDrawingText(clientX, clientY) ?? pickDrawingImage(clientX, clientY);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Nächstgelegenes EINZELNES Geraden-Segment (nicht das ganze Element) für
|
||||||
|
// die Hover-Teilpunkte-Anzeige (s. hoverDivideSeg unten) — dieselbe Nähe-
|
||||||
|
// Logik wie pickDrawing, liefert aber die zwei Endpunkte des getroffenen
|
||||||
|
// Segments selbst: eine Polylinie besteht aus mehreren "line"-Primitiven
|
||||||
|
// (eins je Segment, gleiche drawingId), unterteilt werden soll nur das
|
||||||
|
// konkret gehoverte Segment, nicht das ganze Element.
|
||||||
|
const nearestLineSegment = (clientX: number, clientY: number): { a: Vec2; b: Vec2 } | null => {
|
||||||
|
let best: { a: Vec2; b: Vec2 } | null = null;
|
||||||
|
let bestDist = LINE_PICK_PX;
|
||||||
|
for (const p of plan.primitives) {
|
||||||
|
if (p.kind !== "line" || !p.drawingId) continue;
|
||||||
|
const a = vbToClient(toScreen(p.a).x, toScreen(p.a).y);
|
||||||
|
const b = vbToClient(toScreen(p.b).x, toScreen(p.b).y);
|
||||||
|
const d = pointSegDistPx({ x: clientX, y: clientY }, a, b);
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d;
|
||||||
|
best = { a: p.a, b: p.b };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
// Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell-
|
// Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell-
|
||||||
// Bounding-Box um den Ankerpunkt (Text hat keine Linien-Geometrie zum Anfassen);
|
// Bounding-Box um den Ankerpunkt (Text hat keine Linien-Geometrie zum Anfassen);
|
||||||
// Winkel wird vernachlässigt (meist waagrecht). Liefert die drawingId oder null.
|
// Winkel wird vernachlässigt (meist waagrecht). Liefert die drawingId oder null.
|
||||||
@@ -1199,6 +1442,23 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
// Maus-Schema (docs/design/context-menu.md): MITTE = schwenken (Pan),
|
// Maus-Schema (docs/design/context-menu.md): MITTE = schwenken (Pan),
|
||||||
// LINKS = Auswahl, RECHTS = Kontextmenü, RAD = Zoom.
|
// LINKS = Auswahl, RECHTS = Kontextmenü, RAD = Zoom.
|
||||||
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
|
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||||
|
// Ein Punkt- ODER Kanten-Griff ist bereits „bewaffnet" (Maus wurde
|
||||||
|
// losgelassen, der Griff folgt seither dem Cursor) — JEDER weitere
|
||||||
|
// Linksklick bestätigt nur die aktuelle Position, statt irgendeine andere
|
||||||
|
// Aktion (Doppelklick, Schere, neue Auswahl, …) an derselben Stelle
|
||||||
|
// auszulösen.
|
||||||
|
if (e.button === 0 && gripDrag.current && gripHandlers) {
|
||||||
|
e.preventDefault();
|
||||||
|
gripDrag.current = null;
|
||||||
|
gripHandlers.onGripEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.button === 0 && edgeDrag.current && gripHandlers) {
|
||||||
|
e.preventDefault();
|
||||||
|
edgeDrag.current = null;
|
||||||
|
gripHandlers.onEdgeEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Doppelklick (zweiter Klick der Sequenz) auf einen Raum-Stempel/-Fläche →
|
// Doppelklick (zweiter Klick der Sequenz) auf einen Raum-Stempel/-Fläche →
|
||||||
// Stempel-Editor öffnen. Manuell über Zeit/Abstand erkannt, weil PlanView
|
// Stempel-Editor öffnen. Manuell über Zeit/Abstand erkannt, weil PlanView
|
||||||
// Pointer-Capture nutzt und der Browser dann kein natives `dblclick`
|
// Pointer-Capture nutzt und der Browser dann kein natives `dblclick`
|
||||||
@@ -1278,16 +1538,19 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
const gi = gripAt(e.clientX, e.clientY);
|
const gi = gripAt(e.clientX, e.clientY);
|
||||||
if (gi !== null) {
|
if (gi !== null) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
// KEIN Pointer-Capture: der Griff bleibt nach dem Loslassen bewaffnet
|
||||||
gripDrag.current = { pointerId: e.pointerId, index: gi };
|
// und folgt via normalem Hover-onPointerMove weiter dem Cursor (s.
|
||||||
|
// Kommentar an der gripDrag-Deklaration).
|
||||||
|
gripDrag.current = { pointerId: e.pointerId, index: gi, origin: grips![gi] };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Linke Taste auf einem Kanten-Anfasser (Dreieck): Seite ziehen.
|
// Linke Taste auf einem Kanten-Anfasser (Dreieck): Seite ziehen. KEIN
|
||||||
|
// Pointer-Capture — dasselbe „bewaffnet nach Loslassen"-Verhalten wie
|
||||||
|
// beim Punkt-Griff (s. Kommentar an der edgeDrag-Deklaration).
|
||||||
if (edgeGripsActive) {
|
if (edgeGripsActive) {
|
||||||
const eg = edgeGripAt(e.clientX, e.clientY);
|
const eg = edgeGripAt(e.clientX, e.clientY);
|
||||||
if (eg) {
|
if (eg) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
|
||||||
edgeDrag.current = { pointerId: e.pointerId, edge: eg };
|
edgeDrag.current = { pointerId: e.pointerId, edge: eg };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1508,7 +1771,12 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const d = drag.current;
|
const d = drag.current;
|
||||||
if (!d || d.pointerId !== e.pointerId) return;
|
if (!d || d.pointerId !== e.pointerId) {
|
||||||
|
// Echter Ruhezustand (kein Drag/Werkzeug/Marquee/Pan) — Hover-
|
||||||
|
// Teilpunkte-Erkennung.
|
||||||
|
updateHoverDivide(e.clientX, e.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { scale } = measure(d.startView);
|
const { scale } = measure(d.startView);
|
||||||
// Bildschirm-Delta → viewBox-Einheiten; Inhalt folgt dem Cursor, also
|
// Bildschirm-Delta → viewBox-Einheiten; Inhalt folgt dem Cursor, also
|
||||||
// verschiebt sich der Ausschnitt entgegengesetzt.
|
// verschiebt sich der Ausschnitt entgegengesetzt.
|
||||||
@@ -1560,25 +1828,19 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
stampDrag.current = null;
|
stampDrag.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Kanten-Griff loslassen: Ziehen abschließen.
|
// Kanten-Griff: Maus loslassen beendet die Bearbeitung NICHT mehr — kein
|
||||||
const ed = edgeDrag.current;
|
// Pointer-Capture gehalten, der Griff bleibt bewaffnet und folgt weiter dem
|
||||||
if (ed && ed.pointerId === e.pointerId) {
|
// Cursor. Abschluss erst per erneutem Klick (oben in onPointerDown) oder
|
||||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
// Enter, Verwerfen per Escape (beides im Tastatur-Effekt unten).
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
if (edgeDrag.current && edgeDrag.current.pointerId === e.pointerId) {
|
||||||
}
|
|
||||||
edgeDrag.current = null;
|
|
||||||
gripHandlers!.onGripEnd();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editier-Griff loslassen: Ziehen abschließen.
|
// Editier-Griff: Maus loslassen beendet die Bearbeitung NICHT mehr — kein
|
||||||
const gd = gripDrag.current;
|
// Pointer-Capture gehalten, der Griff bleibt bewaffnet und folgt weiter dem
|
||||||
if (gd && gd.pointerId === e.pointerId) {
|
// Cursor. Abschluss erst per erneutem Klick (oben in onPointerDown) oder
|
||||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
// Enter, Verwerfen per Escape (beides im Tastatur-Effekt unten).
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
if (gripDrag.current && gripDrag.current.pointerId === e.pointerId) {
|
||||||
}
|
|
||||||
gripDrag.current = null;
|
|
||||||
gripHandlers!.onGripEnd();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1631,6 +1893,9 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
onMarquee({
|
onMarquee({
|
||||||
wallIds: marqueeHit(plan.primitives, rect, crossing),
|
wallIds: marqueeHit(plan.primitives, rect, crossing),
|
||||||
drawingIds: marqueeHitDrawings(plan.primitives, rect, crossing),
|
drawingIds: marqueeHitDrawings(plan.primitives, rect, crossing),
|
||||||
|
ceilingIds: marqueeHitCeilings(plan.primitives, rect, crossing),
|
||||||
|
roomIds: marqueeHitRooms(plan.primitives, rect, crossing),
|
||||||
|
stairIds: marqueeHitStairs(plan.primitives, rect, crossing),
|
||||||
crossing,
|
crossing,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1798,6 +2063,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);
|
||||||
@@ -2006,6 +2278,48 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
);
|
);
|
||||||
}, [plan, selectedColumnIds]);
|
}, [plan, selectedColumnIds]);
|
||||||
|
|
||||||
|
// Ist eine GRUPPE gewählt (statt einzelner Elemente), zeigt die Auswahl NUR
|
||||||
|
// eine einfache Bounding-Box mit vier Eckpunkten statt jedes einzelnen
|
||||||
|
// Mitglieds-Umrisses (Nutzer-Wunsch, Vectorworks-Vorbild: „einfach vier
|
||||||
|
// Aussenpunkte zeigen") — vereint dieselben Hervorhebungs-Listen, die sonst
|
||||||
|
// einzeln gezeichnet würden.
|
||||||
|
const groupBBox = useMemo(() => {
|
||||||
|
if (!selectedGroupIds || selectedGroupIds.length === 0) return null;
|
||||||
|
const pts: Vec2[] = [];
|
||||||
|
for (const poly of highlightPolys) pts.push(...poly.pts);
|
||||||
|
for (const poly of highlightCeilingPolys) pts.push(...poly.pts);
|
||||||
|
for (const poly of highlightRoomPolys) pts.push(...poly.pts);
|
||||||
|
for (const poly of highlightRoofPolys) pts.push(...poly.pts);
|
||||||
|
for (const poly of highlightColumnPolys) pts.push(...poly.pts);
|
||||||
|
const fromLineOrPoly = (p: { kind: string; a?: Vec2; b?: Vec2; from?: Vec2; to?: Vec2; center?: Vec2; pts?: Vec2[] }) => {
|
||||||
|
if (p.kind === "line" && p.a && p.b) pts.push(p.a, p.b);
|
||||||
|
else if (p.kind === "arc" && p.from && p.to) pts.push(p.from, p.to);
|
||||||
|
else if (p.pts) pts.push(...p.pts);
|
||||||
|
};
|
||||||
|
for (const p of highlightDrawPrims) fromLineOrPoly(p);
|
||||||
|
for (const p of highlightOpeningPrims) fromLineOrPoly(p);
|
||||||
|
for (const p of highlightStairPrims) fromLineOrPoly(p);
|
||||||
|
if (pts.length === 0) return null;
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (const p of pts) {
|
||||||
|
minX = Math.min(minX, p.x);
|
||||||
|
minY = Math.min(minY, p.y);
|
||||||
|
maxX = Math.max(maxX, p.x);
|
||||||
|
maxY = Math.max(maxY, p.y);
|
||||||
|
}
|
||||||
|
return { minX, minY, maxX, maxY };
|
||||||
|
}, [
|
||||||
|
selectedGroupIds,
|
||||||
|
highlightPolys,
|
||||||
|
highlightCeilingPolys,
|
||||||
|
highlightRoomPolys,
|
||||||
|
highlightRoofPolys,
|
||||||
|
highlightColumnPolys,
|
||||||
|
highlightDrawPrims,
|
||||||
|
highlightOpeningPrims,
|
||||||
|
highlightStairPrims,
|
||||||
|
]);
|
||||||
|
|
||||||
// Zusammenhängende 2D-Zeichen-Linien (gleiche drawingId + Stil) zu Zügen
|
// Zusammenhängende 2D-Zeichen-Linien (gleiche drawingId + Stil) zu Zügen
|
||||||
// bündeln, damit sie als EIN <polyline>/<polygon> gezeichnet werden können —
|
// bündeln, damit sie als EIN <polyline>/<polygon> gezeichnet werden können —
|
||||||
// so gehren dicke Ecken sauber (kein Butt-Cap-Sprung). generatePlan schiebt die
|
// so gehren dicke Ecken sauber (kein Butt-Cap-Sprung). generatePlan schiebt die
|
||||||
@@ -2123,7 +2437,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={endDrag}
|
onPointerUp={endDrag}
|
||||||
onPointerCancel={endDrag}
|
onPointerCancel={endDrag}
|
||||||
onPointerLeave={() => { setRulerCursor(null); onCursor?.(null); }}
|
onPointerLeave={() => { setRulerCursor(null); onCursor?.(null); clearHoverDivide(); }}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
onContextMenu={onContextMenuHandler}
|
onContextMenu={onContextMenuHandler}
|
||||||
@@ -2197,6 +2511,11 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
CAD-typische Gehrung an den Ecken (stroke-linejoin), keine Stufe bei
|
CAD-typische Gehrung an den Ecken (stroke-linejoin), keine Stufe bei
|
||||||
dicken Linien. Nach der Haupt-Ebene gezeichnet (2D liegt ohnehin oben). */}
|
dicken Linien. Nach der Haupt-Ebene gezeichnet (2D liegt ohnehin oben). */}
|
||||||
{drawingRunEls}
|
{drawingRunEls}
|
||||||
|
{/* Ist eine GRUPPE gewählt, ersetzt die Bounding-Box unten die einzelnen
|
||||||
|
Mitglieds-Hervorhebungen (s. `groupBBox`) — dieser ganze Block bleibt
|
||||||
|
dann aus. */}
|
||||||
|
{!groupBBox && (
|
||||||
|
<>
|
||||||
{/* Auswahl-Hervorhebung: alle Bänder der gewählten Wand (bzw. das lokal
|
{/* Auswahl-Hervorhebung: alle Bänder der gewählten Wand (bzw. das lokal
|
||||||
getroffene Polygon) mit Akzentkontur überzeichnen. */}
|
getroffene Polygon) mit Akzentkontur überzeichnen. */}
|
||||||
{highlightPolys.map((poly, i) => (
|
{highlightPolys.map((poly, i) => (
|
||||||
@@ -2376,6 +2695,38 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Gruppen-Auswahl: nur eine Bounding-Box mit vier Eckpunkten (kein
|
||||||
|
Umriss jedes einzelnen Mitglieds) — s. `groupBBox`. */}
|
||||||
|
{groupBBox && (() => {
|
||||||
|
const c1 = toScreen({ x: groupBBox.minX, y: groupBBox.minY });
|
||||||
|
const c2 = toScreen({ x: groupBBox.maxX, y: groupBBox.minY });
|
||||||
|
const c3 = toScreen({ x: groupBBox.maxX, y: groupBBox.maxY });
|
||||||
|
const c4 = toScreen({ x: groupBBox.minX, y: groupBBox.maxY });
|
||||||
|
const size = 5 / (meetScale(view, svgRef.current) ?? 1);
|
||||||
|
const corners = [c1, c2, c3, c4];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<polygon
|
||||||
|
className="plan-group-bbox"
|
||||||
|
points={corners.map((s) => `${s.x},${s.y}`).join(" ")}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
{corners.map((s, i) => (
|
||||||
|
<rect
|
||||||
|
key={`gbb-${i}`}
|
||||||
|
className="plan-group-bbox-corner"
|
||||||
|
x={s.x - size}
|
||||||
|
y={s.y - size}
|
||||||
|
width={size * 2}
|
||||||
|
height={size * 2}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{/* Auswahl-Rechteck (Marquee). Gestrichelt; die crossing-Variante (rechts→
|
{/* Auswahl-Rechteck (Marquee). Gestrichelt; die crossing-Variante (rechts→
|
||||||
links, berührend) hebt sich farblich/strichlich ab. Das Rechteck steht
|
links, berührend) hebt sich farblich/strichlich ab. Das Rechteck steht
|
||||||
bereits in viewBox-Einheiten → direkt setzbar. */}
|
bereits in viewBox-Einheiten → direkt setzbar. */}
|
||||||
@@ -2394,6 +2745,21 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Bezier-Hilfslinien (Anker → Griff), gestrichelt — VOR den Griff-
|
||||||
|
Quadraten gezeichnet, damit die Griffe oben liegen (anklickbar). */}
|
||||||
|
{gripsActive &&
|
||||||
|
gripHandleLines &&
|
||||||
|
gripHandleLines.map((l, i) => (
|
||||||
|
<line
|
||||||
|
key={`gripline-${i}`}
|
||||||
|
className="plan-grip-line"
|
||||||
|
x1={toScreen(l.a).x}
|
||||||
|
y1={toScreen(l.a).y}
|
||||||
|
x2={toScreen(l.b).x}
|
||||||
|
y2={toScreen(l.b).y}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{/* Editier-Griffe des selektierten Elements (Wand-Enden / 2D-Vertices):
|
{/* Editier-Griffe des selektierten Elements (Wand-Enden / 2D-Vertices):
|
||||||
ziehbare Quadrate. Bildschirmkonstante Größe über die meet-Skala. */}
|
ziehbare Quadrate. Bildschirmkonstante Größe über die meet-Skala. */}
|
||||||
{gripsActive &&
|
{gripsActive &&
|
||||||
@@ -2476,6 +2842,36 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
hudFields={hudFields ?? null}
|
hudFields={hudFields ?? null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Hover-Teilpunkte (s. hoverDivideSeg oben): kleine Punkte in Akzentfarbe
|
||||||
|
an den `hoverDivideCount`-gleichen Teilpunkten des gehoverten Segments
|
||||||
|
(2 = nur Mitte). Nur im Ruhezustand (kein Werkzeug aktiv). */}
|
||||||
|
{hoverDivideSeg && !toolActive && (() => {
|
||||||
|
const vbPerPx = 1 / (meetScale(view, svgRef.current) ?? 1);
|
||||||
|
const r = 3.5 * vbPerPx;
|
||||||
|
const n = Math.max(2, Math.round(hoverDivideCount));
|
||||||
|
const { a, b } = hoverDivideSeg;
|
||||||
|
const pts = Array.from({ length: n - 1 }, (_, i) => {
|
||||||
|
const t = (i + 1) / n;
|
||||||
|
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<g className="plan-hover-divide" pointerEvents="none">
|
||||||
|
{pts.map((pt, i) => {
|
||||||
|
const s = toScreen(pt);
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={s.x}
|
||||||
|
cy={s.y}
|
||||||
|
r={r}
|
||||||
|
style={{ fill: snapColor, stroke: snapColor }}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</svg>
|
</svg>
|
||||||
{/* Inline-Text-Editor DIREKT auf der Zeichenfläche (kein Dialog, Nutzer-
|
{/* Inline-Text-Editor DIREKT auf der Zeichenfläche (kein Dialog, Nutzer-
|
||||||
Feedback) — für ein frisch über `text`/`textbox` erzeugtes Element
|
Feedback) — für ein frisch über `text`/`textbox` erzeugtes Element
|
||||||
|
|||||||
@@ -121,8 +121,9 @@ export function resolveWallSectionStyle(project: Project, wall: Wall): SectionCu
|
|||||||
wallAngleDeg,
|
wallAngleDeg,
|
||||||
resolveForeground(best, wall.foreground, category, wall.foregroundSource),
|
resolveForeground(best, wall.foreground, category, wall.foregroundSource),
|
||||||
);
|
);
|
||||||
// Poché-Hintergrund neutral (Papier) statt Bauteil-Albedo; Vollmuster → Tinte.
|
// Poché-Hintergrund neutral (Papier) statt Bauteil-Albedo; Vollmuster → Tinte
|
||||||
// `hatch.color` ist bereits über {@link resolveHatch} auf HATCH_INK gezwungen.
|
// (unabhängig von `hatch.color`, das jetzt den aufgelösten Vordergrund tragen
|
||||||
|
// kann, s. {@link resolveHatch}).
|
||||||
const solid = hatch.pattern === "solid";
|
const solid = hatch.pattern === "solid";
|
||||||
return {
|
return {
|
||||||
fill: solid ? HATCH_INK : HATCH_PAPER,
|
fill: solid ? HATCH_INK : HATCH_PAPER,
|
||||||
@@ -437,7 +438,13 @@ export function generatePlan(
|
|||||||
const category = catByCode.get(column.categoryCode);
|
const category = catByCode.get(column.categoryCode);
|
||||||
const ov = overrideFor(column.categoryCode, columnLabel(column));
|
const ov = overrideFor(column.categoryCode, columnLabel(column));
|
||||||
const lwMm =
|
const lwMm =
|
||||||
ov.lineweight ?? resolveStrokeWeight(undefined, undefined, category, lwByCode.get(column.categoryCode) ?? WALL_FALLBACK_MM);
|
ov.lineweight ??
|
||||||
|
resolveStrokeWeight(
|
||||||
|
column.strokeWeight,
|
||||||
|
column.strokeWeightSource,
|
||||||
|
category,
|
||||||
|
lwByCode.get(column.categoryCode) ?? WALL_FALLBACK_MM,
|
||||||
|
);
|
||||||
const effCol = ov.color !== undefined ? { ...column, color: ov.color } : column;
|
const effCol = ov.color !== undefined ? { ...column, color: ov.color } : column;
|
||||||
addColumnPoche(primitives, project, effCol, greyed, lwMm, category);
|
addColumnPoche(primitives, project, effCol, greyed, lwMm, category);
|
||||||
}
|
}
|
||||||
@@ -717,7 +724,15 @@ function computeBounds(
|
|||||||
else if (g.shape === "circle" || g.shape === "arc") {
|
else if (g.shape === "circle" || g.shape === "arc") {
|
||||||
acc({ x: g.center.x - g.r, y: g.center.y - g.r });
|
acc({ x: g.center.x - g.r, y: g.center.y - g.r });
|
||||||
acc({ x: g.center.x + g.r, y: g.center.y + g.r });
|
acc({ x: g.center.x + g.r, y: g.center.y + g.r });
|
||||||
} else if (g.shape === "text") acc(g.at);
|
} else if (g.shape === "ellipse") {
|
||||||
|
acc({ x: g.center.x - g.rx, y: g.center.y - g.ry });
|
||||||
|
acc({ x: g.center.x + g.rx, y: g.center.y + g.ry });
|
||||||
|
} else if (g.shape === "bezier" || g.shape === "spline") {
|
||||||
|
g.pts.forEach(acc);
|
||||||
|
} else if (g.shape === "dimension") {
|
||||||
|
g.pts.forEach(acc);
|
||||||
|
} else if (g.shape === "kote") acc(g.at);
|
||||||
|
else if (g.shape === "text") acc(g.at);
|
||||||
else if (g.shape === "image") { acc(g.min); acc(g.max); }
|
else if (g.shape === "image") { acc(g.min); acc(g.max); }
|
||||||
}
|
}
|
||||||
// GESAMTE Kontext-Schicht (Konturen, importierte Gebäude/Terrain, Luftbild)
|
// GESAMTE Kontext-Schicht (Konturen, importierte Gebäude/Terrain, Luftbild)
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* `addDrawing2D` — Rendering der neuen Freiform-Geometrien (ellipse/bezier/
|
||||||
|
* spline): tessellierte Linien-/Polygon-Primitive wie bei polyline/rect,
|
||||||
|
* kein eigenes SVG-Primitiv (s. `model/types.ts` Drawing2DGeom-Doku).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { addDrawing2D } from "./drawing2d";
|
||||||
|
import type { Drawing2D, Project } from "../../model/types";
|
||||||
|
import type { Primitive } from "./shared";
|
||||||
|
|
||||||
|
function project(): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [],
|
||||||
|
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lwByCode = new Map<string, number>();
|
||||||
|
const colorByCode = new Map<string, string>();
|
||||||
|
const catByCode = new Map<string, Project["layers"][number]>();
|
||||||
|
|
||||||
|
describe("addDrawing2D — ellipse", () => {
|
||||||
|
it("gibt eine gefüllte Fläche + geschlossenen Linienzug (64 Segmente) aus", () => {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: "e1",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom: { shape: "ellipse", center: { x: 0, y: 0 }, rx: 4, ry: 2, rotation: 0 },
|
||||||
|
};
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
|
||||||
|
const polys = out.filter((p) => p.kind === "polygon");
|
||||||
|
const lines = out.filter((p) => p.kind === "line");
|
||||||
|
expect(polys.length).toBe(1);
|
||||||
|
expect(lines.length).toBe(64);
|
||||||
|
for (const l of lines) expect(l.drawingId).toBe("e1");
|
||||||
|
// Alle Punkte liegen auf der (unrotierten) Ellipse.
|
||||||
|
for (const l of lines) {
|
||||||
|
const dx = l.a.x / 4, dy = l.a.y / 2;
|
||||||
|
expect(dx * dx + dy * dy).toBeCloseTo(1, 3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addDrawing2D — bezier", () => {
|
||||||
|
it("ein Segment: tessellierte offene Linienkette, kein Füll-Polygon", () => {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: "b1",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom: {
|
||||||
|
shape: "bezier",
|
||||||
|
pts: [{ x: 0, y: 0 }, { x: 1, y: 3 }, { x: 3, y: 3 }, { x: 4, y: 0 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
|
||||||
|
expect(out.every((p) => p.kind === "line")).toBe(true);
|
||||||
|
expect(out.length).toBe(24); // segsPerCurve Default
|
||||||
|
const lines = out.filter((p): p is Extract<Primitive, { kind: "line" }> => p.kind === "line");
|
||||||
|
expect(lines[0].a).toEqual({ x: 0, y: 0 });
|
||||||
|
const last = lines[lines.length - 1];
|
||||||
|
expect(last.b.x).toBeCloseTo(4, 6);
|
||||||
|
expect(last.b.y).toBeCloseTo(0, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addDrawing2D — spline", () => {
|
||||||
|
it("offen: tessellierte Linienkette durch die Wegpunkte, kein Füll-Polygon", () => {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: "s1",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom: { shape: "spline", pts: [{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 5, y: 1 }], closed: false },
|
||||||
|
};
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
|
||||||
|
expect(out.some((p) => p.kind === "polygon")).toBe(false);
|
||||||
|
const lines = out.filter((p): p is Extract<Primitive, { kind: "line" }> => p.kind === "line");
|
||||||
|
expect(lines[0].a).toEqual({ x: 0, y: 0 });
|
||||||
|
const last = lines[lines.length - 1];
|
||||||
|
expect(last.b.x).toBeCloseTo(5, 6);
|
||||||
|
expect(last.b.y).toBeCloseTo(1, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("geschlossen: Füll-Polygon + Linienring schliesst zum ersten Punkt", () => {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: "s2",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom: {
|
||||||
|
shape: "spline",
|
||||||
|
pts: [{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 4 }, { x: 0, y: 4 }],
|
||||||
|
closed: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
|
||||||
|
expect(out.filter((p) => p.kind === "polygon").length).toBe(1);
|
||||||
|
const lines = out.filter((p): p is Extract<Primitive, { kind: "line" }> => p.kind === "line");
|
||||||
|
const last = lines[lines.length - 1];
|
||||||
|
expect(last.b).toEqual(lines[0].a);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addDrawing2D — dimension (SIA 400 B.5.3)", () => {
|
||||||
|
it("gibt 2 Masshilfslinien + 1 Masslinie (dünn) + 2 Massstriche (doppelte Stärke) + 1 Masszahl-Text aus", () => {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: "dim1",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom: { shape: "dimension", pts: [{ x: 0, y: 0 }, { x: 4, y: 0 }], offset: 1 },
|
||||||
|
};
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
|
||||||
|
const lines = out.filter((p): p is Extract<Primitive, { kind: "line" }> => p.kind === "line");
|
||||||
|
const texts = out.filter((p) => p.kind === "drawingText");
|
||||||
|
expect(lines.length).toBe(5); // 2 Masshilfslinien + 1 Masslinie + 2 Massstriche
|
||||||
|
expect(texts.length).toBe(1);
|
||||||
|
const weights = lines.map((l) => l.weightMm).sort((a, b) => a - b);
|
||||||
|
const thin = weights[0];
|
||||||
|
const thick = weights[weights.length - 1];
|
||||||
|
// 3× dünn (Masshilfslinien + Masslinie), 2× doppelt so dick (Massstriche).
|
||||||
|
expect(weights.filter((w) => w === thin).length).toBe(3);
|
||||||
|
expect(weights.filter((w) => w === thick).length).toBe(2);
|
||||||
|
expect(thick).toBeCloseTo(thin * 2, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Masszahl entspricht dist(a,b), 2 Dezimalstellen (SIA 400 Figur 13/14: Punkt-Notation)", () => {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: "dim2",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom: { shape: "dimension", pts: [{ x: 0, y: 0 }, { x: 3, y: 4 }], offset: 0.5 }, // 3-4-5-Dreieck → 5m
|
||||||
|
};
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
|
||||||
|
const text = out.find((p) => p.kind === "drawingText");
|
||||||
|
expect(text).toBeDefined();
|
||||||
|
if (text?.kind === "drawingText") expect(text.text).toBe("5.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entartete Strecke (a===b) erzeugt keine Primitive", () => {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: "dim3",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom: { shape: "dimension", pts: [{ x: 2, y: 2 }, { x: 2, y: 2 }], offset: 1 },
|
||||||
|
};
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
|
||||||
|
expect(out.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addDrawing2D — kote (SIA 400 B.5.4)", () => {
|
||||||
|
it("OK fertig: hohles Dreieck (Spitze nach unten) + formatierte Zahl mit +", () => {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: "k1",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom: { shape: "kote", at: { x: 0, y: 0 }, value: 3.25, kind: "okFertig", rotation: 0 },
|
||||||
|
};
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
|
||||||
|
const poly = out.find((p) => p.kind === "polygon");
|
||||||
|
const text = out.find((p) => p.kind === "drawingText");
|
||||||
|
expect(poly).toBeDefined();
|
||||||
|
expect(text).toBeDefined();
|
||||||
|
if (poly?.kind === "polygon") {
|
||||||
|
expect(poly.fill).toBe("none"); // fertig = hohl
|
||||||
|
expect(poly.pts[0]).toEqual({ x: 0, y: 0 }); // Spitze = at
|
||||||
|
expect(poly.pts[1].y).toBeGreaterThan(0); // OK: Basis oberhalb der Spitze
|
||||||
|
}
|
||||||
|
if (text?.kind === "drawingText") expect(text.text).toBe("+3.25");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("UK roh: gefülltes Dreieck (Spitze nach oben), Basis unterhalb", () => {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: "k2",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom: { shape: "kote", at: { x: 0, y: 0 }, value: -0.1, kind: "ukRoh", rotation: 0 },
|
||||||
|
};
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
|
||||||
|
const poly = out.find((p) => p.kind === "polygon");
|
||||||
|
const text = out.find((p) => p.kind === "drawingText");
|
||||||
|
if (poly?.kind === "polygon") {
|
||||||
|
expect(poly.fill).not.toBe("none"); // roh = gefüllt
|
||||||
|
expect(poly.pts[1].y).toBeLessThan(0); // UK: Basis unterhalb der Spitze
|
||||||
|
}
|
||||||
|
if (text?.kind === "drawingText") expect(text.text).toBe("-0.10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Wert 0 wird als ±0.00 formatiert", () => {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: "k3",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom: { shape: "kote", at: { x: 0, y: 0 }, value: 0, kind: "okFertig", rotation: 0 },
|
||||||
|
};
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
|
||||||
|
const text = out.find((p) => p.kind === "drawingText");
|
||||||
|
if (text?.kind === "drawingText") expect(text.text).toBe("±0.00");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,105 @@ import {
|
|||||||
resolveStrokeWeight,
|
resolveStrokeWeight,
|
||||||
type Primitive,
|
type Primitive,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
import { ellipsePts, sampleBezierChain, sampleCatmullRom } from "../../tools/curves";
|
||||||
|
|
||||||
|
// ── Bemassung (SIA 400 B.5.3) ────────────────────────────────────────────────
|
||||||
|
// Modell-Meter-Konstanten für rein grafische (nicht reale) Bemassungs-Details
|
||||||
|
// — analog zu DEFAULT_TEXT_HEIGHT_M (text.ts): fest gewählt, damit sie bei der
|
||||||
|
// üblichen Plan-Massstäben (1:50…1:100) lesbar wirken, nicht dynamisch an den
|
||||||
|
// Massstab gekoppelt (wie auch die übrige Textgrösse im Plan).
|
||||||
|
const DIM_GAP_M = 0.05; // Abstand Masshilfslinie → bemessener Punkt
|
||||||
|
const DIM_OVERSHOOT_M = 0.05; // Überstand der Masshilfslinie über die Masslinie hinaus
|
||||||
|
const DIM_TICK_LEN_M = 0.15; // Länge des Massstrichs (Diagonale an den Masslinien-Enden)
|
||||||
|
const DIM_TEXT_HEIGHT_M = 0.2; // Schrifthöhe der Masszahl
|
||||||
|
|
||||||
|
/** Formatiert eine Länge (Meter) als Masszahl: 2 Dezimalstellen (SIA 400 B.5.2/B.5.4, „3.25"/„518.60"). */
|
||||||
|
function formatDimLabel(m: number): string {
|
||||||
|
return m.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelnes Mass-Segment (Masshilfslinien + Masslinie + Massstriche +
|
||||||
|
* Masszahl) zwischen `a` und `b` bei gegebenem `offset` — s. `formatDimLabel`.
|
||||||
|
* Extrahiert, damit eine Masskette (`shape:"dimension"`, `pts.length` ≥ 2)
|
||||||
|
* je aufeinanderfolgendem Punktpaar dasselbe Rendering wiederverwenden kann.
|
||||||
|
*/
|
||||||
|
function addDimensionSegment(
|
||||||
|
out: Primitive[],
|
||||||
|
a: Vec2,
|
||||||
|
b: Vec2,
|
||||||
|
offset: number,
|
||||||
|
weightMm: number,
|
||||||
|
dash: number[] | null,
|
||||||
|
color: string,
|
||||||
|
greyed: boolean,
|
||||||
|
drawingId: string,
|
||||||
|
): void {
|
||||||
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||||||
|
const lineLen = Math.hypot(dx, dy);
|
||||||
|
if (lineLen < 1e-6) return;
|
||||||
|
const ux = dx / lineLen, uy = dy / lineLen; // Einheitsrichtung a→b
|
||||||
|
const nx = -uy, ny = ux; // Links-Normale (Einheit) — Richtung des Offsets
|
||||||
|
const sign = offset >= 0 ? 1 : -1;
|
||||||
|
const dimA: Vec2 = { x: a.x + nx * offset, y: a.y + ny * offset };
|
||||||
|
const dimB: Vec2 = { x: b.x + nx * offset, y: b.y + ny * offset };
|
||||||
|
const extLine = (from: Vec2, to: Vec2) =>
|
||||||
|
out.push({ kind: "line", a: from, b: to, cls: "draw2d", weightMm, dash, color, greyed, drawingId });
|
||||||
|
extLine(
|
||||||
|
{ x: a.x + nx * DIM_GAP_M * sign, y: a.y + ny * DIM_GAP_M * sign },
|
||||||
|
{ x: dimA.x + nx * DIM_OVERSHOOT_M * sign, y: dimA.y + ny * DIM_OVERSHOOT_M * sign },
|
||||||
|
);
|
||||||
|
extLine(
|
||||||
|
{ x: b.x + nx * DIM_GAP_M * sign, y: b.y + ny * DIM_GAP_M * sign },
|
||||||
|
{ x: dimB.x + nx * DIM_OVERSHOOT_M * sign, y: dimB.y + ny * DIM_OVERSHOOT_M * sign },
|
||||||
|
);
|
||||||
|
extLine(dimA, dimB);
|
||||||
|
// Massstriche: 45°-Diagonale (Winkelhalbierende von Richtung/Normale), an
|
||||||
|
// beiden Enden PARALLEL (nicht gespiegelt) — wie in SIA 400 Figur 13.
|
||||||
|
const tx = (ux + nx) / Math.SQRT2, ty = (uy + ny) / Math.SQRT2;
|
||||||
|
const half = DIM_TICK_LEN_M / 2;
|
||||||
|
const tick = (pt: Vec2) =>
|
||||||
|
out.push({
|
||||||
|
kind: "line",
|
||||||
|
a: { x: pt.x - tx * half, y: pt.y - ty * half },
|
||||||
|
b: { x: pt.x + tx * half, y: pt.y + ty * half },
|
||||||
|
cls: "draw2d",
|
||||||
|
weightMm: weightMm * 2,
|
||||||
|
dash: null,
|
||||||
|
color,
|
||||||
|
greyed,
|
||||||
|
drawingId,
|
||||||
|
});
|
||||||
|
tick(dimA);
|
||||||
|
tick(dimB);
|
||||||
|
// Masszahl: Mitte der Masslinie, leicht abgesetzt, nie kopfüber lesbar.
|
||||||
|
let angle = Math.atan2(dy, dx);
|
||||||
|
if (Math.cos(angle) < 0) angle += Math.PI;
|
||||||
|
const upx = -Math.sin(angle), upy = Math.cos(angle); // Links-Normale der ENDGÜLTIGEN Richtung
|
||||||
|
const mid: Vec2 = { x: (dimA.x + dimB.x) / 2, y: (dimA.y + dimB.y) / 2 };
|
||||||
|
out.push({
|
||||||
|
kind: "drawingText",
|
||||||
|
at: { x: mid.x + upx * DIM_TEXT_HEIGHT_M * 0.6, y: mid.y + upy * DIM_TEXT_HEIGHT_M * 0.6 },
|
||||||
|
text: formatDimLabel(lineLen),
|
||||||
|
heightM: DIM_TEXT_HEIGHT_M,
|
||||||
|
angle,
|
||||||
|
color,
|
||||||
|
greyed,
|
||||||
|
drawingId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Höhenkote (SIA 400 B.5.4) ────────────────────────────────────────────────
|
||||||
|
const KOTE_TRI_SIZE_M = 0.15; // Dreieck-Grösse (Spitze bis Basis)
|
||||||
|
const KOTE_TRI_HALF_W_M = KOTE_TRI_SIZE_M * 0.6;
|
||||||
|
const KOTE_TEXT_HEIGHT_M = 0.2;
|
||||||
|
const KOTE_TEXT_GAP_M = 0.08; // Abstand Dreieck-Basis → Zahl
|
||||||
|
|
||||||
|
/** Formatiert einen Höhenwert (SIA 400 B.5.4, Figur 18): „+3.25" · „±0.00" · „-0.10". */
|
||||||
|
function formatKoteLabel(value: number): string {
|
||||||
|
if (Math.abs(value) < 1e-6) return "±0.00";
|
||||||
|
return (value > 0 ? "+" : "") + value.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
export function addDrawing2D(
|
export function addDrawing2D(
|
||||||
out: Primitive[],
|
out: Primitive[],
|
||||||
@@ -135,6 +234,67 @@ export function addDrawing2D(
|
|||||||
drawingId: d.id,
|
drawingId: d.id,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case "ellipse": {
|
||||||
|
// Tessellierte Kontur (wie polyline/rect) statt eigenem SVG-Primitiv —
|
||||||
|
// konsistent mit dem übrigen 2D-Zeichnen-Werkzeugsatz, keine eigene
|
||||||
|
// Grip-/Snap-/Export-Sonderbehandlung nötig.
|
||||||
|
const pts = ellipsePts(g.center, g.rx, g.ry, g.rotation);
|
||||||
|
fillPoly(pts);
|
||||||
|
for (let i = 0; i < pts.length; i++) seg(pts[i], pts[(i + 1) % pts.length]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "bezier": {
|
||||||
|
const pts = sampleBezierChain(g.pts);
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) seg(pts[i], pts[i + 1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "spline": {
|
||||||
|
const pts = sampleCatmullRom(g.pts, g.closed && g.pts.length > 2);
|
||||||
|
if (g.closed && g.pts.length > 2) fillPoly(pts);
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) seg(pts[i], pts[i + 1]);
|
||||||
|
if (g.closed && g.pts.length > 2) seg(pts[pts.length - 1], pts[0]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "dimension": {
|
||||||
|
// SIA 400 B.5.3, Figur 14 (Reihenbemassung): jedes aufeinanderfolgende
|
||||||
|
// Punktpaar der Kette ist ein eigenes Mass-Segment, alle teilen `offset`.
|
||||||
|
for (let i = 0; i < g.pts.length - 1; i++) {
|
||||||
|
addDimensionSegment(out, g.pts[i], g.pts[i + 1], g.offset, weightMm, dash, color, greyed, d.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "kote": {
|
||||||
|
// SIA 400 B.5.4/Figur 18: OK = Spitze nach unten, UK = Spitze nach oben;
|
||||||
|
// fertig = Kontur (hohl), roh = voll gefüllt. `at` ist die Spitze.
|
||||||
|
const up = g.kind === "okFertig" || g.kind === "okRoh" ? 1 : -1;
|
||||||
|
const filled = g.kind === "okRoh" || g.kind === "ukRoh";
|
||||||
|
const pts: Vec2[] = [
|
||||||
|
g.at,
|
||||||
|
{ x: g.at.x - KOTE_TRI_HALF_W_M, y: g.at.y + up * KOTE_TRI_SIZE_M },
|
||||||
|
{ x: g.at.x + KOTE_TRI_HALF_W_M, y: g.at.y + up * KOTE_TRI_SIZE_M },
|
||||||
|
];
|
||||||
|
out.push({
|
||||||
|
kind: "polygon",
|
||||||
|
pts,
|
||||||
|
fill: filled ? color : "none",
|
||||||
|
stroke: color,
|
||||||
|
strokeWidthMm: weightMm,
|
||||||
|
hatch: NO_HATCH,
|
||||||
|
greyed,
|
||||||
|
drawingId: d.id,
|
||||||
|
});
|
||||||
|
out.push({
|
||||||
|
kind: "drawingText",
|
||||||
|
at: { x: g.at.x + KOTE_TRI_HALF_W_M + KOTE_TEXT_GAP_M, y: g.at.y + up * KOTE_TRI_SIZE_M * 0.5 },
|
||||||
|
text: formatKoteLabel(g.value),
|
||||||
|
heightM: KOTE_TEXT_HEIGHT_M,
|
||||||
|
angle: 0,
|
||||||
|
color,
|
||||||
|
greyed,
|
||||||
|
drawingId: d.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "text":
|
case "text":
|
||||||
out.push({
|
out.push({
|
||||||
kind: "drawingText",
|
kind: "drawingText",
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* `resolveForeground`/`resolveBackground`/`resolveHatchId`/`resolveStrokeWeight`
|
||||||
|
* — By-Layer/By-Object-Auflösung. Der Resolver selbst bleibt „Nach Bauteil"
|
||||||
|
* (Component) als Default, wenn `source` fehlt/`"object"` ist — das liefert
|
||||||
|
* bei Wand/Decke die neutrale SIA-Poché-Konvention (weisser Grund, schwarzer
|
||||||
|
* Vordergrund), NICHT die rohe Ebenenfarbe (Nutzer-Bestätigung: „eine Wand
|
||||||
|
* usw soll weiterhin nach Bauteil haben"). Ein Drawing2D hat kein eigenes
|
||||||
|
* Bauteil und bietet „Nach Bauteil" im Attribut-Panel gar nicht an — DORT
|
||||||
|
* mappt der Aufrufer (`selectionInfo.ts`) ein fehlendes Source-Feld explizit
|
||||||
|
* auf `"layer"`, bevor er diese Resolver aufruft (separat unten geprüft).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveForeground,
|
||||||
|
resolveBackground,
|
||||||
|
resolveHatchId,
|
||||||
|
resolveStrokeWeight,
|
||||||
|
} from "./shared";
|
||||||
|
import type { Component, LayerCategory } from "../../model/types";
|
||||||
|
|
||||||
|
const comp: Component = {
|
||||||
|
id: "c1",
|
||||||
|
name: "Beton",
|
||||||
|
color: "#888888",
|
||||||
|
foreground: "#111111",
|
||||||
|
background: "#222222",
|
||||||
|
hatchId: "sia-concrete",
|
||||||
|
joinPriority: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const category: LayerCategory = {
|
||||||
|
code: "20",
|
||||||
|
name: "Wände",
|
||||||
|
color: "#0a0a0a",
|
||||||
|
lw: 0.5,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
hatch: "sia-wall",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("resolveForeground/resolveBackground — Default bleibt 'Nach Bauteil'", () => {
|
||||||
|
it("kein Override, Source fehlt: liefert weiterhin die Bauteilfarbe (Default unverändert)", () => {
|
||||||
|
expect(resolveForeground(comp, undefined, category, undefined)).toBe(comp.foreground);
|
||||||
|
expect(resolveBackground(comp, undefined, category, undefined)).toBe(comp.background);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kein Override, Source explizit 'object': unverändert die Bauteilfarbe", () => {
|
||||||
|
expect(resolveForeground(comp, undefined, category, "object")).toBe(comp.foreground);
|
||||||
|
expect(resolveBackground(comp, undefined, category, "object")).toBe(comp.background);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kein Override, Source explizit 'layer': liefert die Kategorie-Farbe", () => {
|
||||||
|
expect(resolveForeground(comp, undefined, category, "layer")).toBe(category.color);
|
||||||
|
expect(resolveBackground(comp, undefined, category, "layer")).toBe(category.color);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ein gesetzter Override gewinnt immer, unabhängig von der Source", () => {
|
||||||
|
expect(resolveForeground(comp, "#ff00ff", category, "layer")).toBe("#ff00ff");
|
||||||
|
expect(resolveForeground(comp, "#ff00ff", category, undefined)).toBe("#ff00ff");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveHatchId — Default bleibt 'Nach Bauteil'", () => {
|
||||||
|
it("kein Override, Source fehlt: liefert den Fallback (Bauteil-Schraffur)", () => {
|
||||||
|
expect(resolveHatchId(undefined, undefined, category, comp.hatchId)).toBe(comp.hatchId);
|
||||||
|
});
|
||||||
|
it("kein Override, Source explizit 'layer': liefert die Kategorie-Schraffur", () => {
|
||||||
|
expect(resolveHatchId(undefined, "layer", category, comp.hatchId)).toBe(category.hatch);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveStrokeWeight — Default bleibt 'Nach Bauteil'", () => {
|
||||||
|
it("kein Override, Source fehlt: liefert den Fallback", () => {
|
||||||
|
expect(resolveStrokeWeight(undefined, undefined, category, 0.13)).toBe(0.13);
|
||||||
|
});
|
||||||
|
it("kein Override, Source explizit 'layer': liefert die Kategorie-Strichstärke", () => {
|
||||||
|
expect(resolveStrokeWeight(undefined, "layer", category, 0.13)).toBe(category.lw);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Drawing2D-Panel-Vorschau (selectionInfo.ts) — mappt fehlende Source explizit auf 'layer'", () => {
|
||||||
|
it("simuliert das Aufrufer-Mapping `d.foregroundSource ?? \"layer\"`: liefert die Kategorie-Farbe für ein Drawing2D ohne Bauteil", () => {
|
||||||
|
const noComponent: Component = { id: "", name: "", color: "#000", hatchId: "", joinPriority: 0 };
|
||||||
|
const drawingSource: "layer" | undefined = undefined; // wie ein frisches Drawing2D
|
||||||
|
expect(resolveForeground(noComponent, undefined, category, drawingSource ?? "layer")).toBe(
|
||||||
|
category.color,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -369,20 +369,20 @@ export type Primitive =
|
|||||||
* {@link HatchRender} auf. Der Linienstil der Musterlinien wird, falls gesetzt,
|
* {@link HatchRender} auf. Der Linienstil der Musterlinien wird, falls gesetzt,
|
||||||
* aus dem Line Manager nachgeschlagen; sonst gilt ein dünner Default.
|
* aus dem Line Manager nachgeschlagen; sonst gilt ein dünner Default.
|
||||||
*
|
*
|
||||||
* VORDERGRUND (Muster-/Schraffurlinienfarbe bzw. Vollfüllung): AKTUELL fest auf
|
* VORDERGRUND (Muster-/Schraffurlinienfarbe bzw. Vollfüllung): der über
|
||||||
* {@link HATCH_INK} gezwungen (Nutzer-Vorgabe 2026-07-04) — Schnitt- WIE
|
* {@link resolveForeground} aufgelöste Vordergrund gewinnt, wenn gesetzt (Nach
|
||||||
* Oberflächen-/Ansichtsschraffur zeigen dieselbe dunkle Tinte, unabhängig von
|
* Ebene/Nach Bauteil/eigener Wert); OHNE aufgelösten Wert bleibt es bei der
|
||||||
* Material-/Bauteilfarbe, HatchStyle.color oder einem By-Layer/By-Object-
|
* neutralen Tinte {@link HATCH_INK} (bisheriges Verhalten — 2026-07-04 war die
|
||||||
* Vordergrund-Override. `_foreground` bleibt Teil der Signatur — alle Aufrufer
|
* Vordergrund-Kette hier noch fest auf HATCH_INK gezwungen, unabhängig vom
|
||||||
* reichen weiterhin den über {@link resolveForeground} aufgelösten Bauteil-
|
* Ergebnis; auf Nutzer-Wunsch wieder aufgehoben, da „Vordergrund" im Attribut-
|
||||||
* Vordergrund durch (z. B. für eine spätere Wiederherstellung der materialtreuen
|
* Panel sonst wirkungslos bliebe — betrifft nur Elemente mit tatsächlich
|
||||||
* Schraffur) —, fließt aber bewusst nicht mehr in `color` ein.
|
* gesetztem Vordergrund, unverändertes Verhalten für alle anderen).
|
||||||
*/
|
*/
|
||||||
export function resolveHatch(
|
export function resolveHatch(
|
||||||
project: Project,
|
project: Project,
|
||||||
hatchId: string,
|
hatchId: string,
|
||||||
wallAngleDeg?: number,
|
wallAngleDeg?: number,
|
||||||
_foreground?: string,
|
foreground?: string,
|
||||||
): HatchRender {
|
): HatchRender {
|
||||||
const h = getHatch(project, hatchId);
|
const h = getHatch(project, hatchId);
|
||||||
const ls = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null;
|
const ls = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null;
|
||||||
@@ -399,9 +399,8 @@ export function resolveHatch(
|
|||||||
pattern: h.pattern,
|
pattern: h.pattern,
|
||||||
scale: h.scale,
|
scale: h.scale,
|
||||||
angle,
|
angle,
|
||||||
// Fest auf die Monochrom-Tinte gezwungen (siehe JSDoc oben) — weder
|
// Aufgelöster Vordergrund gewinnt (s. JSDoc oben), sonst neutrale Tinte.
|
||||||
// `_foreground` noch `HatchStyle.color` (Material-/Bauteilfarbe) fließen ein.
|
color: foreground ?? HATCH_INK,
|
||||||
color: HATCH_INK,
|
|
||||||
lineWeight: ls?.weight ?? 0.13,
|
lineWeight: ls?.weight ?? 0.13,
|
||||||
dash: ls?.dash ?? null,
|
dash: ls?.dash ?? null,
|
||||||
kind: h.kind,
|
kind: h.kind,
|
||||||
@@ -421,10 +420,13 @@ export function resolveHatch(
|
|||||||
* (By-Layer/By-Object, siehe `docs`/Attribut-Panel):
|
* (By-Layer/By-Object, siehe `docs`/Attribut-Panel):
|
||||||
* (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.color`) ??
|
* (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.color`) ??
|
||||||
* (`Component.foreground`) ?? `undefined`.
|
* (`Component.foreground`) ?? `undefined`.
|
||||||
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert).
|
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" — bei WAND/DECKE bewusst der
|
||||||
* `undefined` als Endergebnis heißt „Nach System" — die Kette läuft dann in
|
* DEFAULT (die Bauteil-Kette liefert die neutrale SIA-Poché, s.
|
||||||
* resolveHatch auf den HatchStyle.color-Backward-Compat-Fallback weiter
|
* {@link resolveBackground}). Ein Drawing2D hat KEIN eigenes Bauteil und
|
||||||
* (Sample-Verhalten: kein Element setzt `override`/`source`, also identisch).
|
* bietet „Nach Bauteil" im Attribut-Panel gar nicht erst an — dort wird ein
|
||||||
|
* fehlendes Source-Feld vom AUFRUFER (`selectionInfo.ts`) explizit auf
|
||||||
|
* `"layer"` gemappt, bevor er hier ankommt (dieser Resolver selbst bleibt
|
||||||
|
* Bauteil-Default, unabhängig von der Elementart).
|
||||||
*/
|
*/
|
||||||
export function resolveForeground(
|
export function resolveForeground(
|
||||||
comp: Component,
|
comp: Component,
|
||||||
@@ -446,7 +448,9 @@ export function resolveForeground(
|
|||||||
* dann auf ihren eigenen Default zurück (bei Wand/Decke die neutrale SIA-
|
* dann auf ihren eigenen Default zurück (bei Wand/Decke die neutrale SIA-
|
||||||
* Poché-Farbe HATCH_INK/HATCH_PAPER, NICHT `Component.color` — die reale
|
* Poché-Farbe HATCH_INK/HATCH_PAPER, NICHT `Component.color` — die reale
|
||||||
* Bauteilfarbe soll die Schnitt-Konvention nicht überschreiben, nur ein
|
* Bauteilfarbe soll die Schnitt-Konvention nicht überschreiben, nur ein
|
||||||
* bewusster Override).
|
* bewusster Override). Genau DIESE neutrale Poché ist bei Wand/Decke der
|
||||||
|
* gewünschte Default („weisser Grund, schwarzer Vordergrund") — deshalb
|
||||||
|
* bleibt „Nach Bauteil" dort der Default, nicht „Nach Ebene".
|
||||||
*/
|
*/
|
||||||
export function resolveBackground(
|
export function resolveBackground(
|
||||||
comp: Component,
|
comp: Component,
|
||||||
@@ -466,7 +470,8 @@ export function resolveBackground(
|
|||||||
* (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.hatch`) ??
|
* (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.hatch`) ??
|
||||||
* `fallback` (Bauteil-Schraffur, z. B. `Component.hatchId`/`viewHatchId`, oder
|
* `fallback` (Bauteil-Schraffur, z. B. `Component.hatchId`/`viewHatchId`, oder
|
||||||
* `undefined` bei Drawing2D ohne Bauteil-Bezug).
|
* `undefined` bei Drawing2D ohne Bauteil-Bezug).
|
||||||
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert).
|
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" (DEFAULT bei Wand/Decke, s.
|
||||||
|
* {@link resolveForeground}).
|
||||||
* Ergebnis kann `undefined` sein — der Aufrufer fällt dann auf `NO_HATCH` zurück.
|
* Ergebnis kann `undefined` sein — der Aufrufer fällt dann auf `NO_HATCH` zurück.
|
||||||
*/
|
*/
|
||||||
export function resolveHatchId(
|
export function resolveHatchId(
|
||||||
@@ -486,7 +491,8 @@ export function resolveHatchId(
|
|||||||
* (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.lw`) ??
|
* (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.lw`) ??
|
||||||
* `fallback` (bisherige Bauteil-/LineStyle-/Kategorie-Default-Kette des
|
* `fallback` (bisherige Bauteil-/LineStyle-/Kategorie-Default-Kette des
|
||||||
* Aufrufers — heutiges Verhalten).
|
* Aufrufers — heutiges Verhalten).
|
||||||
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert).
|
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" (DEFAULT bei Wand/Decke, s.
|
||||||
|
* {@link resolveForeground}).
|
||||||
*/
|
*/
|
||||||
export function resolveStrokeWeight(
|
export function resolveStrokeWeight(
|
||||||
override: number | undefined,
|
override: number | undefined,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* `addColumnPoche` — By-Layer/By-Object-Override für Stützen (Nutzer-Wunsch:
|
||||||
|
* "generell sollten alle BIM Teile ... die weiteren Attribute haben" — Stütze
|
||||||
|
* hat bereits eine Poché UND nutzt dieselben Resolver wie Wand/Decke, daher
|
||||||
|
* hier zuerst fertig verdrahtet). Regressionsschutz: die neuen
|
||||||
|
* foreground/background/hatchId/strokeWeight(+Source)-Felder auf `Column`
|
||||||
|
* müssen tatsächlich bis in die gerenderte Poché durchschlagen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { addColumnPoche } from "./structures";
|
||||||
|
import type { Column, Component, LayerCategory, Project } from "../../model/types";
|
||||||
|
import type { Primitive } from "./shared";
|
||||||
|
|
||||||
|
const comp: Component = {
|
||||||
|
id: "beton",
|
||||||
|
name: "Beton",
|
||||||
|
color: "#888888",
|
||||||
|
hatchId: "sia-concrete",
|
||||||
|
joinPriority: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const category: LayerCategory = {
|
||||||
|
code: "50",
|
||||||
|
name: "Tragwerk",
|
||||||
|
color: "#333333",
|
||||||
|
lw: 0.35,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function project(overrides: Partial<Project> = {}): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [
|
||||||
|
{ id: "sia-concrete", name: "Beton", pattern: "solid" },
|
||||||
|
{ id: "sia-other", name: "Sonstiges", pattern: "none" },
|
||||||
|
] as unknown as Project["hatches"],
|
||||||
|
components: [comp],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [],
|
||||||
|
layers: [category],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
columns: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function column(overrides: Partial<Column> = {}): Column {
|
||||||
|
return {
|
||||||
|
id: "col1",
|
||||||
|
type: "column",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "50",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
profile: { kind: "rect", width: 0.3, depth: 0.3 },
|
||||||
|
rotation: 0,
|
||||||
|
height: 2.6,
|
||||||
|
componentId: "beton",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonOf(out: Primitive[]): Extract<Primitive, { kind: "polygon" }> {
|
||||||
|
const p = out.find((x) => x.kind === "polygon");
|
||||||
|
if (!p || p.kind !== "polygon") throw new Error("kein Poché-Polygon gefunden");
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("addColumnPoche — Attribut-Override greift", () => {
|
||||||
|
it("ohne Override: Bauteil-Schraffur/-Farbe bestimmen die Poché (Default 'Nach Bauteil')", () => {
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addColumnPoche(out, project(), column(), false, 0.35, category);
|
||||||
|
const poly = polygonOf(out);
|
||||||
|
expect(poly.hatch.pattern).toBe("solid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mit explizitem background-Override: die Poché-Füllung wird die Override-Farbe", () => {
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addColumnPoche(out, project(), column({ background: "#ff00ff" }), false, 0.35, category);
|
||||||
|
expect(polygonOf(out).fill).toBe("#ff00ff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mit hatchSource:'layer': die Kategorie-Schraffur wird verwendet, nicht die Bauteil-Schraffur", () => {
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
const catWithHatch: LayerCategory = { ...category, hatch: "sia-other" };
|
||||||
|
addColumnPoche(
|
||||||
|
out,
|
||||||
|
project({ layers: [catWithHatch] }),
|
||||||
|
column({ hatchSource: "layer" }),
|
||||||
|
false,
|
||||||
|
0.35,
|
||||||
|
catWithHatch,
|
||||||
|
);
|
||||||
|
expect(polygonOf(out).hatch.pattern).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mit explizitem hatchId-Override: gewinnt gegenüber Bauteil UND Kategorie", () => {
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addColumnPoche(
|
||||||
|
out,
|
||||||
|
project(),
|
||||||
|
column({ hatchId: "sia-other" }),
|
||||||
|
false,
|
||||||
|
0.35,
|
||||||
|
category,
|
||||||
|
);
|
||||||
|
expect(polygonOf(out).hatch.pattern).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ohne gesetzten Vordergrund: Schraffurfarbe bleibt die neutrale Tinte (Default unverändert)", () => {
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addColumnPoche(out, project(), column(), false, 0.35, category);
|
||||||
|
expect(polygonOf(out).hatch.color).toBe("#0f0f0f");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mit explizitem foreground-Override: die Schraffurfarbe wird die Override-Farbe (vorher wirkungslos)", () => {
|
||||||
|
const out: Primitive[] = [];
|
||||||
|
addColumnPoche(out, project(), column({ foreground: "#00ff88" }), false, 0.35, category);
|
||||||
|
expect(polygonOf(out).hatch.color).toBe("#00ff88");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
MONO_INK,
|
MONO_INK,
|
||||||
NO_HATCH,
|
NO_HATCH,
|
||||||
SYMBOL_HAIRLINE_MM,
|
SYMBOL_HAIRLINE_MM,
|
||||||
|
resolveBackground,
|
||||||
resolveForeground,
|
resolveForeground,
|
||||||
resolveHatch,
|
resolveHatch,
|
||||||
resolveHatchId,
|
resolveHatchId,
|
||||||
@@ -67,15 +68,19 @@ export function addColumnPoche(
|
|||||||
try {
|
try {
|
||||||
const comp = getComponent(project, column.componentId);
|
const comp = getComponent(project, column.componentId);
|
||||||
const hatchId =
|
const hatchId =
|
||||||
resolveHatchId(undefined, undefined, category, comp.hatchId) ?? comp.hatchId;
|
resolveHatchId(column.hatchId, column.hatchSource, category, comp.hatchId) ?? comp.hatchId;
|
||||||
const resolved = resolveHatch(
|
const resolved = resolveHatch(
|
||||||
project,
|
project,
|
||||||
hatchId,
|
hatchId,
|
||||||
undefined,
|
undefined,
|
||||||
resolveForeground(comp, undefined, category, undefined),
|
resolveForeground(comp, column.foreground, category, column.foregroundSource),
|
||||||
);
|
);
|
||||||
hatch = resolved;
|
hatch = resolved;
|
||||||
fill = resolved.pattern === "solid" ? HATCH_INK : HATCH_PAPER;
|
// SIA-Poché: neutraler Hintergrund (Tinte/Papier je nach Muster) statt
|
||||||
|
// Bauteil-Albedo, es sei denn ein expliziter Hintergrund-Override greift
|
||||||
|
// (analog addWallPoche: bgOverride ?? neutrale Poché).
|
||||||
|
const bgOverride = resolveBackground(comp, column.background, category, column.backgroundSource);
|
||||||
|
fill = bgOverride ?? (resolved.pattern === "solid" ? HATCH_INK : HATCH_PAPER);
|
||||||
} catch {
|
} catch {
|
||||||
// Bauteil nicht auflösbar → neutrale Poché (Fallback bleibt bestehen).
|
// Bauteil nicht auflösbar → neutrale Poché (Fallback bleibt bestehen).
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,24 +222,56 @@ export function marqueeHit(
|
|||||||
rect: ModelRect,
|
rect: ModelRect,
|
||||||
crossing: boolean,
|
crossing: boolean,
|
||||||
): string[] {
|
): string[] {
|
||||||
// Polygone je Wand sammeln (nur Primitive mit Wandbezug zählen für die Auswahl).
|
return marqueeHitByField(prims, "wallId", rect, crossing);
|
||||||
const byWall = new Map<string, Vec2[][]>();
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generische Variante von {@link marqueeHit} für ein beliebiges Polygon-ID-
|
||||||
|
* Feld (`ceilingId`/`roomId`/`stairId`/`columnId`/`roofId`/…) — dieselbe
|
||||||
|
* window/crossing-Konvention, nur auf ein anderes Element bezogen. Deckt
|
||||||
|
* Nutzer-Report: „Auswahlrahmen kann Decken/Räume/Treppen nicht anwählen"
|
||||||
|
* (bisher nur Wände über `marqueeHit`, alles andere fehlte komplett).
|
||||||
|
*/
|
||||||
|
function marqueeHitByField(
|
||||||
|
prims: Primitive[],
|
||||||
|
field: "wallId" | "ceilingId" | "roomId" | "stairId" | "columnId" | "roofId",
|
||||||
|
rect: ModelRect,
|
||||||
|
crossing: boolean,
|
||||||
|
): string[] {
|
||||||
|
const byId = new Map<string, Vec2[][]>();
|
||||||
for (const p of prims) {
|
for (const p of prims) {
|
||||||
if (p.kind !== "polygon" || p.wallId == null) continue;
|
if (p.kind !== "polygon") continue;
|
||||||
const list = byWall.get(p.wallId);
|
const id = p[field];
|
||||||
|
if (id == null) continue;
|
||||||
|
const list = byId.get(id);
|
||||||
if (list) list.push(p.pts);
|
if (list) list.push(p.pts);
|
||||||
else byWall.set(p.wallId, [p.pts]);
|
else byId.set(id, [p.pts]);
|
||||||
}
|
}
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const [wallId, polys] of byWall) {
|
for (const [id, polys] of byId) {
|
||||||
const hit = crossing
|
const hit = crossing
|
||||||
? polys.some((poly) => polyTouchesRect(poly, rect))
|
? polys.some((poly) => polyTouchesRect(poly, rect))
|
||||||
: polys.every((poly) => polyEnclosedByRect(poly, rect));
|
: polys.every((poly) => polyEnclosedByRect(poly, rect));
|
||||||
if (hit) out.push(wallId);
|
if (hit) out.push(id);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vom Auswahl-Rechteck getroffene Decken-IDs (gleiche Konvention wie {@link marqueeHit}). */
|
||||||
|
export function marqueeHitCeilings(prims: Primitive[], rect: ModelRect, crossing: boolean): string[] {
|
||||||
|
return marqueeHitByField(prims, "ceilingId", rect, crossing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vom Auswahl-Rechteck getroffene Raum-IDs (gleiche Konvention wie {@link marqueeHit}). */
|
||||||
|
export function marqueeHitRooms(prims: Primitive[], rect: ModelRect, crossing: boolean): string[] {
|
||||||
|
return marqueeHitByField(prims, "roomId", rect, crossing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vom Auswahl-Rechteck getroffene Treppen-IDs (gleiche Konvention wie {@link marqueeHit}). */
|
||||||
|
export function marqueeHitStairs(prims: Primitive[], rect: ModelRect, crossing: boolean): string[] {
|
||||||
|
return marqueeHitByField(prims, "stairId", rect, crossing);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vom Auswahl-Rechteck getroffene 2D-Zeichenelement-IDs (gleiche window/crossing-
|
* Vom Auswahl-Rechteck getroffene 2D-Zeichenelement-IDs (gleiche window/crossing-
|
||||||
* Konvention wie {@link marqueeHit}). Sammelt je Drawing2D ALLE seine Punkte aus
|
* Konvention wie {@link marqueeHit}). Sammelt je Drawing2D ALLE seine Punkte aus
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* `marqueeHitCeilings`/`marqueeHitRooms`/`marqueeHitStairs` — Nutzer-Report:
|
||||||
|
* "Auswahlrahmen kann nicht alle Elemente anwählen, z.B. Decken, Räume und
|
||||||
|
* Treppen nicht". Vorher las das Auswahl-Rechteck nur `wallId`/`drawingId`
|
||||||
|
* aus den Polygon-Primitiven; die anderen ID-Felder (die dieselben
|
||||||
|
* Primitive längst tragen) wurden ignoriert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { marqueeHit, marqueeHitCeilings, marqueeHitRooms, marqueeHitStairs } from "./geometry";
|
||||||
|
import type { Primitive } from "../generatePlan";
|
||||||
|
|
||||||
|
const NO_HATCH = { pattern: "none" as const, color: "#000" };
|
||||||
|
|
||||||
|
function poly(id: Partial<Record<"wallId" | "ceilingId" | "roomId" | "stairId", string>>, pts: { x: number; y: number }[]): Primitive {
|
||||||
|
return {
|
||||||
|
kind: "polygon",
|
||||||
|
pts,
|
||||||
|
fill: "none",
|
||||||
|
stroke: "#000",
|
||||||
|
strokeWidthMm: 0.18,
|
||||||
|
hatch: NO_HATCH,
|
||||||
|
...id,
|
||||||
|
} as Primitive;
|
||||||
|
}
|
||||||
|
|
||||||
|
const square = (x0: number, y0: number, x1: number, y1: number) => [
|
||||||
|
{ x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("Marquee — Decken/Räume/Treppen", () => {
|
||||||
|
it("marqueeHitCeilings findet eine vollständig eingeschlossene Decke (window)", () => {
|
||||||
|
const prims = [poly({ ceilingId: "c1" }, square(1, 1, 3, 3))];
|
||||||
|
const rect = { minX: 0, minY: 0, maxX: 5, maxY: 5 };
|
||||||
|
expect(marqueeHitCeilings(prims, rect, false)).toEqual(["c1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marqueeHitRooms ignoriert einen Raum, der nur teilweise im Fenster liegt (window, kein crossing)", () => {
|
||||||
|
const prims = [poly({ roomId: "r1" }, square(1, 1, 10, 10))];
|
||||||
|
const rect = { minX: 0, minY: 0, maxX: 5, maxY: 5 };
|
||||||
|
expect(marqueeHitRooms(prims, rect, false)).toEqual([]);
|
||||||
|
// Dieselbe Geometrie im Crossing-Modus (rechts→links) trifft trotzdem.
|
||||||
|
expect(marqueeHitRooms(prims, rect, true)).toEqual(["r1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marqueeHitStairs findet eine Treppe über mehrere Tritt-Polygone (alle müssen im Fenster liegen)", () => {
|
||||||
|
const prims = [
|
||||||
|
poly({ stairId: "s1" }, square(1, 1, 2, 2)),
|
||||||
|
poly({ stairId: "s1" }, square(2, 1, 3, 2)),
|
||||||
|
];
|
||||||
|
const rect = { minX: 0, minY: 0, maxX: 5, maxY: 5 };
|
||||||
|
expect(marqueeHitStairs(prims, rect, false)).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Wände (marqueeHit) und Decken/Räume/Treppen sind unabhängig voneinander auswertbar", () => {
|
||||||
|
const prims = [
|
||||||
|
poly({ wallId: "w1" }, square(0, 0, 1, 1)),
|
||||||
|
poly({ ceilingId: "c1" }, square(1, 1, 2, 2)),
|
||||||
|
poly({ roomId: "r1" }, square(2, 2, 3, 3)),
|
||||||
|
poly({ stairId: "s1" }, square(3, 3, 4, 4)),
|
||||||
|
];
|
||||||
|
const rect = { minX: 0, minY: 0, maxX: 5, maxY: 5 };
|
||||||
|
expect(marqueeHit(prims, rect, false)).toEqual(["w1"]);
|
||||||
|
expect(marqueeHitCeilings(prims, rect, false)).toEqual(["c1"]);
|
||||||
|
expect(marqueeHitRooms(prims, rect, false)).toEqual(["r1"]);
|
||||||
|
expect(marqueeHitStairs(prims, rect, false)).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -356,6 +356,17 @@ function SnapMarker({
|
|||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Teilpunkt (nur fangbar nach Verweilen, s. computeSnap-Dwell) — kleine
|
||||||
|
gefüllte Raute, bewusst anders als Mittelpunkt (Linie) und Raster
|
||||||
|
(Kreis), damit „warum snappt es hier" nachvollziehbar bleibt. */}
|
||||||
|
{snap.kind === "divide" && (
|
||||||
|
<polygon
|
||||||
|
className="snap-glyph snap-fill"
|
||||||
|
points={`${s.x},${s.y - r} ${s.x + r},${s.y} ${s.x},${s.y + r} ${s.x - r},${s.y}`}
|
||||||
|
style={fillStyle}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{snap.kind === "intersection" && (
|
{snap.kind === "intersection" && (
|
||||||
<>
|
<>
|
||||||
<line className="snap-glyph" x1={s.x - r} y1={s.y - r} x2={s.x + r} y2={s.y + r} style={glyphStyle} vectorEffect="non-scaling-stroke" />
|
<line className="snap-glyph" x1={s.x - r} y1={s.y - r} x2={s.x + r} y2={s.y + r} style={glyphStyle} vectorEffect="non-scaling-stroke" />
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* `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 {
|
||||||
|
addNewElementsToActiveGroup,
|
||||||
|
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("addNewElementsToActiveGroup", () => {
|
||||||
|
const g1: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
|
||||||
|
const line = (id: string) =>
|
||||||
|
({ id, type: "drawing2d", levelId: "eg", categoryCode: "20", geom: { shape: "line", a: { x: 0, y: 0 }, b: { x: 1, y: 0 } } }) as Project["drawings2d"][number];
|
||||||
|
|
||||||
|
it("neu gezeichnetes Element wird Mitglied der aktiven (innersten) Gruppe", () => {
|
||||||
|
const before = project([g1]);
|
||||||
|
const after: Project = { ...before, drawings2d: [line("d1")] };
|
||||||
|
const result = addNewElementsToActiveGroup(before, after, ["g1"]);
|
||||||
|
const g = result.groups!.find((x) => x.id === "g1")!;
|
||||||
|
expect(g.drawingIds).toEqual(["d1"]);
|
||||||
|
expect(g.wallIds).toEqual(["w1"]); // bestehende Mitgliedschaft bleibt
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ohne aktive Gruppe (groupPath leer) bleibt das Projekt unverändert", () => {
|
||||||
|
const before = project([g1]);
|
||||||
|
const after: Project = { ...before, drawings2d: [line("d1")] };
|
||||||
|
const result = addNewElementsToActiveGroup(before, after, []);
|
||||||
|
expect(result).toBe(after); // unverändert (Referenzgleichheit)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keine Änderung (before === after) ist ein No-op", () => {
|
||||||
|
const p = project([g1]);
|
||||||
|
expect(addNewElementsToActiveGroup(p, p, ["g1"])).toBe(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mehrere neue Elemente auf einmal (z. B. Feld/Array) werden alle Mitglied", () => {
|
||||||
|
const before = project([g1]);
|
||||||
|
const after: Project = { ...before, drawings2d: [line("d1"), line("d2"), line("d3")] };
|
||||||
|
const result = addNewElementsToActiveGroup(before, after, ["g1"]);
|
||||||
|
const g = result.groups!.find((x) => x.id === "g1")!;
|
||||||
|
expect(g.drawingIds.sort()).toEqual(["d1", "d2", "d3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verschachtelt: neues Element landet in der INNERSTEN (aktiven) Gruppe, nicht der äusseren", () => {
|
||||||
|
const inner: Group = { id: "g1", wallIds: [], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
|
||||||
|
const outer: Group = { id: "g2", wallIds: [], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: ["g1"] };
|
||||||
|
const before = project([inner, outer]);
|
||||||
|
const after: Project = { ...before, drawings2d: [line("d1")] };
|
||||||
|
const result = addNewElementsToActiveGroup(before, after, ["g2", "g1"]);
|
||||||
|
expect(result.groups!.find((x) => x.id === "g1")!.drawingIds).toEqual(["d1"]);
|
||||||
|
expect(result.groups!.find((x) => x.id === "g2")!.drawingIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ungroup", () => {
|
||||||
|
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,328 @@
|
|||||||
|
// 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IDs, die in `after` neu hinzugekommen sind (in `before` noch nicht vorhanden). */
|
||||||
|
function newIds(before: { id: string }[], after: { id: string }[]): string[] {
|
||||||
|
const beforeIds = new Set(before.map((x) => x.id));
|
||||||
|
return after.filter((x) => !beforeIds.has(x.id)).map((x) => x.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet man WÄHREND man eine Gruppe betreten hat (Isolation) ein neues
|
||||||
|
* Element, wird es automatisch Mitglied dieser (innersten) Gruppe — man
|
||||||
|
* zeichnet ja SICHTLICH „in" ihr (Nutzer-Wunsch, Vectorworks-Verhalten).
|
||||||
|
* Vergleicht `before`/`after` je Elementart auf neu hinzugekommene IDs (Diff
|
||||||
|
* über die commit-Mutation) und hängt sie an die aktive Gruppe an. `before`
|
||||||
|
* === `after` (keine Änderung) oder `groupPath` leer → `after` unverändert.
|
||||||
|
*/
|
||||||
|
export function addNewElementsToActiveGroup(
|
||||||
|
before: Project,
|
||||||
|
after: Project,
|
||||||
|
groupPath: string[],
|
||||||
|
): Project {
|
||||||
|
if (groupPath.length === 0 || before === after) return after;
|
||||||
|
const activeId = groupPath[groupPath.length - 1];
|
||||||
|
const g = (after.groups ?? []).find((x) => x.id === activeId);
|
||||||
|
if (!g) return after;
|
||||||
|
const added: GroupMembership = {
|
||||||
|
wallIds: newIds(before.walls, after.walls),
|
||||||
|
drawingIds: newIds(before.drawings2d, after.drawings2d),
|
||||||
|
ceilingIds: newIds(before.ceilings ?? [], after.ceilings ?? []),
|
||||||
|
roomIds: newIds(before.rooms ?? [], after.rooms ?? []),
|
||||||
|
stairIds: newIds(before.stairs ?? [], after.stairs ?? []),
|
||||||
|
columnIds: newIds(before.columns ?? [], after.columns ?? []),
|
||||||
|
roofIds: newIds(before.roofs ?? [], after.roofs ?? []),
|
||||||
|
openingIds: newIds(before.openings ?? [], after.openings ?? []),
|
||||||
|
};
|
||||||
|
if (KEYS.every((k) => added[k].length === 0)) return after;
|
||||||
|
const merged: Group = { ...g, ...mergeMembership(g, added) };
|
||||||
|
return {
|
||||||
|
...after,
|
||||||
|
groups: (after.groups ?? []).map((x) => (x.id === activeId ? merged : x)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine neue Gruppe aus einer Mengen-Auswahl (+ optional bereits
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -40,9 +40,9 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("drawingVertices — Kreis/Bogen", () => {
|
describe("drawingVertices — Kreis/Bogen", () => {
|
||||||
it("Kreis liefert einen Radius-Griff auf der Ost-Seite", () => {
|
it("Kreis liefert zwei Griffe (Ost/Nord) — wie eine Ellipse", () => {
|
||||||
const v = drawingVertices(circleDrawing);
|
const v = drawingVertices(circleDrawing);
|
||||||
expect(v).toEqual([{ x: 3, y: 3 }]);
|
expect(v).toEqual([{ x: 3, y: 3 }, { x: 2, y: 4 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Bogen liefert Start-/End-/Mittel-Winkel-Griffe", () => {
|
it("Bogen liefert Start-/End-/Mittel-Winkel-Griffe", () => {
|
||||||
@@ -55,8 +55,21 @@ describe("drawingVertices — Kreis/Bogen", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("moveGripOf — Kreis/Bogen", () => {
|
describe("moveGripOf — Kreis/Bogen", () => {
|
||||||
it("Kreis-Griff ziehen ändert nur den Radius, nicht das Zentrum", () => {
|
it("Kreis-Ost-Griff auf denselben Radius gezogen bleibt ein Kreis", () => {
|
||||||
getState().moveGripOf("d-circle", null, 0, { x: 5, y: 3 }); // 3m vom Zentrum
|
getState().moveGripOf("d-circle", null, 0, { x: 3, y: 3 }); // weiterhin 1m Ost
|
||||||
|
const g = getState().project.drawings2d.find((d) => d.id === "d-circle")!.geom;
|
||||||
|
expect(g).toMatchObject({ shape: "circle", center: { x: 2, y: 3 }, r: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Kreis-Ost-Griff auf einen ANDEREN Radius gezogen wird zur Ellipse (Nutzer-Wunsch: Kreis kann beim Bearbeiten zur Ellipse werden)", () => {
|
||||||
|
getState().moveGripOf("d-circle", null, 0, { x: 5, y: 3 }); // 3m Ost statt 1m
|
||||||
|
const g = getState().project.drawings2d.find((d) => d.id === "d-circle")!.geom;
|
||||||
|
expect(g).toMatchObject({ shape: "ellipse", center: { x: 2, y: 3 }, rx: 3, ry: 1, rotation: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ellipse-Griff zurück auf (fast) gleiche Halbachsen gezogen wird wieder zum Kreis", () => {
|
||||||
|
getState().moveGripOf("d-circle", null, 0, { x: 5, y: 3 }); // → Ellipse rx=3, ry=1
|
||||||
|
getState().moveGripOf("d-circle", null, 1, { x: 2, y: 6 }); // Nord-Griff auf ry=3
|
||||||
const g = getState().project.drawings2d.find((d) => d.id === "d-circle")!.geom;
|
const g = getState().project.drawings2d.find((d) => d.id === "d-circle")!.geom;
|
||||||
expect(g).toMatchObject({ shape: "circle", center: { x: 2, y: 3 }, r: 3 });
|
expect(g).toMatchObject({ shape: "circle", center: { x: 2, y: 3 }, r: 3 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Kanten-Griff ziehen (`moveEdgeOf`/`moveRoomEdge` in `projectSlice.ts`) bei
|
||||||
|
* Vielecken mit nicht rechtwinkligen Anschlüssen. Nutzer-Report: die
|
||||||
|
* Nachbarkanten der verschobenen Seite kippten bisher im WINKEL statt sich
|
||||||
|
* nur zu verlängern/verkürzen — der Eckpunkt wurde stur um `delta` versetzt,
|
||||||
|
* statt entlang seiner unveränderten Nachbarkante zu wandern. Fix:
|
||||||
|
* `movePolygonEdge`/`edgeMoveVertex` schneiden die verschobene Kanten-Linie
|
||||||
|
* mit der (unendlich verlängerten) Nachbarkanten-Linie durch den fixen
|
||||||
|
* Nachbarpunkt — Nachbarkante behält ihre Richtung, nur ihre Länge ändert
|
||||||
|
* sich.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { getState, setState } from "./appStore";
|
||||||
|
import { sampleProject } from "../model/sampleProject";
|
||||||
|
import type { Drawing2D, Room } from "../model/types";
|
||||||
|
|
||||||
|
// Fünfeck (Haus-Form): P0(0,0) P1(4,0) P2(4,3) P3(2,5) P4(0,3), geschlossen.
|
||||||
|
const pentagon: Drawing2D = {
|
||||||
|
id: "d-pentagon",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "00",
|
||||||
|
geom: {
|
||||||
|
shape: "polyline",
|
||||||
|
closed: true,
|
||||||
|
pts: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 4, y: 0 },
|
||||||
|
{ x: 4, y: 3 },
|
||||||
|
{ x: 2, y: 5 },
|
||||||
|
{ x: 0, y: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeRoom(over: Partial<Room> = {}): Room {
|
||||||
|
return {
|
||||||
|
id: "r-pentagon",
|
||||||
|
type: "room",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "45",
|
||||||
|
siaCategory: "HNF",
|
||||||
|
name: "Wohnen",
|
||||||
|
boundary: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 4, y: 0 },
|
||||||
|
{ x: 4, y: 3 },
|
||||||
|
{ x: 2, y: 5 },
|
||||||
|
{ x: 0, y: 3 },
|
||||||
|
],
|
||||||
|
color: "#123456",
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setState({
|
||||||
|
project: { ...sampleProject, drawings2d: [pentagon], rooms: [makeRoom()] },
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moveEdgeOf — Nachbarkanten winkeltreu (nicht rechtwinklige Ecke)", () => {
|
||||||
|
it("verschiebt die slanted Kante P2-P3 und erhält die Richtung der Nachbarkanten", () => {
|
||||||
|
// Kante (Index 2,3) = P2(4,3)→P3(2,5) um (1,1) verschieben.
|
||||||
|
getState().moveEdgeOf("d-pentagon", null, 2, 3, { x: 1, y: 1 });
|
||||||
|
const g = getState().project.drawings2d.find((d) => d.id === "d-pentagon")!.geom;
|
||||||
|
expect(g.shape).toBe("polyline");
|
||||||
|
if (g.shape !== "polyline") return;
|
||||||
|
// P0/P1/P4 unverändert (nicht Teil der gezogenen Kante).
|
||||||
|
expect(g.pts[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(g.pts[1]).toEqual({ x: 4, y: 0 });
|
||||||
|
expect(g.pts[4]).toEqual({ x: 0, y: 3 });
|
||||||
|
// P2/P3 wandern entlang ihrer FIXEN Nachbarkanten (P1-P2 senkrecht,
|
||||||
|
// P4-P3 diagonal 45°), nicht stur um (1,1) versetzt.
|
||||||
|
expect(g.pts[2].x).toBeCloseTo(4, 9);
|
||||||
|
expect(g.pts[2].y).toBeCloseTo(5, 9);
|
||||||
|
expect(g.pts[3].x).toBeCloseTo(3, 9);
|
||||||
|
expect(g.pts[3].y).toBeCloseTo(6, 9);
|
||||||
|
// Nachbarkante P1-P2' behält ihre Richtung (senkrecht, Δx=0) — nur die
|
||||||
|
// Länge ändert sich (3 → 5).
|
||||||
|
const p1 = g.pts[1];
|
||||||
|
const p2 = g.pts[2];
|
||||||
|
expect(p2.x - p1.x).toBeCloseTo(0, 9);
|
||||||
|
expect(p2.y - p1.y).toBeCloseTo(5, 9);
|
||||||
|
// Nachbarkante P4-P3' behält ihre Richtung (Steigung 1:1) — nur die
|
||||||
|
// Länge ändert sich (√8 → √18).
|
||||||
|
const p4 = g.pts[4];
|
||||||
|
const p3 = g.pts[3];
|
||||||
|
const dir = { x: p3.x - p4.x, y: p3.y - p4.y };
|
||||||
|
expect(dir.x).toBeCloseTo(dir.y, 9); // 45°-Steigung erhalten
|
||||||
|
expect(Math.hypot(dir.x, dir.y)).toBeGreaterThan(Math.hypot(2, 2)); // länger als vorher
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Rechteck (4 rechtwinklige Ecken) bleibt reine Parallel-Verschiebung (Regressionsschutz)", () => {
|
||||||
|
const rect: Drawing2D = {
|
||||||
|
id: "d-rect",
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "00",
|
||||||
|
geom: {
|
||||||
|
shape: "polyline",
|
||||||
|
closed: true,
|
||||||
|
pts: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 4, y: 0 },
|
||||||
|
{ x: 4, y: 3 },
|
||||||
|
{ x: 0, y: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setState({ project: { ...getState().project, drawings2d: [rect] } });
|
||||||
|
// Rechte Kante (Index 1,2) um (2,0) nach außen verschieben.
|
||||||
|
getState().moveEdgeOf("d-rect", null, 1, 2, { x: 2, y: 0 });
|
||||||
|
const g = getState().project.drawings2d.find((d) => d.id === "d-rect")!.geom;
|
||||||
|
expect(g.shape).toBe("polyline");
|
||||||
|
if (g.shape !== "polyline") return;
|
||||||
|
expect(g.pts[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(g.pts[1]).toEqual({ x: 6, y: 0 });
|
||||||
|
expect(g.pts[2]).toEqual({ x: 6, y: 3 });
|
||||||
|
expect(g.pts[3]).toEqual({ x: 0, y: 3 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moveRoomEdge — dieselbe Winkeltreue über movePolygonEdge", () => {
|
||||||
|
it("verschiebt die slanted Kante eines Raum-Umrisses winkeltreu", () => {
|
||||||
|
getState().moveRoomEdge("r-pentagon", 2, 3, { x: 1, y: 1 });
|
||||||
|
const r = getState().project.rooms!.find((x) => x.id === "r-pentagon")!;
|
||||||
|
expect(r.boundary[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(r.boundary[2].x).toBeCloseTo(4, 9);
|
||||||
|
expect(r.boundary[2].y).toBeCloseTo(5, 9);
|
||||||
|
expect(r.boundary[3].x).toBeCloseTo(3, 9);
|
||||||
|
expect(r.boundary[3].y).toBeCloseTo(6, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
+215
-18
@@ -23,6 +23,7 @@ import type {
|
|||||||
Project,
|
Project,
|
||||||
Vec2,
|
Vec2,
|
||||||
} from "../model/types";
|
} from "../model/types";
|
||||||
|
import { lineIntersect } from "../model/geometry";
|
||||||
import { commitTransform } from "../tools/transform";
|
import { commitTransform } from "../tools/transform";
|
||||||
import type { CopyMode, TransformOp, TransformSelection } from "../tools/transform";
|
import type { CopyMode, TransformOp, TransformSelection } from "../tools/transform";
|
||||||
import { uniqueId } from "../tools/types";
|
import { uniqueId } from "../tools/types";
|
||||||
@@ -908,9 +909,7 @@ export function createProjectSlice(
|
|||||||
setProject(
|
setProject(
|
||||||
(p) => mapCeiling(p, ceilingId, (c) => ({
|
(p) => mapCeiling(p, ceilingId, (c) => ({
|
||||||
...c,
|
...c,
|
||||||
outline: c.outline.map((v, i) =>
|
outline: movePolygonEdge(c.outline, aIndex, bIndex, delta, true),
|
||||||
i === aIndex || i === bIndex ? { x: v.x + delta.x, y: v.y + delta.y } : v,
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
`moveCeilingEdge:${ceilingId}:${aIndex}:${bIndex}`,
|
`moveCeilingEdge:${ceilingId}:${aIndex}:${bIndex}`,
|
||||||
),
|
),
|
||||||
@@ -1030,11 +1029,7 @@ export function createProjectSlice(
|
|||||||
(p) =>
|
(p) =>
|
||||||
mapRoom(p, roomId, (r) => ({
|
mapRoom(p, roomId, (r) => ({
|
||||||
...r,
|
...r,
|
||||||
boundary: r.boundary.map((v, i) =>
|
boundary: movePolygonEdge(r.boundary, aIndex, bIndex, delta, true),
|
||||||
i === aIndex || i === bIndex
|
|
||||||
? { x: v.x + delta.x, y: v.y + delta.y }
|
|
||||||
: v,
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
`moveRoomEdge:${roomId}:${aIndex}:${bIndex}`,
|
`moveRoomEdge:${roomId}:${aIndex}:${bIndex}`,
|
||||||
),
|
),
|
||||||
@@ -1412,6 +1407,28 @@ function resizeGeom(
|
|||||||
};
|
};
|
||||||
return { ...g, center, r };
|
return { ...g, center, r };
|
||||||
}
|
}
|
||||||
|
case "ellipse": {
|
||||||
|
// bbox: rotation=0 angenommen (das Werkzeug erzeugt nur achsparallele
|
||||||
|
// Ellipsen); rx/ry unabhängig skaliert, Mittelpunkt um den Anker verschoben.
|
||||||
|
const box = {
|
||||||
|
minX: g.center.x - g.rx,
|
||||||
|
minY: g.center.y - g.ry,
|
||||||
|
maxX: g.center.x + g.rx,
|
||||||
|
maxY: g.center.y + g.ry,
|
||||||
|
};
|
||||||
|
const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
|
||||||
|
const center: Vec2 = {
|
||||||
|
x: ax + (g.center.x - ax) * sx,
|
||||||
|
y: ay + (g.center.y - ay) * sy,
|
||||||
|
};
|
||||||
|
return { ...g, center, rx: Math.max(1e-3, g.rx * sx), ry: Math.max(1e-3, g.ry * sy) };
|
||||||
|
}
|
||||||
|
case "bezier":
|
||||||
|
case "spline": {
|
||||||
|
const { ax, ay, sx, sy } = scaleAround(bbox2(g.pts), w, h, anchor);
|
||||||
|
const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy });
|
||||||
|
return { ...g, pts: g.pts.map(f) };
|
||||||
|
}
|
||||||
case "text": {
|
case "text": {
|
||||||
// Text: nur die Einfügeposition um den Anker schieben (Höhe bleibt).
|
// Text: nur die Einfügeposition um den Anker schieben (Höhe bleibt).
|
||||||
const box = { minX: g.at.x, minY: g.at.y, maxX: g.at.x, maxY: g.at.y };
|
const box = { minX: g.at.x, minY: g.at.y, maxX: g.at.x, maxY: g.at.y };
|
||||||
@@ -1435,6 +1452,24 @@ function resizeGeom(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kreis, solange rx≈ry (relative Toleranz 0.5%) — sonst Ellipse (rotation 0).
|
||||||
|
* Trägt den Kreis-zu-Ellipse-Formwandel beim Griff-Ziehen (Nutzer-Wunsch:
|
||||||
|
* „Kreis wird wie bisher erstellt, kann beim Bearbeiten zur Ellipse werden");
|
||||||
|
* symmetrisch nutzbar auch für die Rückrichtung (Ellipse → Kreis, wenn man
|
||||||
|
* die Halbachsen wieder aneinander annähert).
|
||||||
|
*/
|
||||||
|
function ellipseOrCircle(
|
||||||
|
center: Vec2,
|
||||||
|
rx: number,
|
||||||
|
ry: number,
|
||||||
|
): import("../model/types").Drawing2DGeom {
|
||||||
|
if (Math.abs(rx - ry) <= 0.005 * Math.max(rx, ry)) {
|
||||||
|
return { shape: "circle", center, r: (rx + ry) / 2 };
|
||||||
|
}
|
||||||
|
return { shape: "ellipse", center, rx, ry, rotation: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Editier-Griffe (verschoben aus App.tsx, 1:1) ─────────────────────────────
|
// ── Editier-Griffe (verschoben aus App.tsx, 1:1) ─────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1455,8 +1490,16 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
|
|||||||
{ x: g.min.x, y: g.max.y },
|
{ x: g.min.x, y: g.max.y },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
// Kreis: zwei Griffe (Ost/Nord, wie bei der Ellipse) — zieht man EINEN der
|
||||||
|
// beiden unabhängig, wird der Kreis zur Ellipse (s. `moveGrip`). So bleibt
|
||||||
|
// ein Kreis ein Kreis, solange man ihn nicht gezielt verzerrt (Nutzer-
|
||||||
|
// Wunsch: „Kreis erstellen wie bisher, beim Bearbeiten kann er zur Ellipse
|
||||||
|
// werden").
|
||||||
if (g.shape === "circle") {
|
if (g.shape === "circle") {
|
||||||
return [{ x: g.center.x + g.r, y: g.center.y }];
|
return [
|
||||||
|
{ x: g.center.x + g.r, y: g.center.y },
|
||||||
|
{ x: g.center.x, y: g.center.y + g.r },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
if (g.shape === "arc") {
|
if (g.shape === "arc") {
|
||||||
const mid = (g.a0 + g.a1) / 2;
|
const mid = (g.a0 + g.a1) / 2;
|
||||||
@@ -1466,6 +1509,30 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
|
|||||||
{ x: g.center.x + g.r * Math.cos(mid), y: g.center.y + g.r * Math.sin(mid) },
|
{ x: g.center.x + g.r * Math.cos(mid), y: g.center.y + g.r * Math.sin(mid) },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
// Ellipse: zwei Griffe (Index 0 = rx-Achse, Index 1 = ry-Achse), gedreht um
|
||||||
|
// `rotation` (0 beim heutigen Werkzeug) — siehe `moveGrip`.
|
||||||
|
if (g.shape === "ellipse") {
|
||||||
|
const c = Math.cos(g.rotation), s = Math.sin(g.rotation);
|
||||||
|
return [
|
||||||
|
{ x: g.center.x + g.rx * c, y: g.center.y + g.rx * s },
|
||||||
|
{ x: g.center.x - g.ry * s, y: g.center.y + g.ry * c },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// Bezier/Spline: alle Kontroll-/Wegpunkte als Griffe (wie polyline).
|
||||||
|
if (g.shape === "bezier" || g.shape === "spline") return [...g.pts];
|
||||||
|
// Bemassung: Griff 0..N-1 = jeder Punkt der Kette (wie polyline); EIN
|
||||||
|
// zusätzlicher Griff (Index N) sitzt AUF der Masslinie (Mitte der Kette) —
|
||||||
|
// ihn zu ziehen ändert nur `offset`, nicht die Punkte (s. moveGrip).
|
||||||
|
if (g.shape === "dimension") {
|
||||||
|
const a = g.pts[0], b = g.pts[g.pts.length - 1];
|
||||||
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||||||
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
|
const nx = (-dy / len) * g.offset, ny = (dx / len) * g.offset;
|
||||||
|
const mid: Vec2 = { x: (a.x + b.x) / 2 + nx, y: (a.y + b.y) / 2 + ny };
|
||||||
|
return [...g.pts, mid];
|
||||||
|
}
|
||||||
|
// Kote: EIN Griff an der Dreieck-Spitze (zum Verschieben; siehe moveGrip).
|
||||||
|
if (g.shape === "kote") return [g.at];
|
||||||
// Text: EIN Griff am Ankerpunkt (zum Verschieben; siehe moveGrip).
|
// Text: EIN Griff am Ankerpunkt (zum Verschieben; siehe moveGrip).
|
||||||
if (g.shape === "text") return [g.at];
|
if (g.shape === "text") return [g.at];
|
||||||
return [];
|
return [];
|
||||||
@@ -1523,10 +1590,26 @@ function moveGrip(
|
|||||||
if (g.shape === "line") {
|
if (g.shape === "line") {
|
||||||
return { ...d, geom: index === 0 ? { ...g, a: pt } : { ...g, b: pt } };
|
return { ...d, geom: index === 0 ? { ...g, a: pt } : { ...g, b: pt } };
|
||||||
}
|
}
|
||||||
if (g.shape === "polyline") {
|
if (g.shape === "polyline" || g.shape === "bezier" || g.shape === "spline") {
|
||||||
const pts = g.pts.map((p, i) => (i === index ? pt : p));
|
const pts = g.pts.map((p, i) => (i === index ? pt : p));
|
||||||
return { ...d, geom: { ...g, pts } };
|
return { ...d, geom: { ...g, pts } };
|
||||||
}
|
}
|
||||||
|
// Bemassung: Griff 0..N-1 verschiebt den jeweiligen Kettenpunkt
|
||||||
|
// (Masszahlen folgen automatisch, s. Rendering); der LETZTE Griff
|
||||||
|
// (auf der Masslinie) ändert nur `offset` — Normalabstand von `pt`
|
||||||
|
// zur (unveränderten) Punktreihe.
|
||||||
|
if (g.shape === "dimension") {
|
||||||
|
if (index < g.pts.length) {
|
||||||
|
const pts = g.pts.map((p, i) => (i === index ? pt : p));
|
||||||
|
return { ...d, geom: { ...g, pts } };
|
||||||
|
}
|
||||||
|
const a = g.pts[0], b = g.pts[g.pts.length - 1];
|
||||||
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||||||
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
|
const nx = -dy / len, ny = dx / len;
|
||||||
|
const offset = (pt.x - a.x) * nx + (pt.y - a.y) * ny;
|
||||||
|
return { ...d, geom: { ...g, offset } };
|
||||||
|
}
|
||||||
if (g.shape === "rect" || g.shape === "image") {
|
if (g.shape === "rect" || g.shape === "image") {
|
||||||
const corners = [
|
const corners = [
|
||||||
g.min,
|
g.min,
|
||||||
@@ -1541,10 +1624,16 @@ function moveGrip(
|
|||||||
}
|
}
|
||||||
// Text: der einzige Griff (Index 0) sitzt am Ankerpunkt → verschiebt ihn.
|
// Text: der einzige Griff (Index 0) sitzt am Ankerpunkt → verschiebt ihn.
|
||||||
if (g.shape === "text") return { ...d, geom: { ...g, at: pt } };
|
if (g.shape === "text") return { ...d, geom: { ...g, at: pt } };
|
||||||
// Kreis: der einzige Griff (Index 0) ändert nur den Radius.
|
// Kote: der einzige Griff (Index 0) sitzt an der Dreieck-Spitze.
|
||||||
|
if (g.shape === "kote") return { ...d, geom: { ...g, at: pt } };
|
||||||
|
// Kreis: Griff 0 (Ost) bzw. 1 (Nord) — zieht man die beiden Achsen
|
||||||
|
// unabhängig auseinander, wird der Kreis zur Ellipse (rotation 0);
|
||||||
|
// bleiben rx/ry (fast) gleich, bleibt es ein Kreis (s. `ellipseOrCircle`).
|
||||||
if (g.shape === "circle") {
|
if (g.shape === "circle") {
|
||||||
const r = Math.max(1e-3, Math.hypot(pt.x - g.center.x, pt.y - g.center.y));
|
const dx = pt.x - g.center.x, dy = pt.y - g.center.y;
|
||||||
return { ...d, geom: { ...g, r } };
|
const rx = index === 0 ? Math.max(1e-3, Math.abs(dx)) : g.r;
|
||||||
|
const ry = index === 0 ? g.r : Math.max(1e-3, Math.abs(dy));
|
||||||
|
return { ...d, geom: ellipseOrCircle(g.center, rx, ry) };
|
||||||
}
|
}
|
||||||
// Bogen: Griff 0/1 (Start-/End-Winkel, s. drawingVertices) ändert a0/a1;
|
// Bogen: Griff 0/1 (Start-/End-Winkel, s. drawingVertices) ändert a0/a1;
|
||||||
// Griff 2 (Bogenmitte) ändert nur den Radius (Winkel bleiben fest).
|
// Griff 2 (Bogenmitte) ändert nur den Radius (Winkel bleiben fest).
|
||||||
@@ -1556,6 +1645,22 @@ function moveGrip(
|
|||||||
const ang = Math.atan2(pt.y - g.center.y, pt.x - g.center.x);
|
const ang = Math.atan2(pt.y - g.center.y, pt.x - g.center.x);
|
||||||
return { ...d, geom: index === 0 ? { ...g, a0: ang } : { ...g, a1: ang } };
|
return { ...d, geom: index === 0 ? { ...g, a0: ang } : { ...g, a1: ang } };
|
||||||
}
|
}
|
||||||
|
// Ellipse: Griff 0 (rx-Achse) bzw. 1 (ry-Achse) — der gezogene Punkt wird
|
||||||
|
// ins lokale (gedrehte) Achsensystem projiziert, nur die jeweils
|
||||||
|
// betroffene Halbachse ändert sich.
|
||||||
|
if (g.shape === "ellipse") {
|
||||||
|
const c = Math.cos(g.rotation), s = Math.sin(g.rotation);
|
||||||
|
const dx = pt.x - g.center.x, dy = pt.y - g.center.y;
|
||||||
|
const lx = dx * c + dy * s;
|
||||||
|
const ly = -dx * s + dy * c;
|
||||||
|
const rx = index === 0 ? Math.max(1e-3, Math.abs(lx)) : g.rx;
|
||||||
|
const ry = index === 0 ? g.ry : Math.max(1e-3, Math.abs(ly));
|
||||||
|
// Nur bei rotation 0 kann eine (fast) gleichseitige Ellipse wieder
|
||||||
|
// zum Kreis kollabieren — eine gedrehte Ellipse mit rx≈ry ist optisch
|
||||||
|
// bereits ein Kreis, „Drehung" wäre dann aber wieder bedeutungslos.
|
||||||
|
if (g.rotation === 0) return { ...d, geom: ellipseOrCircle(g.center, rx, ry) };
|
||||||
|
return { ...d, geom: { ...g, rx, ry } };
|
||||||
|
}
|
||||||
return d;
|
return d;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -1595,7 +1700,14 @@ function moveElementBy(
|
|||||||
return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
|
return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
|
||||||
case "circle":
|
case "circle":
|
||||||
case "arc":
|
case "arc":
|
||||||
|
case "ellipse":
|
||||||
return { ...d, geom: { ...g, center: mv(g.center) } };
|
return { ...d, geom: { ...g, center: mv(g.center) } };
|
||||||
|
case "bezier":
|
||||||
|
case "spline":
|
||||||
|
case "dimension":
|
||||||
|
return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
|
||||||
|
case "kote":
|
||||||
|
return { ...d, geom: { ...g, at: mv(g.at) } };
|
||||||
case "text":
|
case "text":
|
||||||
return { ...d, geom: { ...g, at: mv(g.at) } };
|
return { ...d, geom: { ...g, at: mv(g.at) } };
|
||||||
default:
|
default:
|
||||||
@@ -1615,12 +1727,97 @@ function moveElementBy(
|
|||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neue Position eines Eckpunkts, dessen EINE Kante parallel um `delta`
|
||||||
|
* verschoben wird: der Punkt bleibt NICHT einfach delta-verschoben, sondern
|
||||||
|
* wandert entlang seiner ANDEREN (unverschobenen) Nachbarkante — deren
|
||||||
|
* Richtung bleibt exakt erhalten (nur ihre Länge ändert sich), statt sich mit
|
||||||
|
* zu drehen. Nutzer-Report: bei Vielecken mit mehr als 4/5 Ecken (nicht
|
||||||
|
* rechtwinklige Anschlüsse) kippten die Nachbarkanten bisher im Winkel, weil
|
||||||
|
* der Eckpunkt stur um `delta` versetzt wurde (korrekt nur bei rechtwinkligen
|
||||||
|
* Anschlüssen — Zufall bei Rechtecken, da rect ohnehin nur achsparallele
|
||||||
|
* Ecken kennt). Berechnet als Schnittpunkt der verschobenen Kanten-Linie mit
|
||||||
|
* der (unendlich verlängerten) Nachbarkanten-Linie durch den fixen
|
||||||
|
* Nachbarpunkt — dasselbe Prinzip wie die Wandstoß-Gehrung in `model/
|
||||||
|
* joins.ts`. Fällt bei fehlendem Nachbarn (offenes Kettenende) oder (fast)
|
||||||
|
* paralleler Nachbarkante (kein/instabiler Schnittpunkt) auf die einfache
|
||||||
|
* Delta-Verschiebung zurück.
|
||||||
|
*/
|
||||||
|
function edgeMoveVertex(
|
||||||
|
vertexOrig: Vec2,
|
||||||
|
neighborFixed: Vec2 | null,
|
||||||
|
shiftedLinePoint: Vec2,
|
||||||
|
edgeDir: Vec2,
|
||||||
|
delta: Vec2,
|
||||||
|
): Vec2 {
|
||||||
|
const fallback: Vec2 = { x: vertexOrig.x + delta.x, y: vertexOrig.y + delta.y };
|
||||||
|
if (!neighborFixed) return fallback;
|
||||||
|
const neighborDir: Vec2 = {
|
||||||
|
x: vertexOrig.x - neighborFixed.x,
|
||||||
|
y: vertexOrig.y - neighborFixed.y,
|
||||||
|
};
|
||||||
|
if (Math.hypot(neighborDir.x, neighborDir.y) < 1e-9) return fallback; // entartete Nachbarkante
|
||||||
|
return lineIntersect(neighborFixed, neighborDir, shiftedLinePoint, edgeDir) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachbar-Index in einem Vertex-Ring/einer -Kette in Richtung `dir`. `null`,
|
||||||
|
* wenn `i` am offenen Ende liegt und `closed` falsch ist (kein Nachbar dort)
|
||||||
|
* ODER der Nachbar auf die andere Kanten-Ecke selbst fiele (entartetes
|
||||||
|
* 2-Punkt-„Polygon" — kein sinnvoller dritter Bezugspunkt).
|
||||||
|
*/
|
||||||
|
function ringNeighbor(
|
||||||
|
i: number,
|
||||||
|
n: number,
|
||||||
|
closed: boolean,
|
||||||
|
dir: 1 | -1,
|
||||||
|
excluding: number,
|
||||||
|
): number | null {
|
||||||
|
const j = i + dir;
|
||||||
|
const k = j >= 0 && j < n ? j : closed ? ((j % n) + n) % n : null;
|
||||||
|
return k !== null && k !== excluding ? k : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschiebt die Kante (aIndex,bIndex) eines Vertex-Rings/einer -Kette um
|
||||||
|
* `delta`, winkeltreu an den beiden Nachbar-Ecken (s. `edgeMoveVertex`).
|
||||||
|
* `closed`: Ring (Raum/Decke/geschlossene Polylinie) oder offene Kette
|
||||||
|
* (offene Polylinie).
|
||||||
|
*/
|
||||||
|
function movePolygonEdge(
|
||||||
|
pts: Vec2[],
|
||||||
|
aIndex: number,
|
||||||
|
bIndex: number,
|
||||||
|
delta: Vec2,
|
||||||
|
closed: boolean,
|
||||||
|
): Vec2[] {
|
||||||
|
const n = pts.length;
|
||||||
|
const A = pts[aIndex];
|
||||||
|
const B = pts[bIndex];
|
||||||
|
const shiftedA: Vec2 = { x: A.x + delta.x, y: A.y + delta.y };
|
||||||
|
const edgeDir: Vec2 = { x: B.x - A.x, y: B.y - A.y };
|
||||||
|
const prevOfA = ringNeighbor(aIndex, n, closed, -1, bIndex);
|
||||||
|
const nextOfB = ringNeighbor(bIndex, n, closed, 1, aIndex);
|
||||||
|
return pts.map((p, i) => {
|
||||||
|
if (i === aIndex) {
|
||||||
|
return edgeMoveVertex(A, prevOfA != null ? pts[prevOfA] : null, shiftedA, edgeDir, delta);
|
||||||
|
}
|
||||||
|
if (i === bIndex) {
|
||||||
|
return edgeMoveVertex(B, nextOfB != null ? pts[nextOfB] : null, shiftedA, edgeDir, delta);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verschiebt eine SEITE (Kante) des selektierten Elements: beide Vertices der
|
* Verschiebt eine SEITE (Kante) des selektierten Elements: beide Vertices der
|
||||||
* Kante um `delta` (immutabel). Für 2D-Elemente:
|
* Kante um `delta` (immutabel), winkeltreu an den Nachbarkanten (s. o.). Für
|
||||||
|
* 2D-Elemente:
|
||||||
* • rect: die zwei betroffenen Ecken (per Index) verschieben und min/max neu
|
* • rect: die zwei betroffenen Ecken (per Index) verschieben und min/max neu
|
||||||
* normalisieren — so wächst/schrumpft das Rechteck an genau dieser Seite.
|
* normalisieren — so wächst/schrumpft das Rechteck an genau dieser Seite
|
||||||
* • polyline: die zwei Vertices `aIndex`/`bIndex` um `delta` verschieben.
|
* (immer rechtwinklig, `movePolygonEdge` nicht nötig).
|
||||||
|
* • polyline: die zwei Vertices `aIndex`/`bIndex` um `delta` verschieben,
|
||||||
|
* winkeltreu über `movePolygonEdge`.
|
||||||
* • line: beide Endpunkte verschieben (das gesamte Segment senkrecht).
|
* • line: beide Endpunkte verschieben (das gesamte Segment senkrecht).
|
||||||
* Für die Wand: start+end gemeinsam (die ganze Wand senkrecht zur Achse).
|
* Für die Wand: start+end gemeinsam (die ganze Wand senkrecht zur Achse).
|
||||||
*/
|
*/
|
||||||
@@ -1643,8 +1840,8 @@ function moveEdge(
|
|||||||
// Linie hat nur eine Kante [0,1] → das ganze Segment verschieben.
|
// Linie hat nur eine Kante [0,1] → das ganze Segment verschieben.
|
||||||
return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
|
return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
|
||||||
}
|
}
|
||||||
if (g.shape === "polyline") {
|
if (g.shape === "polyline" || g.shape === "spline") {
|
||||||
const pts = g.pts.map((p, i) => (i === aIndex || i === bIndex ? mv(p) : p));
|
const pts = movePolygonEdge(g.pts, aIndex, bIndex, delta, !!g.closed);
|
||||||
return { ...d, geom: { ...g, pts } };
|
return { ...d, geom: { ...g, pts } };
|
||||||
}
|
}
|
||||||
if (g.shape === "rect" || g.shape === "image") {
|
if (g.shape === "rect" || g.shape === "image") {
|
||||||
|
|||||||
+144
-5
@@ -43,6 +43,12 @@ import type {
|
|||||||
WallReferenceLine,
|
WallReferenceLine,
|
||||||
} from "../model/types";
|
} from "../model/types";
|
||||||
import { localToLv95 } from "../io/lv95";
|
import { localToLv95 } from "../io/lv95";
|
||||||
|
import {
|
||||||
|
resolveForeground,
|
||||||
|
resolveBackground,
|
||||||
|
resolveHatchId,
|
||||||
|
resolveStrokeWeight,
|
||||||
|
} from "../plan/generatePlan/shared";
|
||||||
import { columnVerticalExtent } from "../model/wall";
|
import { columnVerticalExtent } from "../model/wall";
|
||||||
import { columnFootprint } from "../geometry/column";
|
import { columnFootprint } from "../geometry/column";
|
||||||
import { evaluateRoom, polygonArea } from "../geometry/roomArea";
|
import { evaluateRoom, polygonArea } from "../geometry/roomArea";
|
||||||
@@ -413,6 +419,18 @@ export interface Selection {
|
|||||||
strokeWeightSource?: AttributeSource;
|
strokeWeightSource?: AttributeSource;
|
||||||
/** Quelle der Schraffur, wenn kein `fillHatchId` gesetzt ist. */
|
/** Quelle der Schraffur, wenn kein `fillHatchId` gesetzt ist. */
|
||||||
hatchSource?: AttributeSource;
|
hatchSource?: AttributeSource;
|
||||||
|
/**
|
||||||
|
* EFFEKTIV aufgelöster Vordergrund (Override ?? Ebene ?? Bauteil, s.
|
||||||
|
* `resolveForeground` — dieselbe Kette wie beim Rendern), für die IMMER
|
||||||
|
* sichtbare Vorschau im Attribut-Panel (Nutzer-Wunsch: „man sollte immer
|
||||||
|
* sehen welche Farbe [gilt]"), unabhängig von der aktuellen Quelle.
|
||||||
|
* `undefined` = kein Wert an keiner Stelle der Kette (echtes „Nach System").
|
||||||
|
*/
|
||||||
|
effectiveForeground?: string;
|
||||||
|
/** Effektiv aufgelöster Hintergrund, analog zu {@link Selection.effectiveForeground}. */
|
||||||
|
effectiveBackground?: string;
|
||||||
|
/** Effektiv aufgelöste Schraffur-ID, analog zu {@link Selection.effectiveForeground}. */
|
||||||
|
effectiveHatchId?: string | null;
|
||||||
/** Ob die Form geschlossen ist (rect/circle/closed polyline) — nur Drawing2D. */
|
/** Ob die Form geschlossen ist (rect/circle/closed polyline) — nur Drawing2D. */
|
||||||
closed?: boolean;
|
closed?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -476,7 +494,9 @@ function isClosedGeom(g: Drawing2DGeom): boolean {
|
|||||||
return (
|
return (
|
||||||
g.shape === "rect" ||
|
g.shape === "rect" ||
|
||||||
g.shape === "circle" ||
|
g.shape === "circle" ||
|
||||||
(g.shape === "polyline" && g.closed)
|
g.shape === "ellipse" ||
|
||||||
|
(g.shape === "polyline" && g.closed) ||
|
||||||
|
(g.shape === "spline" && g.closed)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +530,32 @@ function geomBBox(g: Drawing2DGeom): SelectionBBox {
|
|||||||
acc(g.center.x - g.r, g.center.y - g.r);
|
acc(g.center.x - g.r, g.center.y - g.r);
|
||||||
acc(g.center.x + g.r, g.center.y + g.r);
|
acc(g.center.x + g.r, g.center.y + g.r);
|
||||||
break;
|
break;
|
||||||
|
case "ellipse": {
|
||||||
|
// Präzise Bounding-Box unter Berücksichtigung der Drehung (rotation=0
|
||||||
|
// beim heutigen Werkzeug, Feld existiert für spätere Quellen).
|
||||||
|
const c = Math.cos(g.rotation), s = Math.sin(g.rotation);
|
||||||
|
const hw = Math.hypot(g.rx * c, g.ry * s);
|
||||||
|
const hh = Math.hypot(g.rx * s, g.ry * c);
|
||||||
|
acc(g.center.x - hw, g.center.y - hh);
|
||||||
|
acc(g.center.x + hw, g.center.y + hh);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "bezier":
|
||||||
|
case "spline":
|
||||||
|
for (const p of g.pts) acc(p.x, p.y);
|
||||||
|
break;
|
||||||
|
case "dimension": {
|
||||||
|
for (const p of g.pts) acc(p.x, p.y);
|
||||||
|
if (g.pts.length >= 2) {
|
||||||
|
const a = g.pts[0], b = g.pts[g.pts.length - 1];
|
||||||
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||||||
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
|
const nx = (-dy / len) * g.offset, ny = (dx / len) * g.offset;
|
||||||
|
for (const p of g.pts) acc(p.x + nx, p.y + ny);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "kote":
|
||||||
case "text":
|
case "text":
|
||||||
acc(g.at.x, g.at.y);
|
acc(g.at.x, g.at.y);
|
||||||
break;
|
break;
|
||||||
@@ -533,6 +579,26 @@ function wallSelection(project: Project, wall: Wall): Selection {
|
|||||||
// Wand-Attribute auflösen (Referenzlinie, Aufbau, Referenzgeschoss, UK/OK).
|
// Wand-Attribute auflösen (Referenzlinie, Aufbau, Referenzgeschoss, UK/OK).
|
||||||
const wt = getWallType(project, wall);
|
const wt = getWallType(project, wall);
|
||||||
const thickness = wallTypeThickness(wt);
|
const thickness = wallTypeThickness(wt);
|
||||||
|
// Effektive Vordergrund-/Hintergrund-/Schraffur-Vorschau fürs Attribut-Panel
|
||||||
|
// (Nutzer-Wunsch: immer den TATSÄCHLICHEN Wert sehen, nicht nur ein leeres
|
||||||
|
// Override-Feld) — dieselbe Resolve-Kette wie beim Rendern (resolveForeground
|
||||||
|
// & Co.), als Bauteil-Repräsentant die ÄUSSERSTE Schicht (wie `abbrevOf`
|
||||||
|
// unten). Bei mehrschichtigem Aufbau nur eine Annäherung (jede Schicht kann
|
||||||
|
// im Schnitt ihre EIGENE Bauteilfarbe zeigen) — für die Panel-Vorschau
|
||||||
|
// ausreichend, der Override selbst wirkt ohnehin einheitlich auf alle Schichten.
|
||||||
|
const outerComponent = getComponent(project, wt.layers[0]?.componentId);
|
||||||
|
const effectiveForeground = outerComponent
|
||||||
|
? resolveForeground(outerComponent, wall.foreground, cat, wall.foregroundSource)
|
||||||
|
: wall.foreground;
|
||||||
|
const effectiveBackground = outerComponent
|
||||||
|
? resolveBackground(outerComponent, wall.background, cat, wall.backgroundSource)
|
||||||
|
: wall.background;
|
||||||
|
const effectiveHatchId = resolveHatchId(
|
||||||
|
wall.hatchId,
|
||||||
|
wall.hatchSource,
|
||||||
|
cat,
|
||||||
|
outerComponent?.hatchId,
|
||||||
|
);
|
||||||
const floor = project.drawingLevels.find((z) => z.id === wall.floorId);
|
const floor = project.drawingLevels.find((z) => z.id === wall.floorId);
|
||||||
const { zBottom, zTop } = wallVerticalExtent(project, wall);
|
const { zBottom, zTop } = wallVerticalExtent(project, wall);
|
||||||
const above = nextFloorAbove(project, wall);
|
const above = nextFloorAbove(project, wall);
|
||||||
@@ -606,6 +672,9 @@ function wallSelection(project: Project, wall: Wall): Selection {
|
|||||||
background: wall.background,
|
background: wall.background,
|
||||||
foregroundSource: wall.foregroundSource,
|
foregroundSource: wall.foregroundSource,
|
||||||
backgroundSource: wall.backgroundSource,
|
backgroundSource: wall.backgroundSource,
|
||||||
|
effectiveForeground,
|
||||||
|
effectiveBackground,
|
||||||
|
effectiveHatchId,
|
||||||
strokeWeightOverride: wall.strokeWeight,
|
strokeWeightOverride: wall.strokeWeight,
|
||||||
strokeWeightSource: wall.strokeWeightSource,
|
strokeWeightSource: wall.strokeWeightSource,
|
||||||
hatchSource: wall.hatchSource,
|
hatchSource: wall.hatchSource,
|
||||||
@@ -623,6 +692,21 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection {
|
|||||||
const box = outlineBBox(ceiling.outline);
|
const box = outlineBBox(ceiling.outline);
|
||||||
const wt = getCeilingType(project, ceiling);
|
const wt = getCeilingType(project, ceiling);
|
||||||
const thickness = ceilingThickness(project, ceiling);
|
const thickness = ceilingThickness(project, ceiling);
|
||||||
|
// Effektive Vordergrund-/Hintergrund-/Schraffur-Vorschau — s. Kommentar in
|
||||||
|
// wallSelection (dieselbe Kette, äusserste Schicht als Bauteil-Repräsentant).
|
||||||
|
const outerComponent = getComponent(project, wt.layers[0]?.componentId);
|
||||||
|
const effectiveForeground = outerComponent
|
||||||
|
? resolveForeground(outerComponent, ceiling.foreground, cat, ceiling.foregroundSource)
|
||||||
|
: ceiling.foreground;
|
||||||
|
const effectiveBackground = outerComponent
|
||||||
|
? resolveBackground(outerComponent, ceiling.background, cat, ceiling.backgroundSource)
|
||||||
|
: ceiling.background;
|
||||||
|
const effectiveHatchId = resolveHatchId(
|
||||||
|
ceiling.hatchId,
|
||||||
|
ceiling.hatchSource,
|
||||||
|
cat,
|
||||||
|
outerComponent?.hatchId,
|
||||||
|
);
|
||||||
const floor = project.drawingLevels.find((z) => z.id === ceiling.floorId);
|
const floor = project.drawingLevels.find((z) => z.id === ceiling.floorId);
|
||||||
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
|
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
|
||||||
const ceilingInfo: CeilingInfo = {
|
const ceilingInfo: CeilingInfo = {
|
||||||
@@ -666,6 +750,9 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection {
|
|||||||
background: ceiling.background,
|
background: ceiling.background,
|
||||||
foregroundSource: ceiling.foregroundSource,
|
foregroundSource: ceiling.foregroundSource,
|
||||||
backgroundSource: ceiling.backgroundSource,
|
backgroundSource: ceiling.backgroundSource,
|
||||||
|
effectiveForeground,
|
||||||
|
effectiveBackground,
|
||||||
|
effectiveHatchId,
|
||||||
strokeWeightOverride: ceiling.strokeWeight,
|
strokeWeightOverride: ceiling.strokeWeight,
|
||||||
strokeWeightSource: ceiling.strokeWeightSource,
|
strokeWeightSource: ceiling.strokeWeightSource,
|
||||||
hatchSource: ceiling.hatchSource,
|
hatchSource: ceiling.hatchSource,
|
||||||
@@ -931,13 +1018,42 @@ function columnSelection(project: Project, col: Column): Selection {
|
|||||||
zBottom,
|
zBottom,
|
||||||
zTop,
|
zTop,
|
||||||
};
|
};
|
||||||
|
// Effektive Vordergrund-/Hintergrund-/Schraffur-/Strichstärke-Vorschau — s.
|
||||||
|
// Kommentar in wallSelection (dieselbe Kette, das zugewiesene Bauteil als
|
||||||
|
// Repräsentant statt der äussersten Schicht, da Column nur EIN componentId
|
||||||
|
// trägt statt eines geschichteten Aufbaus).
|
||||||
|
// `getComponent` wirft bei unbekannter ID (z. B. gelöschtes Bauteil) — die
|
||||||
|
// Panel-Vorschau soll dabei nicht abstürzen (analog dem try/catch in
|
||||||
|
// structures.ts addColumnPoche), einfach auf "kein Bauteil" zurückfallen.
|
||||||
|
const comp = col.componentId
|
||||||
|
? project.components.find((c) => c.id === col.componentId)
|
||||||
|
: undefined;
|
||||||
|
const effectiveForeground = comp
|
||||||
|
? resolveForeground(comp, col.foreground, category, col.foregroundSource)
|
||||||
|
: col.foreground;
|
||||||
|
const effectiveBackground = comp
|
||||||
|
? resolveBackground(comp, col.background, category, col.backgroundSource)
|
||||||
|
: col.background;
|
||||||
|
const effectiveHatchId = resolveHatchId(col.hatchId, col.hatchSource, category, comp?.hatchId);
|
||||||
|
const weightMm = resolveStrokeWeight(col.strokeWeight, col.strokeWeightSource, category, WALL_FALLBACK_MM);
|
||||||
return {
|
return {
|
||||||
kind: "column",
|
kind: "column",
|
||||||
id: col.id,
|
id: col.id,
|
||||||
categoryCode: col.categoryCode,
|
categoryCode: col.categoryCode,
|
||||||
floorId: col.floorId,
|
floorId: col.floorId,
|
||||||
color: col.color ?? category?.color ?? COLUMN_COLOR,
|
color: col.color ?? category?.color ?? COLUMN_COLOR,
|
||||||
weightMm: WALL_FALLBACK_MM,
|
weightMm,
|
||||||
|
fillHatchId: col.hatchId,
|
||||||
|
foreground: col.foreground,
|
||||||
|
background: col.background,
|
||||||
|
foregroundSource: col.foregroundSource,
|
||||||
|
backgroundSource: col.backgroundSource,
|
||||||
|
effectiveForeground,
|
||||||
|
effectiveBackground,
|
||||||
|
effectiveHatchId,
|
||||||
|
strokeWeightOverride: col.strokeWeight,
|
||||||
|
strokeWeightSource: col.strokeWeightSource,
|
||||||
|
hatchSource: col.hatchSource,
|
||||||
bbox: { minX, minY, maxX, maxY },
|
bbox: { minX, minY, maxX, maxY },
|
||||||
column: info,
|
column: info,
|
||||||
};
|
};
|
||||||
@@ -998,15 +1114,35 @@ function drawingSelection(project: Project, d: Drawing2D): Selection {
|
|||||||
const cat = findCategory(project.layers, d.categoryCode);
|
const cat = findCategory(project.layers, d.categoryCode);
|
||||||
const color = d.color ?? ls?.color ?? cat?.color ?? DRAW_FALLBACK_COLOR;
|
const color = d.color ?? ls?.color ?? cat?.color ?? DRAW_FALLBACK_COLOR;
|
||||||
// Effektive Strichstärke, dieselbe Kette wie generatePlan/addDrawing2D
|
// Effektive Strichstärke, dieselbe Kette wie generatePlan/addDrawing2D
|
||||||
// (resolveStrokeWeight): Override zuerst, "layer" erzwingt die Kategorie
|
// (resolveStrokeWeight): Override zuerst, Source ≠ "object" (also "layer"
|
||||||
// (umgeht das LineStyle-Gewicht), sonst die heutige LineStyle-/Kategorie-Kette.
|
// ODER fehlend, s. AttributeSource-Default) erzwingt die Kategorie (umgeht
|
||||||
|
// das LineStyle-Gewicht), sonst die LineStyle-/Kategorie-Kette.
|
||||||
const weightMm =
|
const weightMm =
|
||||||
d.weightMm ??
|
d.weightMm ??
|
||||||
(d.strokeWeightSource === "layer" ? cat?.lw : undefined) ??
|
(d.strokeWeightSource !== "object" ? cat?.lw : undefined) ??
|
||||||
ls?.weight ??
|
ls?.weight ??
|
||||||
cat?.lw ??
|
cat?.lw ??
|
||||||
WALL_FALLBACK_MM;
|
WALL_FALLBACK_MM;
|
||||||
const closed = isClosedGeom(d.geom);
|
const closed = isClosedGeom(d.geom);
|
||||||
|
// Effektive Vordergrund-/Hintergrund-/Schraffur-Vorschau — s. Kommentar in
|
||||||
|
// wallSelection. Ein Drawing2D hat KEIN eigenes Bauteil (daher auch keine
|
||||||
|
// „Nach Bauteil"-Option im Panel, s. AttributesPanel.tsx) — als Platzhalter
|
||||||
|
// ein leeres Bauteil, dessen foreground/background/hatchId immer fehlen,
|
||||||
|
// sodass die Kette bei einem (aus Alt-Daten) expliziten `source:"object"`
|
||||||
|
// korrekt auf „kein Wert" statt einer erfundenen Bauteilfarbe fällt. Anders
|
||||||
|
// als bei Wand/Decke (Default „Nach Bauteil") gilt bei Drawing2D „Nach
|
||||||
|
// Ebene" als Default — daher hier explizit `?? "layer"` VOR dem Resolver
|
||||||
|
// (der selbst weiterhin generisch „Nach Bauteil" als Default kennt).
|
||||||
|
const noComponent = { id: "", name: "", color, hatchId: "", joinPriority: 0 };
|
||||||
|
const effectiveForeground = closed
|
||||||
|
? resolveForeground(noComponent, d.foreground, cat, d.foregroundSource ?? "layer")
|
||||||
|
: undefined;
|
||||||
|
const effectiveBackground = closed
|
||||||
|
? resolveBackground(noComponent, d.background, cat, d.backgroundSource ?? "layer")
|
||||||
|
: undefined;
|
||||||
|
const effectiveHatchId = closed
|
||||||
|
? resolveHatchId(d.hatchId, d.hatchSource ?? "layer", cat, undefined)
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
kind: "drawing2d",
|
kind: "drawing2d",
|
||||||
id: d.id,
|
id: d.id,
|
||||||
@@ -1020,6 +1156,9 @@ function drawingSelection(project: Project, d: Drawing2D): Selection {
|
|||||||
background: closed ? d.background : undefined,
|
background: closed ? d.background : undefined,
|
||||||
foregroundSource: closed ? d.foregroundSource : undefined,
|
foregroundSource: closed ? d.foregroundSource : undefined,
|
||||||
backgroundSource: closed ? d.backgroundSource : undefined,
|
backgroundSource: closed ? d.backgroundSource : undefined,
|
||||||
|
effectiveForeground,
|
||||||
|
effectiveBackground,
|
||||||
|
effectiveHatchId,
|
||||||
strokeWeightOverride: d.weightMm,
|
strokeWeightOverride: d.weightMm,
|
||||||
strokeWeightSource: d.strokeWeightSource,
|
strokeWeightSource: d.strokeWeightSource,
|
||||||
hatchSource: closed ? d.hatchSource : undefined,
|
hatchSource: closed ? d.hatchSource : undefined,
|
||||||
|
|||||||
@@ -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`).
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+602
-144
@@ -104,6 +104,32 @@ export function useGripEditing(opts: {
|
|||||||
return add(st.start, scale(d, st.runLength / 2));
|
return add(st.start, scale(d, st.runLength / 2));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Welchem Element + lokalem Vertex-Index ein Griff im flachen `grips`-Array
|
||||||
|
// gehört (Mehrfachauswahl, Nutzer-Report: Griffe verschwanden komplett,
|
||||||
|
// sobald 2 Elemente gewählt waren — die alte Logik kannte nur GENAU EIN
|
||||||
|
// selektiertes Element). Leer (`{}`) bei den exklusiven Einzel-Fällen
|
||||||
|
// (Öffnung/Treppe/Decke/Raum), die weiterhin nur einzeln editierbar sind.
|
||||||
|
interface GripOwner {
|
||||||
|
drawingId?: string;
|
||||||
|
wallId?: string;
|
||||||
|
localIndex: number;
|
||||||
|
}
|
||||||
|
const gripOwners: GripOwner[] = [];
|
||||||
|
// [ersterFlachIndex, letzterFlachIndex] DESSELBEN Elements — für die Anker-
|
||||||
|
// Auflösung (Shift-Ortho, Tab-Feld-Controller): der "Nachbar-Griff" muss aus
|
||||||
|
// demselben Element kommen, nicht irgendeinem anderen der Mehrfachauswahl.
|
||||||
|
const gripOwnerRange: [number, number][] = [];
|
||||||
|
|
||||||
|
// Bei Mehrfachauswahl fehlt `selectedDrawingIds` manchmal trotzdem (nur die
|
||||||
|
// Einzel-ID `selectedDrawingId` gesetzt, s. useSelectionState-Konvention) —
|
||||||
|
// derselbe Fallback wie an anderen Stellen im Projekt.
|
||||||
|
const effectiveDrawingIds =
|
||||||
|
selectedDrawingIds.length > 0
|
||||||
|
? selectedDrawingIds
|
||||||
|
: selectedDrawingId
|
||||||
|
? [selectedDrawingId]
|
||||||
|
: [];
|
||||||
|
|
||||||
const grips: Vec2[] = (() => {
|
const grips: Vec2[] = (() => {
|
||||||
// Öffnung (Fenster/Tür): EIN Mittelpunkt-Griff auf der Wandachse. Ziehen
|
// Öffnung (Fenster/Tür): EIN Mittelpunkt-Griff auf der Wandachse. Ziehen
|
||||||
// verschiebt die Öffnung entlang der Wirts-Wand (siehe applyGrip →
|
// verschiebt die Öffnung entlang der Wirts-Wand (siehe applyGrip →
|
||||||
@@ -113,7 +139,10 @@ export function useGripEditing(opts: {
|
|||||||
const w = op && project.walls.find((x) => x.id === op.hostWallId);
|
const w = op && project.walls.find((x) => x.id === op.hostWallId);
|
||||||
if (op && w) {
|
if (op && w) {
|
||||||
const axis = normalize(sub(w.end, w.start));
|
const axis = normalize(sub(w.end, w.start));
|
||||||
return [add(w.start, scale(axis, op.position + op.width / 2))];
|
const pt = add(w.start, scale(axis, op.position + op.width / 2));
|
||||||
|
gripOwners.push({ localIndex: 0 });
|
||||||
|
gripOwnerRange.push([0, 0]);
|
||||||
|
return [pt];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -121,34 +150,94 @@ export function useGripEditing(opts: {
|
|||||||
// ganze Treppe (applyGrip → moveStairBy). Auswahl exklusiv.
|
// ganze Treppe (applyGrip → moveStairBy). Auswahl exklusiv.
|
||||||
if (selectedStairId) {
|
if (selectedStairId) {
|
||||||
const st = (project.stairs ?? []).find((s) => s.id === selectedStairId);
|
const st = (project.stairs ?? []).find((s) => s.id === selectedStairId);
|
||||||
return st ? [stairMoveHandle(st)] : [];
|
if (!st) return [];
|
||||||
|
gripOwners.push({ localIndex: 0 });
|
||||||
|
gripOwnerRange.push([0, 0]);
|
||||||
|
return [stairMoveHandle(st)];
|
||||||
}
|
}
|
||||||
if (selectedDrawing) return drawingVertices(selectedDrawing);
|
// Decke: die Umriss-Vertices als Griffe (nur bei genau einer gewählten Decke,
|
||||||
if (selectedWallIds.length === 1 && !selectedDrawingId) {
|
// solange kein 2D-Element/Wand mitgewählt ist — kein Multi-Select für Decken).
|
||||||
const w = project.walls.find((x) => x.id === selectedWallIds[0]);
|
if (selectedCeiling && effectiveDrawingIds.length === 0 && selectedWallIds.length === 0) {
|
||||||
if (w) return [w.start, w.end];
|
const pts = [...selectedCeiling.outline];
|
||||||
}
|
pts.forEach((_, i) => { gripOwners.push({ localIndex: i }); gripOwnerRange.push([0, pts.length - 1]); });
|
||||||
// Decke: die Umriss-Vertices als Griffe (nur bei genau einer gewählten Decke).
|
return pts;
|
||||||
if (selectedCeiling && !selectedDrawingId && selectedWallIds.length === 0) {
|
|
||||||
return [...selectedCeiling.outline];
|
|
||||||
}
|
}
|
||||||
// Raum: die Umriss-Vertices als Griffe (nur bei genau einem gewählten Raum).
|
// Raum: die Umriss-Vertices als Griffe (nur bei genau einem gewählten Raum).
|
||||||
if (
|
if (
|
||||||
selectedRoom &&
|
selectedRoom &&
|
||||||
!selectedDrawingId &&
|
effectiveDrawingIds.length === 0 &&
|
||||||
selectedWallIds.length === 0 &&
|
selectedWallIds.length === 0 &&
|
||||||
!selectedCeiling
|
!selectedCeiling
|
||||||
) {
|
) {
|
||||||
return [...selectedRoom.boundary];
|
const pts = [...selectedRoom.boundary];
|
||||||
|
pts.forEach((_, i) => { gripOwners.push({ localIndex: i }); gripOwnerRange.push([0, pts.length - 1]); });
|
||||||
|
return pts;
|
||||||
}
|
}
|
||||||
return [];
|
// Mehrfachauswahl (Regel- UND Standardfall): je gewähltem 2D-Element bzw.
|
||||||
|
// je gewählter Wand ein eigener, in sich zusammenhängender Vertex-Block.
|
||||||
|
// Wand UND 2D-Element schliessen sich weiterhin gegenseitig aus (wie schon
|
||||||
|
// bei der Auswahl selbst — s. die vielen `!selectedDrawingId`-Wächter, die
|
||||||
|
// dieses Modul vorher hatte).
|
||||||
|
const out: Vec2[] = [];
|
||||||
|
const pushGroup = (pts: Vec2[], owner: { drawingId?: string; wallId?: string }) => {
|
||||||
|
if (pts.length === 0) return;
|
||||||
|
const start = out.length;
|
||||||
|
pts.forEach((p, i) => {
|
||||||
|
out.push(p);
|
||||||
|
gripOwners.push({ ...owner, localIndex: i });
|
||||||
|
});
|
||||||
|
const end = out.length - 1;
|
||||||
|
for (let i = start; i <= end; i++) gripOwnerRange.push([start, end]);
|
||||||
|
};
|
||||||
|
for (const id of effectiveDrawingIds) {
|
||||||
|
const d = project.drawings2d.find((x) => x.id === id);
|
||||||
|
if (d) pushGroup(drawingVertices(d), { drawingId: id });
|
||||||
|
}
|
||||||
|
if (effectiveDrawingIds.length === 0) {
|
||||||
|
for (const id of selectedWallIds) {
|
||||||
|
const w = project.walls.find((x) => x.id === id);
|
||||||
|
if (w) pushGroup([w.start, w.end], { wallId: id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Pro Seite des selektierten Elements ein Griff: Kanten-Mittelpunkt + nach
|
// Bezier-Hilfslinien (Anker → Kontrollpunkt-Griff), gestrichelt gerendert,
|
||||||
// AUSSEN zeigende Einheitsnormale + die beiden Vertex-Indizes (passend zur
|
// damit sichtbar/nachvollziehbar ist, welcher Griff welchen Kurvenabschnitt
|
||||||
// `grips`-/`drawingVertices`-Reihenfolge). Die Außenrichtung wird über das
|
// steuert (Nutzer-Wunsch: „Hilfslinien sollten schon sichtbar sein um sie
|
||||||
// Vorzeichen des Skalarprodukts der Kanten-Normale mit (mid − centroid)
|
// zu steuern" — wie bei Illustrator/Inkscape üblich). `pts` folgt dem Muster
|
||||||
// gewählt (vom Polygon-Schwerpunkt weg).
|
// Anker·Griff·Griff·Anker·… (s. `Drawing2DGeom["bezier"]`); jeder Anker
|
||||||
|
// bekommt eine Linie zu seinem/seinen benachbarten Griff(en).
|
||||||
|
const gripHandleLines: { a: Vec2; b: Vec2 }[] = (() => {
|
||||||
|
const out: { a: Vec2; b: Vec2 }[] = [];
|
||||||
|
for (const id of effectiveDrawingIds) {
|
||||||
|
const d = project.drawings2d.find((x) => x.id === id);
|
||||||
|
const g = d?.geom;
|
||||||
|
if (!g || g.shape !== "bezier") continue;
|
||||||
|
for (let i = 0; i + 3 < g.pts.length; i += 3) {
|
||||||
|
out.push({ a: g.pts[i], b: g.pts[i + 1] }); // Anker → Griff 1
|
||||||
|
out.push({ a: g.pts[i + 3], b: g.pts[i + 2] }); // nächster Anker → Griff 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Nachbar-Griff-Index INNERHALB desselben Elements (für Anker-Auflösung).
|
||||||
|
// null = kein Nachbar möglich (Einzelgriff-Element wie Kreis/Öffnung/Treppe).
|
||||||
|
const neighborGripIndex = (index: number): number | null => {
|
||||||
|
const range = gripOwnerRange[index];
|
||||||
|
if (!range) return null;
|
||||||
|
const [start, end] = range;
|
||||||
|
if (index > start) return index - 1;
|
||||||
|
if (end > start) return start + 1;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pro Seite JEDES gewählten Elements ein Griff: Kanten-Mittelpunkt + nach
|
||||||
|
// AUSSEN zeigende Einheitsnormale + die beiden lokalen Vertex-Indizes +
|
||||||
|
// `owner` (welchem Element diese Kante gehört — Mehrfachauswahl). Die
|
||||||
|
// Außenrichtung wird über das Vorzeichen des Skalarprodukts der Kanten-
|
||||||
|
// Normale mit (mid − centroid) gewählt (vom Polygon-Schwerpunkt weg).
|
||||||
// • rect: 4 Kanten (geschlossen).
|
// • rect: 4 Kanten (geschlossen).
|
||||||
// • polyline mit `closed`: Kanten zwischen aufeinanderfolgenden Vertices
|
// • polyline mit `closed`: Kanten zwischen aufeinanderfolgenden Vertices
|
||||||
// inkl. Schlusskante (letzter → erster).
|
// inkl. Schlusskante (letzter → erster).
|
||||||
@@ -160,7 +249,7 @@ export function useGripEditing(opts: {
|
|||||||
// Verschieben EINER Kante einer komplexen Polylinie, §Task B).
|
// Verschieben EINER Kante einer komplexen Polylinie, §Task B).
|
||||||
const edgeGrips: EdgeGrip[] = (() => {
|
const edgeGrips: EdgeGrip[] = (() => {
|
||||||
// Geschlossener Ring aus Vertices → Kanten-Griffe mit Außennormale.
|
// Geschlossener Ring aus Vertices → Kanten-Griffe mit Außennormale.
|
||||||
const ringEdges = (verts: Vec2[]): EdgeGrip[] => {
|
const ringEdges = (verts: Vec2[], owner: { drawingId?: string; wallId?: string }): EdgeGrip[] => {
|
||||||
const n = verts.length;
|
const n = verts.length;
|
||||||
if (n < 3) return [];
|
if (n < 3) return [];
|
||||||
// Schwerpunkt (Mittel der Eckpunkte) als Innenbezug.
|
// Schwerpunkt (Mittel der Eckpunkte) als Innenbezug.
|
||||||
@@ -177,7 +266,7 @@ export function useGripEditing(opts: {
|
|||||||
// Nach außen orientieren: Normale soll von centroid → mid weisen.
|
// Nach außen orientieren: Normale soll von centroid → mid weisen.
|
||||||
const outward = sub(mid, centroid);
|
const outward = sub(mid, centroid);
|
||||||
if (nrm.x * outward.x + nrm.y * outward.y < 0) nrm = scale(nrm, -1);
|
if (nrm.x * outward.x + nrm.y * outward.y < 0) nrm = scale(nrm, -1);
|
||||||
out.push({ mid, normal: nrm, aIndex: i, bIndex: (i + 1) % n });
|
out.push({ mid, normal: nrm, aIndex: i, bIndex: (i + 1) % n, owner });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
@@ -185,7 +274,7 @@ export function useGripEditing(opts: {
|
|||||||
// Schlusskante. `normal` dient nur der Griff-Darstellung (leftNormal der
|
// Schlusskante. `normal` dient nur der Griff-Darstellung (leftNormal der
|
||||||
// Kante); die Bewegung ist frei (volles Delta), nicht auf die Normale
|
// Kante); die Bewegung ist frei (volles Delta), nicht auf die Normale
|
||||||
// beschränkt — daher `free: true`.
|
// beschränkt — daher `free: true`.
|
||||||
const openEdges = (verts: Vec2[]): EdgeGrip[] => {
|
const openEdges = (verts: Vec2[], owner: { drawingId?: string; wallId?: string }): EdgeGrip[] => {
|
||||||
const n = verts.length;
|
const n = verts.length;
|
||||||
if (n < 2) return [];
|
if (n < 2) return [];
|
||||||
const out: EdgeGrip[] = [];
|
const out: EdgeGrip[] = [];
|
||||||
@@ -194,48 +283,60 @@ export function useGripEditing(opts: {
|
|||||||
const b = verts[i + 1];
|
const b = verts[i + 1];
|
||||||
const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||||
const nrm = normalize(leftNormal(sub(b, a)));
|
const nrm = normalize(leftNormal(sub(b, a)));
|
||||||
out.push({ mid, normal: nrm, aIndex: i, bIndex: i + 1, free: true });
|
out.push({ mid, normal: nrm, aIndex: i, bIndex: i + 1, free: true, owner });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
if (selectedDrawing) {
|
// Decke: geschlossener Ring aus dem Umriss (Auswahl exklusiv, wie bei grips).
|
||||||
const g = selectedDrawing.geom;
|
if (selectedCeiling && effectiveDrawingIds.length === 0 && selectedWallIds.length === 0) {
|
||||||
if (g.shape === "rect" || g.shape === "image") return ringEdges(grips);
|
return ringEdges(selectedCeiling.outline, {});
|
||||||
if (g.shape === "polyline" && g.closed) return ringEdges(grips);
|
|
||||||
if (g.shape === "polyline" && !g.closed) return openEdges(grips);
|
|
||||||
if (g.shape === "line") return openEdges(grips);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (selectedWallIds.length === 1 && !selectedDrawingId) {
|
|
||||||
const w = project.walls.find((x) => x.id === selectedWallIds[0]);
|
|
||||||
if (w) {
|
|
||||||
const mid: Vec2 = { x: (w.start.x + w.end.x) / 2, y: (w.start.y + w.end.y) / 2 };
|
|
||||||
const nrm = normalize(leftNormal(sub(w.end, w.start)));
|
|
||||||
return [{ mid, normal: nrm, aIndex: 0, bIndex: 1 }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Decke: geschlossener Ring aus dem Umriss.
|
|
||||||
if (selectedCeiling && !selectedDrawingId && selectedWallIds.length === 0) {
|
|
||||||
return ringEdges(grips);
|
|
||||||
}
|
}
|
||||||
// Raum: geschlossener Ring aus dem Umriss.
|
// Raum: geschlossener Ring aus dem Umriss.
|
||||||
if (
|
if (
|
||||||
selectedRoom &&
|
selectedRoom &&
|
||||||
!selectedDrawingId &&
|
effectiveDrawingIds.length === 0 &&
|
||||||
selectedWallIds.length === 0 &&
|
selectedWallIds.length === 0 &&
|
||||||
!selectedCeiling
|
!selectedCeiling
|
||||||
) {
|
) {
|
||||||
return ringEdges(grips);
|
return ringEdges(selectedRoom.boundary, {});
|
||||||
}
|
}
|
||||||
return [];
|
const out: EdgeGrip[] = [];
|
||||||
|
for (const id of effectiveDrawingIds) {
|
||||||
|
const d = project.drawings2d.find((x) => x.id === id);
|
||||||
|
if (!d) continue;
|
||||||
|
const verts = drawingVertices(d);
|
||||||
|
const g = d.geom;
|
||||||
|
const owner = { drawingId: id };
|
||||||
|
if (g.shape === "rect" || g.shape === "image") out.push(...ringEdges(verts, owner));
|
||||||
|
else if (g.shape === "polyline" && g.closed) out.push(...ringEdges(verts, owner));
|
||||||
|
else if (g.shape === "polyline" && !g.closed) out.push(...openEdges(verts, owner));
|
||||||
|
else if (g.shape === "spline" && g.closed) out.push(...ringEdges(verts, owner));
|
||||||
|
else if (g.shape === "spline" && !g.closed) out.push(...openEdges(verts, owner));
|
||||||
|
else if (g.shape === "line") out.push(...openEdges(verts, owner));
|
||||||
|
}
|
||||||
|
if (effectiveDrawingIds.length === 0) {
|
||||||
|
for (const id of selectedWallIds) {
|
||||||
|
const w = project.walls.find((x) => x.id === id);
|
||||||
|
if (!w) continue;
|
||||||
|
const mid: Vec2 = { x: (w.start.x + w.end.x) / 2, y: (w.start.y + w.end.y) / 2 };
|
||||||
|
const nrm = normalize(leftNormal(sub(w.end, w.start)));
|
||||||
|
out.push({ mid, normal: nrm, aIndex: 0, bIndex: 1, owner: { wallId: id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Zuletzt angewandter Kanten-Offset während eines Edge-Grip-Drags (Anker).
|
// Bereits angewandtes 2D-Delta seit Grab-Beginn eines Kanten-Drags, relativ
|
||||||
// null = kein laufender Drag; der erste Move-Event verankert nur (kein Sprung).
|
// zum ORIGINALEN Kanten-Mittelpunkt (`edge.mid` — die beim Grab erfasste
|
||||||
const edgeDragOffsetRef = useRef<number | null>(null);
|
// Momentaufnahme, ändert sich während des Drags nicht). null = noch kein
|
||||||
// Freies Kanten-Ziehen (offene Polylinie/Linie): letzter roher Cursor, gegen den
|
// Move-Event verarbeitet (erstes Event verankert nur, kein Sprung). Ein
|
||||||
// das inkrementelle 2D-Delta gebildet wird. null = Drag noch nicht verankert.
|
// ABSOLUTER Zielpunkt (aus Maus ODER gelocktem Feldwert) wird jeden Frame neu
|
||||||
const edgeFreeLastRef = useRef<Vec2 | null>(null);
|
// berechnet; angewandt wird nur die Differenz zum zuletzt angewandten Delta
|
||||||
|
// (moveEdgeOf/moveRoomEdge/moveCeilingEdge sind additiv). Dieselbe Formel
|
||||||
|
// trägt sowohl den geführten (Normalen-)Fall als auch den freien Fall — vorher
|
||||||
|
// zwei getrennte, rein inkrementelle Mechanismen ohne Bezug zum Ursprung, was
|
||||||
|
// eine getippte Ziel-Distanz (s. edgeEdit unten) unmöglich gemacht hätte.
|
||||||
|
const edgeAppliedDeltaRef = useRef<Vec2 | null>(null);
|
||||||
// Körper-Verschieben (2D): koinzidente Endpunkte ANDERER gewählter Elemente, die
|
// Körper-Verschieben (2D): koinzidente Endpunkte ANDERER gewählter Elemente, die
|
||||||
// an einer Ecke des verschobenen Elements hängen, werden um dasselbe Delta
|
// an einer Ecke des verschobenen Elements hängen, werden um dasselbe Delta
|
||||||
// mitgeführt (gemeinsame Ecke bleibt verbunden). Lazy beim ersten Move-Event
|
// mitgeführt (gemeinsame Ecke bleibt verbunden). Lazy beim ersten Move-Event
|
||||||
@@ -317,6 +418,12 @@ export function useGripEditing(opts: {
|
|||||||
// Letzter roher Cursor des laufenden Vertex-Drags (für die Lock-Auflösung beim
|
// Letzter roher Cursor des laufenden Vertex-Drags (für die Lock-Auflösung beim
|
||||||
// Tippen, wenn gerade kein Move-Event kommt). Plus der gezogene Griff-Index.
|
// Tippen, wenn gerade kein Move-Event kommt). Plus der gezogene Griff-Index.
|
||||||
const gripDragInfoRef = useRef<{ index: number; raw: Vec2 } | null>(null);
|
const gripDragInfoRef = useRef<{ index: number; raw: Vec2 } | null>(null);
|
||||||
|
// Position des gezogenen Griffs VOR dem Drag (beim ersten Move-Event nach dem
|
||||||
|
// Grab verankert) — Bezugspunkt für die Flucht-Kandidaten unten (die
|
||||||
|
// Nachbarkanten müssen an ihrer URSPRÜNGLICHEN, nicht der schon verschobenen
|
||||||
|
// Position gemessen werden, sonst würde die Richtung während des Ziehens
|
||||||
|
// mitdriften).
|
||||||
|
const gripOriginRef = useRef<Vec2 | null>(null);
|
||||||
|
|
||||||
// Endpunkt aus Anker + (gelockten/Live-)Länge/Winkel — wie lineEndFromFields,
|
// Endpunkt aus Anker + (gelockten/Live-)Länge/Winkel — wie lineEndFromFields,
|
||||||
// hier für den Vertex-Drag. Ungelockte Größen folgen dem rohen Cursor `raw`.
|
// hier für den Vertex-Drag. Ungelockte Größen folgen dem rohen Cursor `raw`.
|
||||||
@@ -361,39 +468,136 @@ export function useGripEditing(opts: {
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fixer Bezug (Anker) für Länge/Winkel eines gezogenen Punkt-Griffs. Normal
|
||||||
|
// die BENACHBARTE Ecke DESSELBEN Elements (Polylinien-Semantik) — bei Kreis/
|
||||||
|
// Bogen ist das falsch (deren Griffe liegen auf dem Ring, "Länge" muss der
|
||||||
|
// RADIUS sein, also relativ zum ZENTRUM). Ohne diesen Sonderfall blieb der
|
||||||
|
// Kreis-Radius-Griff sogar ganz ohne Anker (nur 1 Griff, kein Nachbar).
|
||||||
|
// Gemeinsam genutzt vom Tab-Feld-Controller UND dem Shift-Ortho-Bezug UND
|
||||||
|
// dem Cursor-HUD (Länge/Winkel-Anzeige neben der Maus).
|
||||||
|
const resolveGripAnchor = (index: number): Vec2 | null => {
|
||||||
|
// Bei Mehrfachauswahl gehört der gezogene Griff evtl. NICHT dem einzeln
|
||||||
|
// selektierten `selectedDrawing`, sondern einem anderen Element der
|
||||||
|
// Auswahl — das eigentliche Element über `gripOwners` auflösen.
|
||||||
|
const owner = gripOwners[index];
|
||||||
|
const ownerDrawing = owner?.drawingId
|
||||||
|
? project.drawings2d.find((d) => d.id === owner.drawingId)
|
||||||
|
: selectedDrawing;
|
||||||
|
const g = ownerDrawing?.geom;
|
||||||
|
if (g && (g.shape === "circle" || g.shape === "arc" || g.shape === "ellipse")) return g.center;
|
||||||
|
const refIndex = neighborGripIndex(index);
|
||||||
|
return refIndex != null ? grips[refIndex] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Die beiden (fixen) Polygon-Nachbarn eines gezogenen Punkt-Griffs — für die
|
||||||
|
// Flucht-Kandidaten unten. Anders als `neighborGripIndex` (EIN Nachbar,
|
||||||
|
// reicht für Länge/Winkel-Anker) werden hier BEIDE gebraucht: ein Vielecks-
|
||||||
|
// Eckpunkt hängt an ZWEI Kanten, jede liefert eine eigene mögliche Flucht-
|
||||||
|
// Richtung. Kennt geschlossene Ringe (rect/geschlossene Polylinie/Raum/
|
||||||
|
// Decke — Nachbar 0/n-1 wickelt um) vs. offene Ketten (offene Polylinie/
|
||||||
|
// Linie — Enden haben nur einen Nachbarn). Kreis/Bogen/Wand/Öffnung/Treppe
|
||||||
|
// haben keine Polygon-Nachbarn (kein Vielecks-Eckpunkt) → {null,null}.
|
||||||
|
const ringNeighborPts = (
|
||||||
|
pts: Vec2[],
|
||||||
|
i: number,
|
||||||
|
closed: boolean,
|
||||||
|
): { prev: Vec2 | null; next: Vec2 | null } => {
|
||||||
|
const n = pts.length;
|
||||||
|
const prevIdx = i > 0 ? i - 1 : closed ? n - 1 : null;
|
||||||
|
const nextIdx = i < n - 1 ? i + 1 : closed ? 0 : null;
|
||||||
|
return {
|
||||||
|
prev: prevIdx != null && prevIdx !== i ? pts[prevIdx] : null,
|
||||||
|
next: nextIdx != null && nextIdx !== i && nextIdx !== prevIdx ? pts[nextIdx] : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const resolvePolygonNeighbors = (index: number): { prev: Vec2 | null; next: Vec2 | null } => {
|
||||||
|
const owner = gripOwners[index];
|
||||||
|
const none = { prev: null, next: null };
|
||||||
|
if (!owner?.drawingId && !owner?.wallId) {
|
||||||
|
// Raum/Decke (exklusiv) — kein Owner, `localIndex` fehlt dann auch, also
|
||||||
|
// ist der flache `index` selbst der Ring-Index.
|
||||||
|
const pts = selectedCeiling
|
||||||
|
? selectedCeiling.outline
|
||||||
|
: selectedRoom
|
||||||
|
? selectedRoom.boundary
|
||||||
|
: null;
|
||||||
|
return pts ? ringNeighborPts(pts, owner?.localIndex ?? index, true) : none;
|
||||||
|
}
|
||||||
|
if (owner.wallId) return none; // Wand: nur 2 Punkte, kein Vielecks-Nachbar
|
||||||
|
const d = project.drawings2d.find((x) => x.id === owner.drawingId);
|
||||||
|
const g = d?.geom;
|
||||||
|
if (!g) return none;
|
||||||
|
if (g.shape === "polyline" || g.shape === "spline")
|
||||||
|
return ringNeighborPts(g.pts, owner.localIndex, !!g.closed);
|
||||||
|
if (g.shape === "rect" || g.shape === "image") {
|
||||||
|
const corners = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
|
||||||
|
return ringNeighborPts(corners, owner.localIndex, true);
|
||||||
|
}
|
||||||
|
if (g.shape === "line") return ringNeighborPts([g.a, g.b], owner.localIndex, false);
|
||||||
|
return none; // circle/arc: kein Vielecks-Eckpunkt
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rohen Cursor `raw` auf die nächstliegende von mehreren Kandidaten-Achsen
|
||||||
|
// projizieren (jeweils Stützpunkt + Richtung) — "nächstliegend" = kleinster
|
||||||
|
// senkrechter Abstand von `raw` zur (unendlich verlängerten) Achse. Deckt
|
||||||
|
// Shift-Ortho (Welt-H/V) UND Flucht-Snap (Richtung anliegender, fixer
|
||||||
|
// Kanten) in einem einheitlichen Mechanismus ab.
|
||||||
|
const closestAxisPoint = (raw: Vec2, candidates: { point: Vec2; dir: Vec2 }[]): Vec2 | null => {
|
||||||
|
let best: Vec2 | null = null;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (const c of candidates) {
|
||||||
|
const len = Math.hypot(c.dir.x, c.dir.y);
|
||||||
|
if (len < 1e-9) continue; // entartete Richtung (koinzidenter Nachbar) → überspringen
|
||||||
|
const u = { x: c.dir.x / len, y: c.dir.y / len };
|
||||||
|
const rel = sub(raw, c.point);
|
||||||
|
const t = rel.x * u.x + rel.y * u.y;
|
||||||
|
const proj = add(c.point, scale(u, t));
|
||||||
|
const dist = Math.hypot(raw.x - proj.x, raw.y - proj.y);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
best = proj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
// Stabiler Ref auf cycleGripEditField (der globale Tab-Handler hat leere Deps).
|
// Stabiler Ref auf cycleGripEditField (der globale Tab-Handler hat leere Deps).
|
||||||
const cycleGripEditFieldRef = useRef<() => void>(() => {});
|
const cycleGripEditFieldRef = useRef<() => void>(() => {});
|
||||||
// Tab im Vertex-Drag: Feld-Controller öffnen/zyklen (length ↔ angle). Der
|
// Tab im Vertex-Drag: Feld-Controller öffnen/zyklen (length ↔ angle).
|
||||||
// Anker ist normalerweise die BENACHBARTE Ecke (Polylinien-Semantik: Länge/
|
// • ÖFFNEN (noch kein gripEdit): friert BEIDE Werte an der aktuellen
|
||||||
// Winkel relativ zum Nachbarpunkt) — bei Kreis/Bogen ist das falsch (deren
|
// Position ein (`locks` sofort vollständig befüllt statt leer) — die
|
||||||
// Griffe liegen auf dem Ring, "Länge" muss der RADIUS sein, also relativ
|
// Maus hat ab jetzt keinen Einfluss mehr, bis man explizit wieder in ein
|
||||||
// zum ZENTRUM). Ohne diese Sonderfälle blieb der Kreis-Radius-Griff sogar
|
// Feld tabbt (verhindert, dass ein Zittern der Hand beim Greifen zur
|
||||||
// ganz ohne Anker (nur 1 Griff, kein Nachbar) → Tab tat nichts.
|
// Tastatur den Punkt noch verschiebt).
|
||||||
|
// • ZYKELN (gripEdit schon offen): wechselt zum anderen Feld und LÖST
|
||||||
|
// dessen Lock (frei für die Maus) — das Feld, das man verlässt, bleibt
|
||||||
|
// exakt auf seinem letzten Wert fixiert (getippt oder eingefroren).
|
||||||
|
// Nutzer-Wunsch: nach dem ersten getippten Wert soll das ANDERE Feld
|
||||||
|
// nicht sofort wieder der Maus folgen, sondern erst nach explizitem Tab
|
||||||
|
// dorthin — sonst „wird der Winkel nie richtig fix".
|
||||||
const cycleGripEditField = () => {
|
const cycleGripEditField = () => {
|
||||||
const info = gripDragInfoRef.current;
|
const info = gripDragInfoRef.current;
|
||||||
if (!info) return;
|
if (!info) return;
|
||||||
const g = selectedDrawing?.geom;
|
const anchor = resolveGripAnchor(info.index);
|
||||||
const anchor =
|
|
||||||
g && (g.shape === "circle" || g.shape === "arc")
|
|
||||||
? g.center
|
|
||||||
: (() => {
|
|
||||||
const refIndex = info.index > 0 ? info.index - 1 : grips.length > 1 ? 1 : -1;
|
|
||||||
return refIndex >= 0 ? grips[refIndex] : null;
|
|
||||||
})();
|
|
||||||
if (!anchor) return; // ohne Anker keine polare Eingabe möglich
|
if (!anchor) return; // ohne Anker keine polare Eingabe möglich
|
||||||
setGripEdit((prev) =>
|
setGripEdit((prev) => {
|
||||||
prev
|
if (!prev) {
|
||||||
? { ...prev, active: prev.active === "length" ? "angle" : "length" }
|
const frozen = grips[info.index];
|
||||||
: { anchor, active: "length", locks: {} },
|
const dx = frozen.x - anchor.x;
|
||||||
);
|
const dy = frozen.y - anchor.y;
|
||||||
|
const angle = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
|
||||||
|
return { anchor, active: "length", locks: { length: Math.hypot(dx, dy), angle } };
|
||||||
|
}
|
||||||
|
const active = prev.active === "length" ? "angle" : "length";
|
||||||
|
const locks = { ...prev.locks };
|
||||||
|
delete locks[active];
|
||||||
|
return { ...prev, active, locks };
|
||||||
|
});
|
||||||
};
|
};
|
||||||
cycleGripEditFieldRef.current = cycleGripEditField;
|
cycleGripEditFieldRef.current = cycleGripEditField;
|
||||||
|
|
||||||
// Globaler Tab-Handler (Feld-Zyklus/Zahlen-Tippen) — siehe state/useKeyboardShortcuts.ts.
|
// Getippte Zahl im Vertex-Drag: aktives Feld locken, aufs nächste vorrücken
|
||||||
useCommandTabShortcut(engineRef, commandLineRef, gripDragInfoRef, gripEditRef, cycleGripEditFieldRef);
|
// (das andere Feld bleibt unverändert fixiert, s. o.).
|
||||||
|
|
||||||
// Getippte Zahl im Vertex-Drag: aktives Feld locken, aufs nächste vorrücken und
|
|
||||||
// den Zielpunkt sofort mit dem gelockten Wert neu setzen.
|
|
||||||
const submitGripEditValue = (value: number) => {
|
const submitGripEditValue = (value: number) => {
|
||||||
const ge = gripEditRef.current;
|
const ge = gripEditRef.current;
|
||||||
const info = gripDragInfoRef.current;
|
const info = gripDragInfoRef.current;
|
||||||
@@ -406,6 +610,149 @@ export function useGripEditing(opts: {
|
|||||||
applyGrip(info.index, pt);
|
applyGrip(info.index, pt);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Feld-Controller schließen (Escape MIT Fokus in der Befehlszeile, s.
|
||||||
|
// App.tsx) — beendet NICHT den ganzen Griff-Drag (der bleibt bewaffnet,
|
||||||
|
// Maus steuert wieder normal wie vor dem ersten Tab).
|
||||||
|
const closeGripEditField = () => {
|
||||||
|
setGripEdit(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dasselbe Feld-Controller-Muster wie oben (gripEdit), jetzt für einen Kanten-
|
||||||
|
// Griff (Dreieck-Anfasser, „Seite parallel verschieben"). Ein Feld genügt für
|
||||||
|
// geführte Kanten (rect/geschlossene Polylinie/Wand — Bewegung ist auf die
|
||||||
|
// Normale beschränkt, "Länge" ist der vorzeichenbehaftete Abstand); freie
|
||||||
|
// Kanten (offene Polylinie/Linie) bekommen wie ein Punkt-Griff Länge+Winkel.
|
||||||
|
const [edgeEdit, setEdgeEdit] = useState<{
|
||||||
|
/** Kanten-Mittelpunkt bei Grab-Beginn (fix für die ganze Dauer des Drags). */
|
||||||
|
anchor: Vec2;
|
||||||
|
active: "length" | "angle";
|
||||||
|
locks: { length?: number; angle?: number };
|
||||||
|
} | null>(null);
|
||||||
|
const edgeEditRef = useRef<typeof edgeEdit>(null);
|
||||||
|
edgeEditRef.current = edgeEdit;
|
||||||
|
// Letzter roher Cursor + der gezogene Kanten-Griff (Momentaufnahme vom Grab).
|
||||||
|
const edgeDragInfoRef = useRef<{ edge: EdgeGrip; raw: Vec2 } | null>(null);
|
||||||
|
|
||||||
|
// Zielpunkt aus Anker + (gelockten/Live-)Werten — bei freien Kanten wie ein
|
||||||
|
// Punkt-Griff (polar), bei geführten Kanten nur entlang der festen Normale.
|
||||||
|
const edgeEditPoint = (
|
||||||
|
edge: EdgeGrip,
|
||||||
|
anchor: Vec2,
|
||||||
|
locks: { length?: number; angle?: number },
|
||||||
|
raw: Vec2,
|
||||||
|
): Vec2 => {
|
||||||
|
if (edge.free) return gripEditPoint(anchor, locks, raw);
|
||||||
|
const live = dot(sub(raw, anchor), edge.normal);
|
||||||
|
const len = locks.length != null ? locks.length : live;
|
||||||
|
return add(anchor, scale(edge.normal, len));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Felder fürs Befehlsfeld während des Kanten-Drags. Geführte Kanten zeigen
|
||||||
|
// NUR "Länge" (kein Winkel — der ist durch die feste Normale vorgegeben).
|
||||||
|
const edgeEditFields = (): GripEditFieldsState[] => {
|
||||||
|
const ee = edgeEdit;
|
||||||
|
const info = edgeDragInfoRef.current;
|
||||||
|
if (!ee || !info) return [];
|
||||||
|
const { edge, raw } = info;
|
||||||
|
if (!edge.free) {
|
||||||
|
const live = dot(sub(raw, ee.anchor), edge.normal);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "length",
|
||||||
|
labelKey: "cmd.field.length",
|
||||||
|
value: ee.locks.length ?? live,
|
||||||
|
locked: ee.locks.length != null,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const dx = raw.x - ee.anchor.x;
|
||||||
|
const dy = raw.y - ee.anchor.y;
|
||||||
|
const liveLen = Math.hypot(dx, dy);
|
||||||
|
const liveAng = ((((Math.atan2(dy, dx) * 180) / Math.PI) % 360) + 360) % 360;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "length",
|
||||||
|
labelKey: "cmd.field.length",
|
||||||
|
value: ee.locks.length ?? liveLen,
|
||||||
|
locked: ee.locks.length != null,
|
||||||
|
active: ee.active === "length",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "angle",
|
||||||
|
labelKey: "cmd.field.angle",
|
||||||
|
value: ee.locks.angle ?? liveAng,
|
||||||
|
locked: ee.locks.angle != null,
|
||||||
|
active: ee.active === "angle",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleEdgeEditFieldRef = useRef<() => void>(() => {});
|
||||||
|
// Tab im Kanten-Drag: Feld-Controller öffnen/zyklen — dasselbe Muster wie
|
||||||
|
// cycleGripEditField oben (Öffnen friert BEIDE Werte ein, Zykeln löst NUR
|
||||||
|
// das neu aktive Feld). Geführte Kanten haben nur ein Feld ("Länge" =
|
||||||
|
// Abstand entlang der Normale) → Zykeln tut dort nichts, das eine Feld
|
||||||
|
// bleibt beim Öffnen ohnehin schon eingefroren/fixiert.
|
||||||
|
const cycleEdgeEditField = () => {
|
||||||
|
const info = edgeDragInfoRef.current;
|
||||||
|
if (!info) return;
|
||||||
|
const { edge, raw } = info;
|
||||||
|
setEdgeEdit((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
const anchor = edge.mid;
|
||||||
|
const frozen = edgeEditPoint(edge, anchor, {}, raw);
|
||||||
|
const dx = frozen.x - anchor.x;
|
||||||
|
const dy = frozen.y - anchor.y;
|
||||||
|
if (edge.free) {
|
||||||
|
const angle = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
|
||||||
|
return { anchor, active: "length", locks: { length: Math.hypot(dx, dy), angle } };
|
||||||
|
}
|
||||||
|
const offset = dx * edge.normal.x + dy * edge.normal.y;
|
||||||
|
return { anchor, active: "length", locks: { length: offset } };
|
||||||
|
}
|
||||||
|
if (!edge.free) return prev; // geführte Kante: nur ein Feld, nichts zu zykeln
|
||||||
|
const active = prev.active === "length" ? "angle" : "length";
|
||||||
|
const locks = { ...prev.locks };
|
||||||
|
delete locks[active];
|
||||||
|
return { ...prev, active, locks };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
cycleEdgeEditFieldRef.current = cycleEdgeEditField;
|
||||||
|
|
||||||
|
// Globaler Tab-Handler (Feld-Zyklus/Zahlen-Tippen, Punkt- UND Kanten-Griff) —
|
||||||
|
// siehe state/useKeyboardShortcuts.ts.
|
||||||
|
useCommandTabShortcut(
|
||||||
|
engineRef,
|
||||||
|
commandLineRef,
|
||||||
|
gripDragInfoRef,
|
||||||
|
gripEditRef,
|
||||||
|
cycleGripEditFieldRef,
|
||||||
|
edgeDragInfoRef,
|
||||||
|
edgeEditRef,
|
||||||
|
cycleEdgeEditFieldRef,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Getippte Zahl im Kanten-Drag: Feld locken, bei freien Kanten aufs nächste
|
||||||
|
// Feld vorrücken, Zielpunkt sofort mit dem gelockten Wert neu anwenden.
|
||||||
|
const submitEdgeEditValue = (value: number) => {
|
||||||
|
const ee = edgeEditRef.current;
|
||||||
|
const info = edgeDragInfoRef.current;
|
||||||
|
if (!ee || !info) return;
|
||||||
|
const { edge, raw } = info;
|
||||||
|
const locks = { ...ee.locks, [ee.active]: value };
|
||||||
|
const next = edge.free ? (ee.active === "length" ? "angle" : "length") : ee.active;
|
||||||
|
setEdgeEdit({ ...ee, locks, active: next });
|
||||||
|
const pt = edgeEditPoint(edge, ee.anchor, locks, raw);
|
||||||
|
applyEdgeToward(edge, ee.anchor, pt);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Feld-Controller schließen (Escape MIT Fokus in der Befehlszeile) — beendet
|
||||||
|
// NICHT den Kanten-Drag (der bleibt bewaffnet, Maus steuert wieder normal).
|
||||||
|
const closeEdgeEditField = () => {
|
||||||
|
setEdgeEdit(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Editier-Dispatch: bei gewählter Decke auf die Decken-Aktionen umleiten, sonst
|
// Editier-Dispatch: bei gewählter Decke auf die Decken-Aktionen umleiten, sonst
|
||||||
// auf die Wand/2D-Aktionen. So teilen Plan- UND 3D-Griffe denselben Pfad.
|
// auf die Wand/2D-Aktionen. So teilen Plan- UND 3D-Griffe denselben Pfad.
|
||||||
const applyGrip = (index: number, pt: Vec2) => {
|
const applyGrip = (index: number, pt: Vec2) => {
|
||||||
@@ -438,45 +785,132 @@ export function useGripEditing(opts: {
|
|||||||
}
|
}
|
||||||
if (selectedRoomId) moveRoomGrip(selectedRoomId, index, pt);
|
if (selectedRoomId) moveRoomGrip(selectedRoomId, index, pt);
|
||||||
else if (selectedCeilingId) moveCeilingGrip(selectedCeilingId, index, pt);
|
else if (selectedCeilingId) moveCeilingGrip(selectedCeilingId, index, pt);
|
||||||
else moveGripOf(selectedDrawingId, selectedWallId, index, pt);
|
else {
|
||||||
|
// Mehrfachauswahl: welchem Element dieser Griff gehört, kommt aus
|
||||||
|
// `gripOwners` (nicht mehr aus den singulären selectedDrawingId/-WallId).
|
||||||
|
const owner = gripOwners[index];
|
||||||
|
moveGripOf(owner?.drawingId ?? selectedDrawingId, owner?.wallId ?? selectedWallId, owner?.localIndex ?? index, pt);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const applyEdge = (aIndex: number, bIndex: number, delta: Vec2) => {
|
const applyEdge = (edge: EdgeGrip, delta: Vec2) => {
|
||||||
if (selectedRoomId) moveRoomEdge(selectedRoomId, aIndex, bIndex, delta);
|
if (selectedRoomId) moveRoomEdge(selectedRoomId, edge.aIndex, edge.bIndex, delta);
|
||||||
else if (selectedCeilingId) moveCeilingEdge(selectedCeilingId, aIndex, bIndex, delta);
|
else if (selectedCeilingId) moveCeilingEdge(selectedCeilingId, edge.aIndex, edge.bIndex, delta);
|
||||||
else moveEdgeOf(selectedDrawingId, selectedWallId, aIndex, bIndex, delta);
|
else moveEdgeOf(edge.owner?.drawingId ?? selectedDrawingId, edge.owner?.wallId ?? selectedWallId, edge.aIndex, edge.bIndex, delta);
|
||||||
|
};
|
||||||
|
// Kante auf einen ABSOLUTEN Zielpunkt (relativ zum ORIGINALEN Anker) bewegen —
|
||||||
|
// moveEdge* ist additiv, daher nur die Differenz zum zuletzt angewandten Delta
|
||||||
|
// anwenden (s. edgeAppliedDeltaRef). Trägt sowohl Maus-geführte Bewegung als
|
||||||
|
// auch gelockte Feldwerte UND das Verwerfen (Ziel = Anker selbst → Delta 0,
|
||||||
|
// die Differenz zum vorherigen Delta macht die ganze Verschiebung rückgängig).
|
||||||
|
const applyEdgeToward = (edge: EdgeGrip, anchor: Vec2, pt: Vec2) => {
|
||||||
|
const desired = sub(pt, anchor);
|
||||||
|
const prev = edgeAppliedDeltaRef.current;
|
||||||
|
edgeAppliedDeltaRef.current = desired;
|
||||||
|
if (prev === null) return; // erster Event verankert nur (kein Sprung beim Greifen)
|
||||||
|
const inc = sub(desired, prev);
|
||||||
|
if (inc.x === 0 && inc.y === 0) return;
|
||||||
|
applyEdge(edge, inc);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maß-/Winkel-HUD am Cursor für einen gezogenen Punkt-Griff — dieselbe
|
||||||
|
// Darstellung wie beim Zeichnen (DraftHud, VW-Stil). Ohne Anker (Einzelgriff-
|
||||||
|
// Elemente wie Öffnung/Treppe) gibt es keine sinnvolle Länge/Winkel-Anzeige.
|
||||||
|
const hudFor = (index: number, pt: Vec2): NonNullable<ToolDraft["hud"]> | undefined => {
|
||||||
|
const anchor = resolveGripAnchor(index);
|
||||||
|
if (!anchor) return undefined;
|
||||||
|
const dx = pt.x - anchor.x;
|
||||||
|
const dy = pt.y - anchor.y;
|
||||||
|
const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
|
||||||
|
return { at: pt, length: Math.hypot(dx, dy), angleDeg };
|
||||||
|
};
|
||||||
|
// Dasselbe für einen gezogenen Kanten-Griff: freie Kanten zeigen Länge+Winkel
|
||||||
|
// (wie ein Punkt-Griff), geführte Kanten nur EINEN Wert (Abstand entlang der
|
||||||
|
// Normale) — dafür reicht das HUD-Schema nicht (length+angleDeg gehören
|
||||||
|
// zusammen), daher als freier Text `Δ: …m`.
|
||||||
|
const edgeHudFor = (edge: EdgeGrip, anchor: Vec2, pt: Vec2): NonNullable<ToolDraft["hud"]> => {
|
||||||
|
const dx = pt.x - anchor.x;
|
||||||
|
const dy = pt.y - anchor.y;
|
||||||
|
if (edge.free) {
|
||||||
|
const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
|
||||||
|
return { at: pt, length: Math.hypot(dx, dy), angleDeg };
|
||||||
|
}
|
||||||
|
const offset = dx * edge.normal.x + dy * edge.normal.y;
|
||||||
|
return { at: pt, text: `Δ: ${offset.toFixed(3)}m` };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aufräumen nach Abschluss/Verwerfen eines Griff- ODER Kanten-Drags (geteilt
|
||||||
|
// von onGripEnd/onGripCancel/onEdgeEnd/onEdgeCancel — nie beide gleichzeitig
|
||||||
|
// aktiv, ein gemeinsames Aufräumen ist daher unschädlich und einfacher).
|
||||||
|
const endEditing = () => {
|
||||||
|
setDraft(null);
|
||||||
|
edgeAppliedDeltaRef.current = null; // Kanten-Drag beendet → Anker zurücksetzen
|
||||||
|
moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen
|
||||||
|
gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen
|
||||||
|
gripOriginRef.current = null; // Flucht-Bezugspunkt verwerfen
|
||||||
|
setGripEdit(null); // (c) Feld-Controller schließen
|
||||||
|
edgeDragInfoRef.current = null; // Kanten-Drag-Info verwerfen
|
||||||
|
setEdgeEdit(null); // Kanten-Feld-Controller schließen
|
||||||
};
|
};
|
||||||
|
|
||||||
const gripHandlers = {
|
const gripHandlers = {
|
||||||
onGripMove: (index: number, raw: Vec2, pxPerMeter: number, mods: ToolMods) => {
|
onGripMove: (index: number, raw: Vec2, pxPerMeter: number, mods: ToolMods) => {
|
||||||
// (c) Vertex-Drag mit aktivem Feld-Controller + Locks: den Zielpunkt polar
|
// Erster Move-Event nach dem Grab (gripDragInfoRef noch leer): die
|
||||||
// aus Anker + gelockter Länge/Winkel bilden (ungelockt folgt dem Cursor).
|
// AKTUELLE — hier noch unveränderte — Position als Flucht-Bezugspunkt
|
||||||
|
// verankern (s. Kommentar an gripOriginRef).
|
||||||
|
if (gripDragInfoRef.current === null) gripOriginRef.current = grips[index];
|
||||||
|
// (c) Vertex-Drag mit aktivem Feld-Controller: `locks` ist ab dem
|
||||||
|
// ÖFFNEN (Tab) immer für BEIDE Felder befüllt (s. cycleGripEditField),
|
||||||
|
// die Maus wirkt also erst wieder, sobald ein Feld per Tab explizit
|
||||||
|
// gelöst wurde — kein separater „eingefroren"-Zustand mehr nötig.
|
||||||
const ge = gripEditRef.current;
|
const ge = gripEditRef.current;
|
||||||
gripDragInfoRef.current = { index, raw };
|
gripDragInfoRef.current = { index, raw };
|
||||||
if (ge && (ge.locks.length != null || ge.locks.angle != null)) {
|
if (ge && (ge.locks.length != null || ge.locks.angle != null)) {
|
||||||
const pt = gripEditPoint(ge.anchor, ge.locks, raw);
|
const pt = gripEditPoint(ge.anchor, ge.locks, raw);
|
||||||
applyGrip(index, pt);
|
applyGrip(index, pt);
|
||||||
setDraft({ preview: [], vertices: [], snap: null });
|
setDraft({ preview: [], vertices: [], snap: null, hud: hudFor(index, pt) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Mehrfachauswahl: das Element, dem DIESER Griff gehört (nicht zwingend
|
||||||
|
// das singuläre selectedDrawingId/selectedWallId) — von den eigenen
|
||||||
|
// Snap-Kandidaten ausschließen (sonst fängt der Punkt an sich selbst).
|
||||||
|
const owner = gripOwners[index];
|
||||||
const snapProject: Project = selectedRoomId
|
const snapProject: Project = selectedRoomId
|
||||||
? { ...project, rooms: (project.rooms ?? []).filter((r) => r.id !== selectedRoomId) }
|
? { ...project, rooms: (project.rooms ?? []).filter((r) => r.id !== selectedRoomId) }
|
||||||
: selectedCeilingId
|
: selectedCeilingId
|
||||||
? { ...project, ceilings: (project.ceilings ?? []).filter((c) => c.id !== selectedCeilingId) }
|
? { ...project, ceilings: (project.ceilings ?? []).filter((c) => c.id !== selectedCeilingId) }
|
||||||
: selectedDrawingId
|
: owner?.drawingId
|
||||||
? { ...project, drawings2d: project.drawings2d.filter((d) => d.id !== selectedDrawingId) }
|
? { ...project, drawings2d: project.drawings2d.filter((d) => d.id !== owner.drawingId) }
|
||||||
: { ...project, walls: project.walls.filter((w) => w.id !== selectedWallId) };
|
: owner?.wallId
|
||||||
// Bezugspunkt für Shift-Ortho: ein Nachbar-Griff (festes Ende), relativ zu
|
? { ...project, walls: project.walls.filter((w) => w.id !== owner.wallId) }
|
||||||
// dem die Bewegung auf H/V eingerastet wird.
|
: project;
|
||||||
const refIndex = index > 0 ? index - 1 : grips.length > 1 ? 1 : -1;
|
// Bezugspunkt für Shift-Ortho: ein Nachbar-Griff DESSELBEN Elements
|
||||||
const anchor = refIndex >= 0 ? grips[refIndex] : null;
|
// (festes Ende), relativ zu dem die Bewegung auf H/V eingerastet wird.
|
||||||
// Shift = Ortho-Lock: den gezogenen Punkt auf H/V gegenüber dem FIXEN Anker
|
const refIndex = neighborGripIndex(index);
|
||||||
// zwingen (Achse = die nähere; applyAngleConstraint mit 90°, wie Ortho beim
|
const anchor = refIndex != null ? grips[refIndex] : null;
|
||||||
// Zeichnen). Der Lock dominiert (kein Snap), damit die Endkoordinate
|
// Shift = Achsen-Lock: den gezogenen Punkt auf eine von mehreren
|
||||||
// garantiert eine Achse mit dem Anker teilt — identisch zum 3D-Vertex-Drag.
|
// Kandidaten-Achsen zwingen (welche gewinnt, s. closestAxisPoint) — Welt-
|
||||||
|
// H/V UND, bei einem Vielecks-Eckpunkt (zwei anliegende Kanten), zusätzlich
|
||||||
|
// die FLUCHT der beiden fixen Nachbarkanten in ihrer ursprünglichen
|
||||||
|
// Richtung. Nutzer-Wunsch: „wenn ein Punkt aus zwei Linien besteht, die
|
||||||
|
// nicht in 0/90° liegen, sollte Shift auch in DIESE Richtung snappen" —
|
||||||
|
// vorher war Shift stur auf Welt-H/V beschränkt, bei schrägen Anschlüssen
|
||||||
|
// also nutzlos für „entlang der bestehenden Kante weiterziehen". Der Lock
|
||||||
|
// dominiert (kein Snap), damit die Endkoordinate garantiert eine Achse
|
||||||
|
// trifft — identisch zum bisherigen Ortho-Verhalten.
|
||||||
if (mods.shift && anchor) {
|
if (mods.shift && anchor) {
|
||||||
const pt = applyAngleConstraint(anchor, raw, 90);
|
const candidates: { point: Vec2; dir: Vec2 }[] = [
|
||||||
|
{ point: anchor, dir: { x: 1, y: 0 } },
|
||||||
|
{ point: anchor, dir: { x: 0, y: 1 } },
|
||||||
|
];
|
||||||
|
const origin = gripOriginRef.current;
|
||||||
|
if (origin) {
|
||||||
|
const { prev, next } = resolvePolygonNeighbors(index);
|
||||||
|
if (prev) candidates.push({ point: prev, dir: sub(origin, prev) });
|
||||||
|
if (next) candidates.push({ point: next, dir: sub(next, origin) });
|
||||||
|
}
|
||||||
|
const pt = closestAxisPoint(raw, candidates) ?? applyAngleConstraint(anchor, raw, 90);
|
||||||
applyGrip(index, pt);
|
applyGrip(index, pt);
|
||||||
setDraft({ preview: [], vertices: [], snap: null });
|
setDraft({ preview: [], vertices: [], snap: null, hud: hudFor(index, pt) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Snap gegen das Projekt OHNE das gerade gezogene Element (sonst fängt der
|
// Snap gegen das Projekt OHNE das gerade gezogene Element (sonst fängt der
|
||||||
@@ -496,7 +930,7 @@ export function useGripEditing(opts: {
|
|||||||
});
|
});
|
||||||
const pt = snapResult?.point ?? raw;
|
const pt = snapResult?.point ?? raw;
|
||||||
applyGrip(index, pt);
|
applyGrip(index, pt);
|
||||||
setDraft({ preview: [], vertices: [], snap: snapResult });
|
setDraft({ preview: [], vertices: [], snap: snapResult, hud: hudFor(index, pt) });
|
||||||
},
|
},
|
||||||
onMoveBody: (
|
onMoveBody: (
|
||||||
delta: Vec2,
|
delta: Vec2,
|
||||||
@@ -542,47 +976,46 @@ export function useGripEditing(opts: {
|
|||||||
moveGripOf(c.drawingId, c.wallId, c.index, c.pos);
|
moveGripOf(c.drawingId, c.wallId, c.index, c.pos);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Kanten-Griff ziehen. Zwei Verhalten:
|
// Kanten-Griff ziehen. Zwei Verhalten (Zielpunkt jeweils ABSOLUT relativ
|
||||||
|
// zum ORIGINALEN Anker `edge.mid`, angewandt über applyEdgeToward — s. dort):
|
||||||
// • free (offene Polylinie/Linie, §Task B): das Segment folgt dem VOLLEN
|
// • free (offene Polylinie/Linie, §Task B): das Segment folgt dem VOLLEN
|
||||||
// Cursor-Delta — beide Endpunkte wandern gleich, die geteilten Ecken ziehen
|
// Cursor-Delta — beide Endpunkte wandern gleich, die geteilten Ecken ziehen
|
||||||
// die Nachbar-Segmente nach. Shift = Ortho (H/V) auf das Gesamt-Delta.
|
// die Nachbar-Segmente nach. Shift = Ortho (H/V) auf den Gesamt-Vektor.
|
||||||
// • sonst (rect/geschlossene Polylinie/Wand): NUR die Komponente senkrecht
|
// • sonst (rect/geschlossene Polylinie/Wand): NUR die Komponente senkrecht
|
||||||
// zur Kante (Seite verschiebt sich parallel nach außen/innen). Fängt an
|
// zur Kante (Seite verschiebt sich parallel nach außen/innen). Fängt an
|
||||||
// eingeschalteten Geometrie-Snaps (Endpunkt/Mittelpunkt/Schnittpunkt/…);
|
// eingeschalteten Geometrie-Snaps (Endpunkt/Mittelpunkt/Schnittpunkt/…);
|
||||||
// ohne Geometrie-Snap wird der Offset wie bisher aufs Raster gerundet.
|
// ohne Geometrie-Snap wird der Offset wie bisher aufs Raster gerundet.
|
||||||
onEdgeMove: (edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods) => {
|
onEdgeMove: (edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods) => {
|
||||||
|
edgeDragInfoRef.current = { edge, raw };
|
||||||
|
const anchor = edge.mid; // Momentaufnahme vom Grab, ändert sich während des Drags nicht
|
||||||
|
const ee = edgeEditRef.current;
|
||||||
|
// Feld-Controller mit Lock aktiv: Zielpunkt aus Anker + gelocktem Wert.
|
||||||
|
// `locks` ist ab dem Öffnen (Tab) immer befüllt (s. cycleEdgeEditField),
|
||||||
|
// die Maus wirkt also erst wieder, sobald ein Feld per Tab gelöst wurde.
|
||||||
|
if (ee && (ee.locks.length != null || ee.locks.angle != null)) {
|
||||||
|
const pt = edgeEditPoint(edge, anchor, ee.locks, raw);
|
||||||
|
applyEdgeToward(edge, anchor, pt);
|
||||||
|
setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (edge.free) {
|
if (edge.free) {
|
||||||
// Freier Fall arbeitet mit einem inkrementellen Cursor-DELTA (nicht
|
// Keine Geometrie-Snap-Projektion (keine feste Kanten-Normale, an die
|
||||||
// einem absoluten Punkt) — eine Geometrie-Snap-Projektion auf die
|
// sich ein gefangener Punkt anlehnen könnte) — der Cursor bewegt den
|
||||||
// Normale ergibt hier keinen Sinn, da es keine feste Kanten-Normale
|
// Zielpunkt direkt, nur Ortho/Raster wirken.
|
||||||
// gibt, an die sich ein gefangener Punkt anlehnen könnte. Bleibt beim
|
let pt = raw;
|
||||||
// bisherigen Raster-Delta-Verhalten.
|
if (mods.shift) {
|
||||||
//
|
const dx = raw.x - anchor.x;
|
||||||
// Inkrementelles 2D-Delta gegen den letzten rohen Cursor. Erster Event
|
const dy = raw.y - anchor.y;
|
||||||
// verankert nur (kein Sprung, da der Griff am Kanten-Mittelpunkt sitzt).
|
pt = Math.abs(dx) >= Math.abs(dy) ? { x: raw.x, y: anchor.y } : { x: anchor.x, y: raw.y };
|
||||||
const last = edgeFreeLastRef.current;
|
}
|
||||||
edgeFreeLastRef.current = raw;
|
|
||||||
if (last === null) return;
|
|
||||||
let dx = raw.x - last.x;
|
|
||||||
let dy = raw.y - last.y;
|
|
||||||
// Raster-Schritt auf das Delta (Snap aktiv, nicht per Ctrl unterdrückt).
|
|
||||||
const gs = snap.gridSize;
|
const gs = snap.gridSize;
|
||||||
if (!mods.ctrl && snap.enabled && snap.grid && gs > 0) {
|
if (!mods.ctrl && snap.enabled && snap.grid && gs > 0) {
|
||||||
dx = Math.round(dx / gs) * gs;
|
const dx = Math.round((pt.x - anchor.x) / gs) * gs;
|
||||||
dy = Math.round(dy / gs) * gs;
|
const dy = Math.round((pt.y - anchor.y) / gs) * gs;
|
||||||
if (dx === 0 && dy === 0) {
|
pt = { x: anchor.x + dx, y: anchor.y + dy };
|
||||||
edgeFreeLastRef.current = last; // noch kein voller Rasterschritt → warten
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Shift = Ortho: das Delta auf die dominante Achse (H/V) zwingen.
|
applyEdgeToward(edge, anchor, pt);
|
||||||
if (mods.shift) {
|
setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) });
|
||||||
if (Math.abs(dx) >= Math.abs(dy)) dy = 0;
|
|
||||||
else dx = 0;
|
|
||||||
}
|
|
||||||
if (dx === 0 && dy === 0) return;
|
|
||||||
applyEdge(edge.aIndex, edge.bIndex, { x: dx, y: dy });
|
|
||||||
setDraft({ preview: [], vertices: [], snap: null });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Cursor zuerst durch die Snap-Engine schicken (Ctrl unterdrückt Snap
|
// Cursor zuerst durch die Snap-Engine schicken (Ctrl unterdrückt Snap
|
||||||
@@ -593,8 +1026,8 @@ export function useGripEditing(opts: {
|
|||||||
const snapResult = snapFor(raw, pxPerMeter, mods);
|
const snapResult = snapFor(raw, pxPerMeter, mods);
|
||||||
const isGeometrySnap = !!snapResult && snapResult.kind !== "grid" && snapResult.kind !== "ortho";
|
const isGeometrySnap = !!snapResult && snapResult.kind !== "grid" && snapResult.kind !== "ortho";
|
||||||
const cursor = isGeometrySnap ? snapResult!.point : raw;
|
const cursor = isGeometrySnap ? snapResult!.point : raw;
|
||||||
// Senkrechte Projektion von (Cursor − Grab-Mittelpunkt) auf die Außennormale.
|
// Senkrechte Projektion von (Cursor − Anker) auf die Außennormale.
|
||||||
const rel = sub(cursor, edge.mid);
|
const rel = sub(cursor, anchor);
|
||||||
let offset = rel.x * edge.normal.x + rel.y * edge.normal.y;
|
let offset = rel.x * edge.normal.x + rel.y * edge.normal.y;
|
||||||
// Raster-Schritt (Snap) nur, wenn kein Geometrie-Snap gefangen wurde —
|
// Raster-Schritt (Snap) nur, wenn kein Geometrie-Snap gefangen wurde —
|
||||||
// sonst gewinnt der gefangene Punkt.
|
// sonst gewinnt der gefangene Punkt.
|
||||||
@@ -602,29 +1035,40 @@ export function useGripEditing(opts: {
|
|||||||
if (!isGeometrySnap && !mods.ctrl && snap.enabled && snap.grid && step > 0) {
|
if (!isGeometrySnap && !mods.ctrl && snap.enabled && snap.grid && step > 0) {
|
||||||
offset = Math.round(offset / step) * step;
|
offset = Math.round(offset / step) * step;
|
||||||
}
|
}
|
||||||
// INKREMENTELL + Grab-Anker: moveEdge verschiebt additiv, daher nur die
|
const pt = add(anchor, scale(edge.normal, offset));
|
||||||
// Änderung seit dem letzten Event anwenden. Erster Event verankert nur
|
applyEdgeToward(edge, anchor, pt);
|
||||||
// (kein Sprung beim Greifen, da das Dreieck am Mittelpunkt sitzt).
|
setDraft({
|
||||||
const prev = edgeDragOffsetRef.current;
|
preview: [],
|
||||||
edgeDragOffsetRef.current = offset;
|
vertices: [],
|
||||||
if (prev === null) return;
|
snap: isGeometrySnap ? snapResult : null,
|
||||||
const inc = offset - prev;
|
hud: edgeHudFor(edge, anchor, pt),
|
||||||
if (inc === 0) return;
|
});
|
||||||
applyEdge(edge.aIndex, edge.bIndex, scale(edge.normal, inc));
|
|
||||||
setDraft({ preview: [], vertices: [], snap: isGeometrySnap ? snapResult : null });
|
|
||||||
},
|
},
|
||||||
onGripEnd: () => {
|
onGripEnd: () => {
|
||||||
setDraft(null);
|
endEditing();
|
||||||
edgeDragOffsetRef.current = null; // Drag beendet → Anker zurücksetzen
|
},
|
||||||
edgeFreeLastRef.current = null; // freien Kanten-Drag-Anker zurücksetzen
|
// Bewaffneten Punkt-Griff verwerfen (Escape): exakt auf `origin`
|
||||||
moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen
|
// zurücksetzen — ohne Snap/Ortho-Neuauswertung, ein reiner Revert — und
|
||||||
gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen
|
// danach wie onGripEnd aufräumen.
|
||||||
setGripEdit(null); // (c) Feld-Controller schließen
|
onGripCancel: (index: number, origin: Vec2) => {
|
||||||
|
applyGrip(index, origin);
|
||||||
|
endEditing();
|
||||||
|
},
|
||||||
|
onEdgeEnd: () => {
|
||||||
|
endEditing();
|
||||||
|
},
|
||||||
|
// Bewaffneten Kanten-Griff verwerfen (Escape): Ziel = Anker selbst (Delta
|
||||||
|
// 0) — applyEdgeToward macht dadurch die komplette bisherige Verschiebung
|
||||||
|
// in einem Schritt rückgängig (negatives Delta zum zuletzt angewandten).
|
||||||
|
onEdgeCancel: (edge: EdgeGrip, anchor: Vec2) => {
|
||||||
|
applyEdgeToward(edge, anchor, anchor);
|
||||||
|
endEditing();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
grips,
|
grips,
|
||||||
|
gripHandleLines,
|
||||||
edgeGrips,
|
edgeGrips,
|
||||||
gripHandlers,
|
gripHandlers,
|
||||||
gripEdit,
|
gripEdit,
|
||||||
@@ -632,5 +1076,19 @@ export function useGripEditing(opts: {
|
|||||||
gripEditFields,
|
gripEditFields,
|
||||||
cycleGripEditField,
|
cycleGripEditField,
|
||||||
submitGripEditValue,
|
submitGripEditValue,
|
||||||
|
closeGripEditField,
|
||||||
|
edgeEdit,
|
||||||
|
setEdgeEdit,
|
||||||
|
edgeEditFields,
|
||||||
|
cycleEdgeEditField,
|
||||||
|
submitEdgeEditValue,
|
||||||
|
closeEdgeEditField,
|
||||||
|
// Für useToolNumberShortcuts: solange ein Punkt- ODER Kanten-Griff
|
||||||
|
// bewaffnet ist, dürfen Zifferntasten NICHT als Werkzeug-Wechsel
|
||||||
|
// interpretiert werden (auch nicht, wenn das Befehlsfeld fokussiert und
|
||||||
|
// leer ist — die Zeichen-Engine selbst läuft ja nicht, daher greift
|
||||||
|
// dieser Wächter sonst nicht).
|
||||||
|
gripDragInfoRef,
|
||||||
|
edgeDragInfoRef,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
@@ -69,6 +71,12 @@ export function useCommandTabShortcut(
|
|||||||
locks: { length?: number; angle?: number };
|
locks: { length?: number; angle?: number };
|
||||||
} | null>,
|
} | null>,
|
||||||
cycleGripEditFieldRef: RefObject<() => void>,
|
cycleGripEditFieldRef: RefObject<() => void>,
|
||||||
|
// Dasselbe für einen Kanten-Griff (Seite parallel verschieben) — separater
|
||||||
|
// Drag-Kanal, aber identisches Tab-Verhalten (öffnen/zyklen + Befehlsfeld
|
||||||
|
// fokussieren).
|
||||||
|
edgeDragInfoRef?: RefObject<{ edge: unknown; raw: Vec2 } | null>,
|
||||||
|
edgeEditRef?: RefObject<unknown>,
|
||||||
|
cycleEdgeEditFieldRef?: RefObject<() => void>,
|
||||||
): void {
|
): void {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -105,6 +113,13 @@ export function useCommandTabShortcut(
|
|||||||
if (!wasOpen) commandLineRef.current?.focus();
|
if (!wasOpen) commandLineRef.current?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Dasselbe für einen laufenden Kanten-Drag (Seite parallel verschieben).
|
||||||
|
if (edgeDragInfoRef?.current) {
|
||||||
|
const wasOpen = edgeEditRef?.current != null;
|
||||||
|
cycleEdgeEditFieldRef?.current?.();
|
||||||
|
if (!wasOpen) commandLineRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
commandLineRef.current?.focus();
|
commandLineRef.current?.focus();
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
@@ -124,6 +139,8 @@ export function useToolNumberShortcuts(
|
|||||||
onSelectTool: (id: ToolId) => void,
|
onSelectTool: (id: ToolId) => void,
|
||||||
isFloor: boolean,
|
isFloor: boolean,
|
||||||
engineRef: RefObject<CommandEngine | null>,
|
engineRef: RefObject<CommandEngine | null>,
|
||||||
|
gripDragInfoRef?: RefObject<{ index: number; raw: Vec2 } | null>,
|
||||||
|
edgeDragInfoRef?: RefObject<unknown>,
|
||||||
): void {
|
): void {
|
||||||
const toolShortcutRef = useRef({ onSelectTool, floor: isFloor });
|
const toolShortcutRef = useRef({ onSelectTool, floor: isFloor });
|
||||||
toolShortcutRef.current = { onSelectTool, floor: isFloor };
|
toolShortcutRef.current = { onSelectTool, floor: isFloor };
|
||||||
@@ -142,6 +159,14 @@ export function useToolNumberShortcuts(
|
|||||||
};
|
};
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
// Ein Punkt-Griff ist bewaffnet (s. useGripEditing.ts) — Zifferntasten
|
||||||
|
// gehören dann IMMER dessen Länge/Winkel-Feld, nie dem Werkzeug-Wechsel.
|
||||||
|
// Die `eng?.hasFields()`-Prüfung unten greift hier nicht, weil während
|
||||||
|
// der Griff-Bearbeitung gar kein Zeichenbefehl der Engine läuft (Bug:
|
||||||
|
// getippter Wert schaltete auf das per Ziffer gemappte Zeichenwerkzeug
|
||||||
|
// um, sobald das leere, fokussierte Befehlsfeld erkannt wurde).
|
||||||
|
if (gripDragInfoRef?.current) return;
|
||||||
|
if (edgeDragInfoRef?.current) return;
|
||||||
const el = e.target as HTMLElement | null;
|
const el = e.target as HTMLElement | null;
|
||||||
const isInput =
|
const isInput =
|
||||||
!!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
|
!!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
|
||||||
@@ -477,6 +502,7 @@ export function useTransformShortcuts(
|
|||||||
) => void,
|
) => void,
|
||||||
selectedWallIds: string[],
|
selectedWallIds: string[],
|
||||||
selectedDrawingId: string | null,
|
selectedDrawingId: string | null,
|
||||||
|
selectedDrawingIds: string[],
|
||||||
selectedExtrudedSolidId: string | null,
|
selectedExtrudedSolidId: string | null,
|
||||||
selectedColumnId: string | null,
|
selectedColumnId: string | null,
|
||||||
engine: CommandEngine,
|
engine: CommandEngine,
|
||||||
@@ -518,6 +544,7 @@ export function useTransformShortcuts(
|
|||||||
const hasSel =
|
const hasSel =
|
||||||
selectedWallIds.length > 0 ||
|
selectedWallIds.length > 0 ||
|
||||||
!!selectedDrawingId ||
|
!!selectedDrawingId ||
|
||||||
|
selectedDrawingIds.length > 0 ||
|
||||||
!!selectedExtrudedSolidId ||
|
!!selectedExtrudedSolidId ||
|
||||||
!!selectedColumnId;
|
!!selectedColumnId;
|
||||||
if (!hasSel) return;
|
if (!hasSel) return;
|
||||||
@@ -542,8 +569,59 @@ export function useTransformShortcuts(
|
|||||||
activeTransform,
|
activeTransform,
|
||||||
selectedWallIds,
|
selectedWallIds,
|
||||||
selectedDrawingId,
|
selectedDrawingId,
|
||||||
|
selectedDrawingIds,
|
||||||
selectedExtrudedSolidId,
|
selectedExtrudedSolidId,
|
||||||
selectedColumnId,
|
selectedColumnId,
|
||||||
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,14 +387,45 @@ 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,
|
||||||
// Decken/Öffnungen/Treppen/Räume/Extrusionen/Dächer sind (noch) nicht Teil
|
drawingIds: sel.drawingIds ?? [],
|
||||||
// des Marquee → beim Aufziehen abwählen.
|
ceilingIds: sel.ceilingIds ?? [],
|
||||||
setSelectedCeilingIds([]);
|
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,
|
||||||
|
// falls gewünscht) → beim Aufziehen abwählen.
|
||||||
setSelectedOpeningIds([]);
|
setSelectedOpeningIds([]);
|
||||||
setSelectedStairIds([]);
|
|
||||||
setSelectedRoomIds([]);
|
|
||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
|||||||
@@ -49,7 +49,15 @@ function drawingsBBox(
|
|||||||
else if (g.shape === "circle" || g.shape === "arc") {
|
else if (g.shape === "circle" || g.shape === "arc") {
|
||||||
acc(g.center.x - g.r, g.center.y - g.r);
|
acc(g.center.x - g.r, g.center.y - g.r);
|
||||||
acc(g.center.x + g.r, g.center.y + g.r);
|
acc(g.center.x + g.r, g.center.y + g.r);
|
||||||
} else if (g.shape === "text") acc(g.at.x, g.at.y);
|
} else if (g.shape === "ellipse") {
|
||||||
|
acc(g.center.x - g.rx, g.center.y - g.ry);
|
||||||
|
acc(g.center.x + g.rx, g.center.y + g.ry);
|
||||||
|
} else if (g.shape === "bezier" || g.shape === "spline") {
|
||||||
|
for (const p of g.pts) acc(p.x, p.y);
|
||||||
|
} else if (g.shape === "dimension") {
|
||||||
|
for (const p of g.pts) acc(p.x, p.y);
|
||||||
|
} else if (g.shape === "kote") acc(g.at.x, g.at.y);
|
||||||
|
else if (g.shape === "text") acc(g.at.x, g.at.y);
|
||||||
}
|
}
|
||||||
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
|
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
|
||||||
}
|
}
|
||||||
@@ -64,6 +72,11 @@ function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D {
|
|||||||
case "rect": return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
|
case "rect": return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
|
||||||
case "circle": return { ...d, geom: { ...g, center: mv(g.center) } };
|
case "circle": return { ...d, geom: { ...g, center: mv(g.center) } };
|
||||||
case "arc": return { ...d, geom: { ...g, center: mv(g.center) } };
|
case "arc": return { ...d, geom: { ...g, center: mv(g.center) } };
|
||||||
|
case "ellipse": return { ...d, geom: { ...g, center: mv(g.center) } };
|
||||||
|
case "bezier": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
|
||||||
|
case "spline": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
|
||||||
|
case "dimension": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
|
||||||
|
case "kote": return { ...d, geom: { ...g, at: mv(g.at) } };
|
||||||
case "text": return { ...d, geom: { ...g, at: mv(g.at) } };
|
case "text": return { ...d, geom: { ...g, at: mv(g.at) } };
|
||||||
default: return d;
|
default: return d;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2110,6 +2110,23 @@ body {
|
|||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gruppen-Auswahl (Vectorworks-artig): statt jedes Mitglieds-Umrisses nur eine
|
||||||
|
gestrichelte Bounding-Box mit vier Eckpunkt-Griffen — dieselbe Akzentfarbe
|
||||||
|
wie die normale Auswahl, aber gestrichelt (sofort als "Gruppe" erkennbar). */
|
||||||
|
.plan-svg .plan-group-bbox {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.plan-svg .plan-group-bbox-corner {
|
||||||
|
fill: var(--accent);
|
||||||
|
stroke: #fff;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hervorhebung selektierter 2D-Zeichenelemente (Mehrfach/Marquee): Akzentkontur
|
/* Hervorhebung selektierter 2D-Zeichenelemente (Mehrfach/Marquee): Akzentkontur
|
||||||
über der Geometrie, keine Füllung. */
|
über der Geometrie, keine Füllung. */
|
||||||
.plan-svg .plan-sel-draw {
|
.plan-svg .plan-sel-draw {
|
||||||
@@ -2119,6 +2136,54 @@ body {
|
|||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gruppen-Isolation (Vectorworks „Gruppe betreten"): halbtransparenter
|
||||||
|
Orange-Rahmen (Band) am Rand der ganzen Ansicht, solange eine Gruppe
|
||||||
|
betreten ist — dieselbe visuelle Sprache wie Vectorworks' "Ansichtsbereich"-
|
||||||
|
Rahmen (Screenshot-Referenz, 2026-08-22). EIGENES Overlay-Element, NACH
|
||||||
|
PlanView im DOM gerendert (nicht `box-shadow` auf `.pane` selbst — das läge
|
||||||
|
HINTER der eigenen Kind-SVG von PlanView und wäre unsichtbar, s. Nutzer-
|
||||||
|
Report "ich seh es nicht"). `pointer-events: none`, damit Klicks/Griffe im
|
||||||
|
Plan weiterhin durchgehen. */
|
||||||
|
.pane-isolation-frame {
|
||||||
|
position: absolute;
|
||||||
|
/* Oben/links um die Lineal-Dicke eingerückt (TH=20 Bildschirm-px in
|
||||||
|
PlanRulers.tsx) — der Rahmen soll NUR den eigentlichen Zeichenbereich
|
||||||
|
umranden, nicht das Lineal überdecken (Nutzer-Wunsch: „respektiere das
|
||||||
|
Lineal, wirklich nur Viewport selber"). Rechts/unten gibt es kein
|
||||||
|
Lineal → bis an den Rand. */
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: inset 0 0 0 8px rgba(230, 126, 34, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Gruppe verlassen": Knopf oben RECHTS in der Ansicht, sichtbar nur solange
|
||||||
|
eine Gruppe betreten ist. `top` unter dem Lineal (TH=20px, s. PlanRulers.tsx)
|
||||||
|
UND unter dem Isolations-Rahmenband (8px ab dessen 20px-Einrückung) —
|
||||||
|
Nutzer-Report: Pill sass vorher zu hoch, überlappte Lineal-Beschriftung. */
|
||||||
|
.pane-leave-group-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 36px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 6;
|
||||||
|
padding: 5px 11px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pane-leave-group-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* 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
|
||||||
@@ -2143,6 +2208,16 @@ body {
|
|||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bezier-Hilfslinien (Anker → Kontrollpunkt-Griff): dünn, gestrichelt, deutlich
|
||||||
|
zurückhaltender als der Kurven-Umriss selbst — reine Steuerungshilfe. */
|
||||||
|
.plan-svg .plan-grip-line {
|
||||||
|
stroke: var(--accent-light);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Kanten-/Seiten-Griffe (Edge Grips): dreieckiger Anfasser je Seite, Spitze
|
/* Kanten-/Seiten-Griffe (Edge Grips): dreieckiger Anfasser je Seite, Spitze
|
||||||
nach außen. Gleiche Akzent-/Rand-Farben wie die Eckpunkt-Griffe. */
|
nach außen. Gleiche Akzent-/Rand-Farben wie die Eckpunkt-Griffe. */
|
||||||
.plan-svg .plan-edge-grip {
|
.plan-svg .plan-edge-grip {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { ellipsePts, sampleBezierChain, sampleCatmullRom } from "./curves";
|
||||||
|
|
||||||
|
describe("ellipsePts", () => {
|
||||||
|
it("liegt auf der Ellipse (rotation 0): Ost/Nord-Punkte bei rx/ry", () => {
|
||||||
|
const pts = ellipsePts({ x: 0, y: 0 }, 4, 2, 0, 4);
|
||||||
|
// t=0 → Ost (rx,0); t=π/2 → Nord (0,ry)
|
||||||
|
expect(pts[0].x).toBeCloseTo(4, 6);
|
||||||
|
expect(pts[0].y).toBeCloseTo(0, 6);
|
||||||
|
expect(pts[1].x).toBeCloseTo(0, 6);
|
||||||
|
expect(pts[1].y).toBeCloseTo(2, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Drehung um 90° vertauscht die Achsen", () => {
|
||||||
|
const pts = ellipsePts({ x: 0, y: 0 }, 4, 2, Math.PI / 2, 4);
|
||||||
|
// Der ehemalige Ost-Punkt (rx,0) liegt jetzt bei (0, rx).
|
||||||
|
expect(pts[0].x).toBeCloseTo(0, 6);
|
||||||
|
expect(pts[0].y).toBeCloseTo(4, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Mittelpunkt-Verschiebung wird respektiert", () => {
|
||||||
|
const pts = ellipsePts({ x: 10, y: 5 }, 3, 3, 0, 8);
|
||||||
|
for (const p of pts) {
|
||||||
|
expect(Math.hypot(p.x - 10, p.y - 5)).toBeCloseTo(3, 6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sampleBezierChain", () => {
|
||||||
|
it("weniger als 4 Punkte: Rohpunkte unverändert (Gummiband-Vorschau)", () => {
|
||||||
|
const pts = [{ x: 0, y: 0 }, { x: 1, y: 1 }];
|
||||||
|
expect(sampleBezierChain(pts)).toEqual(pts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ein Segment: startet bei P0, endet bei P3", () => {
|
||||||
|
const p0 = { x: 0, y: 0 }, p1 = { x: 1, y: 3 }, p2 = { x: 3, y: 3 }, p3 = { x: 4, y: 0 };
|
||||||
|
const out = sampleBezierChain([p0, p1, p2, p3], 10);
|
||||||
|
expect(out[0]).toEqual(p0);
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
expect(last.x).toBeCloseTo(p3.x, 6);
|
||||||
|
expect(last.y).toBeCloseTo(p3.y, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("t=0.5 folgt der De-Casteljau-Formel (symmetrischer Fall)", () => {
|
||||||
|
const p0 = { x: 0, y: 0 }, p1 = { x: 0, y: 4 }, p2 = { x: 4, y: 4 }, p3 = { x: 4, y: 0 };
|
||||||
|
const out = sampleBezierChain([p0, p1, p2, p3], 2); // t = 0.5 genau bei Index 1
|
||||||
|
// Symmetrische Kontrollpunkte → Kurvenmitte liegt exakt bei (2, 3).
|
||||||
|
expect(out[1].x).toBeCloseTo(2, 6);
|
||||||
|
expect(out[1].y).toBeCloseTo(3, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zwei Segmente: verbindet am gemeinsamen Anker P3=P4", () => {
|
||||||
|
const pts = [
|
||||||
|
{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 1 }, { x: 3, y: 0 },
|
||||||
|
{ x: 4, y: -1 }, { x: 5, y: -1 }, { x: 6, y: 0 },
|
||||||
|
];
|
||||||
|
const out = sampleBezierChain(pts, 5);
|
||||||
|
expect(out[0]).toEqual(pts[0]);
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
expect(last.x).toBeCloseTo(6, 6);
|
||||||
|
expect(last.y).toBeCloseTo(0, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sampleCatmullRom", () => {
|
||||||
|
it("weniger als 3 Punkte: Rohpunkte unverändert", () => {
|
||||||
|
const pts = [{ x: 0, y: 0 }, { x: 1, y: 1 }];
|
||||||
|
expect(sampleCatmullRom(pts, false)).toEqual(pts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offen: läuft durch den ersten und letzten Wegpunkt", () => {
|
||||||
|
const pts = [{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 5, y: 1 }, { x: 8, y: 4 }];
|
||||||
|
const out = sampleCatmullRom(pts, false, 8);
|
||||||
|
expect(out[0]).toEqual(pts[0]);
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
expect(last.x).toBeCloseTo(8, 6);
|
||||||
|
expect(last.y).toBeCloseTo(4, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Kollineare Punkte ergeben eine Gerade (kein Überschwingen)", () => {
|
||||||
|
const pts = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }, { x: 3, y: 0 }];
|
||||||
|
const out = sampleCatmullRom(pts, false, 4);
|
||||||
|
for (const p of out) expect(p.y).toBeCloseTo(0, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("geschlossen: liefert keinen doppelten Schlusspunkt (Aufrufer schliesst selbst)", () => {
|
||||||
|
const pts = [{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 4 }, { x: 0, y: 4 }];
|
||||||
|
const out = sampleCatmullRom(pts, true, 6);
|
||||||
|
expect(out.length).toBe(pts.length * 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Geometrie-Helfer für Ellipse/Bezier/Spline — reine Sample-/Tessellierungs-
|
||||||
|
// Funktionen, gemeinsam genutzt von den Zeichen-Werkzeugen (Vorschau), dem
|
||||||
|
// Grundriss-Generator (finales Rendering als Linien-/Polygon-Primitive) und
|
||||||
|
// dem Snapping (Kandidaten-Strecken).
|
||||||
|
|
||||||
|
import type { Vec2 } from "../model/types";
|
||||||
|
|
||||||
|
/** Punkte einer (ggf. gedrehten) Ellipse, N gleichverteilte Parameterschritte. */
|
||||||
|
export function ellipsePts(
|
||||||
|
center: Vec2,
|
||||||
|
rx: number,
|
||||||
|
ry: number,
|
||||||
|
rotation: number,
|
||||||
|
n = 64,
|
||||||
|
): Vec2[] {
|
||||||
|
const pts: Vec2[] = [];
|
||||||
|
const cosR = Math.cos(rotation);
|
||||||
|
const sinR = Math.sin(rotation);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const t = (i / n) * Math.PI * 2;
|
||||||
|
const lx = rx * Math.cos(t);
|
||||||
|
const ly = ry * Math.sin(t);
|
||||||
|
pts.push({ x: center.x + lx * cosR - ly * sinR, y: center.y + lx * sinR + ly * cosR });
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Punkt auf einer kubischen Bezier-Kurve (De-Casteljau-Formel) bei Parameter `t` ∈ [0,1]. */
|
||||||
|
function cubicPoint(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 {
|
||||||
|
const u = 1 - t;
|
||||||
|
const a = u * u * u;
|
||||||
|
const b = 3 * u * u * t;
|
||||||
|
const c = 3 * u * t * t;
|
||||||
|
const d = t * t * t;
|
||||||
|
return {
|
||||||
|
x: a * p0.x + b * p1.x + c * p2.x + d * p3.x,
|
||||||
|
y: a * p0.y + b * p1.y + c * p2.y + d * p3.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tessellierte Punkte einer kubischen Bezier-Kette. `pts` folgt dem Muster
|
||||||
|
* Anker·Griff·Griff·Anker·… (`pts.length` ≡ 1 mod 3). Mit weniger als 4
|
||||||
|
* Punkten (noch im Bau) werden die Rohpunkte unverändert zurückgegeben
|
||||||
|
* (Gummiband-Vorschau).
|
||||||
|
*/
|
||||||
|
export function sampleBezierChain(pts: Vec2[], segsPerCurve = 24): Vec2[] {
|
||||||
|
if (pts.length < 4) return [...pts];
|
||||||
|
const out: Vec2[] = [pts[0]];
|
||||||
|
for (let i = 0; i + 3 < pts.length; i += 3) {
|
||||||
|
const p0 = pts[i], p1 = pts[i + 1], p2 = pts[i + 2], p3 = pts[i + 3];
|
||||||
|
for (let s = 1; s <= segsPerCurve; s++) out.push(cubicPoint(p0, p1, p2, p3, s / segsPerCurve));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kontrollpunkt-Zugriff für Catmull-Rom mit Rand-Klemmung bzw. Ring-Umlauf. */
|
||||||
|
function crPoint(pts: Vec2[], i: number, closed: boolean): Vec2 {
|
||||||
|
const n = pts.length;
|
||||||
|
if (closed) return pts[((i % n) + n) % n];
|
||||||
|
if (i < 0) return pts[0];
|
||||||
|
if (i >= n) return pts[n - 1];
|
||||||
|
return pts[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tessellierte Punkte einer glatten Kurve DURCH `pts` (uniformer Catmull-
|
||||||
|
* Rom-Spline). Mit weniger als 3 Punkten (noch im Bau bzw. entartet) werden
|
||||||
|
* die Rohpunkte unverändert zurückgegeben (Gummiband-Vorschau bzw. Gerade).
|
||||||
|
* `closed` schliesst den Ring OHNE expliziten Doppelpunkt am Ende — Aufrufer
|
||||||
|
* schliessen wie bei `polyline` selbst mit einem letzten Segment zurück zu
|
||||||
|
* `pts[0]`.
|
||||||
|
*/
|
||||||
|
export function sampleCatmullRom(pts: Vec2[], closed: boolean, segsPerSpan = 16): Vec2[] {
|
||||||
|
if (pts.length < 3) return [...pts];
|
||||||
|
const out: Vec2[] = [];
|
||||||
|
const spans = closed ? pts.length : pts.length - 1;
|
||||||
|
for (let i = 0; i < spans; i++) {
|
||||||
|
const p0 = crPoint(pts, i - 1, closed);
|
||||||
|
const p1 = crPoint(pts, i, closed);
|
||||||
|
const p2 = crPoint(pts, i + 1, closed);
|
||||||
|
const p3 = crPoint(pts, i + 2, closed);
|
||||||
|
for (let s = 0; s < segsPerSpan; s++) {
|
||||||
|
const t = s / segsPerSpan;
|
||||||
|
const t2 = t * t;
|
||||||
|
const t3 = t2 * t;
|
||||||
|
const x =
|
||||||
|
0.5 *
|
||||||
|
(2 * p1.x +
|
||||||
|
(-p0.x + p2.x) * t +
|
||||||
|
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
|
||||||
|
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
|
||||||
|
const y =
|
||||||
|
0.5 *
|
||||||
|
(2 * p1.y +
|
||||||
|
(-p0.y + p2.y) * t +
|
||||||
|
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
|
||||||
|
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
|
||||||
|
out.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!closed) out.push(pts[pts.length - 1]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
* Wandschicht statt nur an Achse/Aussenkante enden kann.
|
* Wandschicht statt nur an Achse/Aussenkante enden kann.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { computeSnap, snapCommonAngle, wallLayerBoundarySegments } from "./snapping";
|
import { computeSnap, snapCommonAngle, wallLayerBoundarySegments } from "./snapping";
|
||||||
import { DEFAULT_SNAP } from "./types";
|
import { DEFAULT_SNAP } from "./types";
|
||||||
import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
|
import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
|
||||||
@@ -368,3 +368,48 @@ function wall2d(start: Vec2, end: Vec2): Wall {
|
|||||||
height: 2.6,
|
height: 2.6,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Teilpunkte (divide) — nur fangbar nach Verweilen ────────────────────────
|
||||||
|
// computeSnap hält den Verweil-Zustand modulweit (mehrere, unabhängige
|
||||||
|
// Aufrufer teilen ihn sich, s. Kommentar dort) — Date.now()-basiert, daher
|
||||||
|
// hier mit Fake-Timern gesteuert statt echter Wartezeit.
|
||||||
|
describe("computeSnap — Teilpunkte (divide) nach Verweilen", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fängt NICHT auf dem Drittelpunkt, solange der Cursor noch nicht lange genug verweilt hat", () => {
|
||||||
|
const project = drawProject([{ shape: "line", a: { x: 0, y: 0 }, b: { x: 9, y: 0 } }]);
|
||||||
|
const settings = { ...DEFAULT_SNAP, hoverDivideDelayMs: 300, hoverDivideCount: 3 };
|
||||||
|
const result = snapAt(project, { x: 3, y: 0.02 }, settings);
|
||||||
|
expect(result?.kind).not.toBe("divide");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fängt auf dem Drittelpunkt, sobald der Cursor lange genug auf derselben Linie verweilt hat", () => {
|
||||||
|
const project = drawProject([{ shape: "line", a: { x: 0, y: 0 }, b: { x: 9, y: 0 } }]);
|
||||||
|
const settings = { ...DEFAULT_SNAP, hoverDivideDelayMs: 300, hoverDivideCount: 3 };
|
||||||
|
const raw: Vec2 = { x: 3, y: 0.02 };
|
||||||
|
snapAt(project, raw, settings); // erster Aufruf verankert nur den Verweil-Beginn
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
const result = snapAt(project, raw, settings);
|
||||||
|
expect(result?.kind).toBe("divide");
|
||||||
|
expect(result?.point.x).toBeCloseTo(3, 6); // 1/3 von 9
|
||||||
|
expect(result?.point.y).toBeCloseTo(0, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verwirft den Verweil, sobald der Cursor zwischenzeitlich auf eine ANDERE Linie wechselt", () => {
|
||||||
|
const project = drawProject([
|
||||||
|
{ shape: "line", a: { x: 0, y: 0 }, b: { x: 9, y: 0 } },
|
||||||
|
{ shape: "line", a: { x: 0, y: 5 }, b: { x: 9, y: 5 } },
|
||||||
|
]);
|
||||||
|
const settings = { ...DEFAULT_SNAP, hoverDivideDelayMs: 300, hoverDivideCount: 3 };
|
||||||
|
snapAt(project, { x: 3, y: 0.02 }, settings);
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
snapAt(project, { x: 3, y: 5.02 }, settings); // andere Linie dazwischen
|
||||||
|
const result = snapAt(project, { x: 3, y: 0.02 }, settings);
|
||||||
|
expect(result?.kind).not.toBe("divide");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+87
-4
@@ -12,6 +12,7 @@ import { add, leftNormal, lineIntersect, normalize, scale, sub } from "../model/
|
|||||||
import { wallReferenceOffset } from "../model/wall";
|
import { wallReferenceOffset } from "../model/wall";
|
||||||
import { centroid as roomCentroid } from "../geometry/roomArea";
|
import { centroid as roomCentroid } from "../geometry/roomArea";
|
||||||
import type { SnapKind, SnapResult, SnapSettings } from "./types";
|
import type { SnapKind, SnapResult, SnapSettings } from "./types";
|
||||||
|
import { sampleBezierChain, sampleCatmullRom } from "./curves";
|
||||||
|
|
||||||
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y);
|
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
const mid = (a: Vec2, b: Vec2): Vec2 => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
|
const mid = (a: Vec2, b: Vec2): Vec2 => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
|
||||||
@@ -28,6 +29,7 @@ const PRIORITY: Record<SnapKind, number> = {
|
|||||||
ortho: 0,
|
ortho: 0,
|
||||||
extension: 0,
|
extension: 0,
|
||||||
angle: 0,
|
angle: 0,
|
||||||
|
divide: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface SnapInput {
|
export interface SnapInput {
|
||||||
@@ -100,6 +102,14 @@ function collectSegments(input: SnapInput): Seg[] {
|
|||||||
const c1 = g.min, c3 = g.max;
|
const c1 = g.min, c3 = g.max;
|
||||||
const c2 = { x: g.max.x, y: g.min.y }, c4 = { x: g.min.x, y: g.max.y };
|
const c2 = { x: g.max.x, y: g.min.y }, c4 = { x: g.min.x, y: g.max.y };
|
||||||
segs.push([c1, c2], [c2, c3], [c3, c4], [c4, c1]);
|
segs.push([c1, c2], [c2, c3], [c3, c4], [c4, c1]);
|
||||||
|
} else if (g.shape === "bezier") {
|
||||||
|
const pts = sampleBezierChain(g.pts);
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) segs.push([pts[i], pts[i + 1]]);
|
||||||
|
} else if (g.shape === "spline") {
|
||||||
|
const closed = g.closed && g.pts.length > 2;
|
||||||
|
const pts = sampleCatmullRom(g.pts, closed);
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) segs.push([pts[i], pts[i + 1]]);
|
||||||
|
if (closed) segs.push([pts[pts.length - 1], pts[0]]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Bereits gesetzte Draft-Segmente (Polylinie/Wand im Bau).
|
// Bereits gesetzte Draft-Segmente (Polylinie/Wand im Bau).
|
||||||
@@ -141,6 +151,58 @@ function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 {
|
|||||||
return { x: a.x + dx * t, y: a.y + dy * t };
|
return { x: a.x + dx * t, y: a.y + dy * t };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Verweil-Erkennung fürs Teilpunkte-Snapping ("divide") ───────────────────
|
||||||
|
// Modul-weiter Zustand (wie `lastCoalesceKey` in projectSlice.ts) statt in
|
||||||
|
// SnapInput/computeSnap-Parametern: computeSnap wird von mehreren, völlig
|
||||||
|
// unabhängigen Stellen aufgerufen (Zeichnen über die Command-Engine UND die
|
||||||
|
// älteren Legacy-Werkzeuge, Griff-Ziehen, Transformieren) — der Verweil-
|
||||||
|
// Zustand muss all diese Aufrufer TEILEN, damit "verweilt der Cursor lange
|
||||||
|
// genug auf derselben Linie" aufrufer-übergreifend gilt (Nutzer-Wunsch:
|
||||||
|
// "wenn der Snappunkt eines neuen Elements auch [z. B. 200ms] auf der Linie
|
||||||
|
// ist, sollten die Teilpunkte auch snapbar sein — aber NUR dann").
|
||||||
|
let dwellSegKey: string | null = null;
|
||||||
|
let dwellSince = 0;
|
||||||
|
let dwellRevealedSeg: Seg | null = null;
|
||||||
|
|
||||||
|
const segKey = (seg: Seg): string =>
|
||||||
|
`${seg[0].x},${seg[0].y}|${seg[1].x},${seg[1].y}`;
|
||||||
|
|
||||||
|
/** Nächstgelegene Strecke zu `raw` innerhalb der Fangtoleranz (Meter), oder null. */
|
||||||
|
function nearestSegment(raw: Vec2, segs: Seg[], tolM: number): Seg | null {
|
||||||
|
let best: Seg | null = null;
|
||||||
|
let bestD = tolM;
|
||||||
|
for (const seg of segs) {
|
||||||
|
const d = dist(perpFoot(raw, seg[0], seg[1]), raw);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = seg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verankert/verwirft den Verweil-Zustand für den aktuellen Cursor und liefert
|
||||||
|
* die "freigegebene" Strecke (Teilpunkte fangbar), sobald der Cursor lange
|
||||||
|
* genug (`delayMs`) auf DERSELBEN Strecke geblieben ist — sonst `null`. Ein
|
||||||
|
* Streckenwechsel (auch zu "keine Strecke") setzt die Verweildauer zurück.
|
||||||
|
*/
|
||||||
|
function updateDwell(raw: Vec2, segs: Seg[], delayMs: number, tolM: number): Seg | null {
|
||||||
|
const seg = nearestSegment(raw, segs, tolM);
|
||||||
|
const key = seg ? segKey(seg) : null;
|
||||||
|
const now = Date.now();
|
||||||
|
if (key !== dwellSegKey) {
|
||||||
|
dwellSegKey = key;
|
||||||
|
dwellSince = now;
|
||||||
|
dwellRevealedSeg = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (seg && dwellRevealedSeg === null && now - dwellSince >= delayMs) {
|
||||||
|
dwellRevealedSeg = seg;
|
||||||
|
}
|
||||||
|
return dwellRevealedSeg;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bestimmt den effektiven Snap für den rohen Cursor-Punkt (oder null).
|
* Bestimmt den effektiven Snap für den rohen Cursor-Punkt (oder null).
|
||||||
* Punkt-Snaps haben Vorrang vor Ortho und Raster (Prioritäts-Bonus).
|
* Punkt-Snaps haben Vorrang vor Ortho und Raster (Prioritäts-Bonus).
|
||||||
@@ -164,10 +226,22 @@ export function computeSnap(input: SnapInput): SnapResult | null {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const segs =
|
const needSegs =
|
||||||
settings.midpoint || settings.intersection || settings.onEdge || settings.endpoint
|
settings.midpoint || settings.intersection || settings.onEdge || settings.endpoint;
|
||||||
? collectSegments(input)
|
const segs = needSegs ? collectSegments(input) : [];
|
||||||
: [];
|
|
||||||
|
// Teilpunkt-Verweilerkennung: braucht dieselben Strecken wie oben. Ohne sie
|
||||||
|
// (alle vier Objekt-Snap-Arten aus) bleibt "divide" ebenfalls inaktiv —
|
||||||
|
// konsistent dazu, dass dann ohnehin keine Linien-bezogenen Snaps gelten.
|
||||||
|
const revealed = updateDwell(raw, segs, settings.hoverDivideDelayMs, tolM);
|
||||||
|
if (revealed) {
|
||||||
|
const [a, b] = revealed;
|
||||||
|
const n = Math.max(2, Math.round(settings.hoverDivideCount));
|
||||||
|
for (let i = 1; i < n; i++) {
|
||||||
|
const t = i / n;
|
||||||
|
consider({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }, "divide");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// endpoint (Strecken-Endpunkte + Draft-Knoten + Raum-Stempel-Anker).
|
// endpoint (Strecken-Endpunkte + Draft-Knoten + Raum-Stempel-Anker).
|
||||||
if (settings.endpoint) {
|
if (settings.endpoint) {
|
||||||
@@ -233,6 +307,15 @@ export function computeSnap(input: SnapInput): SnapResult | null {
|
|||||||
consider(onCircle(c, c.arc.a1), "endpoint");
|
consider(onCircle(c, c.arc.a1), "endpoint");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Ellipse: nur der Mittelpunkt (keine Quadranten — bei gedrehter Ellipse
|
||||||
|
// wären die "Achsenpunkte" ohnehin keine Kreis-Quadranten mehr).
|
||||||
|
if (settings.center) {
|
||||||
|
for (const d of input.project.drawings2d) {
|
||||||
|
if (d.levelId !== input.levelId) continue;
|
||||||
|
if (!input.visibleCodes.has(d.categoryCode)) continue;
|
||||||
|
if (d.geom.shape === "ellipse") consider(d.geom.center, "center");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (best) return best;
|
if (best) return best;
|
||||||
|
|||||||
@@ -554,6 +554,71 @@ const arcTool: Tool = {
|
|||||||
onCancel: (s) => [s, { draft: null, done: true }],
|
onCancel: (s) => [s, { draft: null, done: true }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ellipse: Platzhalter-Werkzeug, gekoppelt an den `ellipse`-Befehl (zwei
|
||||||
|
// Bounding-Box-Ecken). Wie Kreis/Bogen nicht floorOnly.
|
||||||
|
const ellipseTool: Tool = {
|
||||||
|
id: "ellipse",
|
||||||
|
labelKey: "tool.ellipse",
|
||||||
|
hintKey: () => "tool.ellipse.hint",
|
||||||
|
init: () => ({ phase: "idle" }),
|
||||||
|
onClick: (s) => [s, { draft: null }],
|
||||||
|
onMove: (s) => [s, { draft: null }],
|
||||||
|
onCommitGesture: (s) => [s, { draft: null, done: true }],
|
||||||
|
onCancel: (s) => [s, { draft: null, done: true }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spline: Platzhalter-Werkzeug, gekoppelt an den `spline`-Befehl (Wegpunkte,
|
||||||
|
// wie Polylinie, aber geglättet). Nicht floorOnly.
|
||||||
|
const splineTool: Tool = {
|
||||||
|
id: "spline",
|
||||||
|
labelKey: "tool.spline",
|
||||||
|
hintKey: () => "tool.spline.hint",
|
||||||
|
init: () => ({ phase: "idle" }),
|
||||||
|
onClick: (s) => [s, { draft: null }],
|
||||||
|
onMove: (s) => [s, { draft: null }],
|
||||||
|
onCommitGesture: (s) => [s, { draft: null, done: true }],
|
||||||
|
onCancel: (s) => [s, { draft: null, done: true }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bezier: Platzhalter-Werkzeug, gekoppelt an den `bezier`-Befehl (manuelle
|
||||||
|
// Kontrollpunkt-Kette). Nicht floorOnly.
|
||||||
|
const bezierTool: Tool = {
|
||||||
|
id: "bezier",
|
||||||
|
labelKey: "tool.bezier",
|
||||||
|
hintKey: () => "tool.bezier.hint",
|
||||||
|
init: () => ({ phase: "idle" }),
|
||||||
|
onClick: (s) => [s, { draft: null }],
|
||||||
|
onMove: (s) => [s, { draft: null }],
|
||||||
|
onCommitGesture: (s) => [s, { draft: null, done: true }],
|
||||||
|
onCancel: (s) => [s, { draft: null, done: true }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bemassung: Platzhalter-Werkzeug, gekoppelt an den `dimension`-Befehl (zwei
|
||||||
|
// Punkte + Lage der Masslinie). Nicht floorOnly.
|
||||||
|
const dimensionTool: Tool = {
|
||||||
|
id: "dimension",
|
||||||
|
labelKey: "tool.dimension",
|
||||||
|
hintKey: () => "tool.dimension.hint",
|
||||||
|
init: () => ({ phase: "idle" }),
|
||||||
|
onClick: (s) => [s, { draft: null }],
|
||||||
|
onMove: (s) => [s, { draft: null }],
|
||||||
|
onCommitGesture: (s) => [s, { draft: null, done: true }],
|
||||||
|
onCancel: (s) => [s, { draft: null, done: true }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Höhenkote: Platzhalter-Werkzeug, gekoppelt an den `kote`-Befehl (Punkt →
|
||||||
|
// getippter Höhenwert). Nicht floorOnly.
|
||||||
|
const koteTool: Tool = {
|
||||||
|
id: "kote",
|
||||||
|
labelKey: "tool.kote",
|
||||||
|
hintKey: () => "tool.kote.hint",
|
||||||
|
init: () => ({ phase: "idle" }),
|
||||||
|
onClick: (s) => [s, { draft: null }],
|
||||||
|
onMove: (s) => [s, { draft: null }],
|
||||||
|
onCommitGesture: (s) => [s, { draft: null, done: true }],
|
||||||
|
onCancel: (s) => [s, { draft: null, done: true }],
|
||||||
|
};
|
||||||
|
|
||||||
// Text: Platzhalter-Werkzeug, gekoppelt an den `text`-Befehl (Ankerpunkt →
|
// Text: Platzhalter-Werkzeug, gekoppelt an den `text`-Befehl (Ankerpunkt →
|
||||||
// getippte Zeile). Wie Kreis/Bogen nicht floorOnly (freies 2D-Element).
|
// getippte Zeile). Wie Kreis/Bogen nicht floorOnly (freies 2D-Element).
|
||||||
const textTool: Tool = {
|
const textTool: Tool = {
|
||||||
@@ -611,6 +676,11 @@ const TOOLS: Record<ToolId, Tool> = {
|
|||||||
rect: rectTool,
|
rect: rectTool,
|
||||||
circle: circleTool,
|
circle: circleTool,
|
||||||
arc: arcTool,
|
arc: arcTool,
|
||||||
|
ellipse: ellipseTool,
|
||||||
|
spline: splineTool,
|
||||||
|
bezier: bezierTool,
|
||||||
|
dimension: dimensionTool,
|
||||||
|
kote: koteTool,
|
||||||
text: textTool,
|
text: textTool,
|
||||||
textbox: textboxTool,
|
textbox: textboxTool,
|
||||||
image: imageTool,
|
image: imageTool,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* `commitTransform`/`transformPreview` mit MEHREREN gewählten 2D-Elementen
|
||||||
|
* (`TransformSelection.drawingIds`) — Nutzer-Report: "wenn ich zwei Linien
|
||||||
|
* anwähle, dass ich dann m usw nicht verwenden kann". Vorher trug
|
||||||
|
* `TransformSelection` nur ein einzelnes `drawingId`, das bei einer
|
||||||
|
* Mehrfachauswahl (2+ Elemente) `null` war → Bewegen/Kopieren/Spiegeln/
|
||||||
|
* Drehen/Feld griffen gar nicht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { commitTransform } from "./transform";
|
||||||
|
import type { TransformSelection } from "./transform";
|
||||||
|
import type { Drawing2D, Project } from "../model/types";
|
||||||
|
|
||||||
|
function project(drawings: Drawing2D[]): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [],
|
||||||
|
layers: [],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
drawings2d: drawings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(id: string, x: number): Drawing2D {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom: { shape: "line", a: { x, y: 0 }, b: { x: x + 1, y: 0 } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("commitTransform — Mehrfachauswahl (drawingIds)", () => {
|
||||||
|
it("move: verschiebt ALLE gewählten 2D-Elemente, nicht nur eines", () => {
|
||||||
|
const p = project([line("d1", 0), line("d2", 5), line("d3", 10)]);
|
||||||
|
const sel: TransformSelection = { wallIds: [], drawingId: null, drawingIds: ["d1", "d2"] };
|
||||||
|
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 2, y: 3 }], "move", 1);
|
||||||
|
const d1 = next.drawings2d.find((d) => d.id === "d1")!.geom;
|
||||||
|
const d2 = next.drawings2d.find((d) => d.id === "d2")!.geom;
|
||||||
|
const d3 = next.drawings2d.find((d) => d.id === "d3")!.geom;
|
||||||
|
if (d1.shape === "line") expect(d1.a).toEqual({ x: 2, y: 3 });
|
||||||
|
if (d2.shape === "line") expect(d2.a).toEqual({ x: 7, y: 3 }); // 5+2
|
||||||
|
if (d3.shape === "line") expect(d3.a).toEqual({ x: 10, y: 0 }); // nicht gewählt, unverändert
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copy: dupliziert ALLE gewählten 2D-Elemente (Original bleibt)", () => {
|
||||||
|
const p = project([line("d1", 0), line("d2", 5)]);
|
||||||
|
const sel: TransformSelection = { wallIds: [], drawingId: null, drawingIds: ["d1", "d2"] };
|
||||||
|
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 1, y: 0 }], "copy", 1);
|
||||||
|
expect(next.drawings2d.length).toBe(4); // 2 Original + 2 Kopien
|
||||||
|
});
|
||||||
|
|
||||||
|
it("array: dupliziert ALLE gewählten 2D-Elemente N-mal", () => {
|
||||||
|
const p = project([line("d1", 0), line("d2", 5)]);
|
||||||
|
const sel: TransformSelection = { wallIds: [], drawingId: null, drawingIds: ["d1", "d2"] };
|
||||||
|
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 1, y: 0 }], "array", 3);
|
||||||
|
expect(next.drawings2d.length).toBe(8); // 2 Original + 2×3 Kopien
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weiterhin abwärtskompatibel: einzelnes `drawingId` (ohne drawingIds) funktioniert unverändert", () => {
|
||||||
|
const p = project([line("d1", 0)]);
|
||||||
|
const sel: TransformSelection = { wallIds: [], drawingId: "d1" };
|
||||||
|
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 3, y: 0 }], "move", 1);
|
||||||
|
const d1 = next.drawings2d[0].geom;
|
||||||
|
if (d1.shape === "line") expect(d1.a).toEqual({ x: 3, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drawingId + drawingIds zusammen werden dedupliziert (kein Doppel-Verschieben)", () => {
|
||||||
|
const p = project([line("d1", 0), line("d2", 5)]);
|
||||||
|
const sel: TransformSelection = { wallIds: [], drawingId: "d1", drawingIds: ["d1", "d2"] };
|
||||||
|
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 1, y: 0 }], "copy", 1);
|
||||||
|
expect(next.drawings2d.length).toBe(4); // je EINE Kopie pro Element, nicht zwei für d1
|
||||||
|
});
|
||||||
|
});
|
||||||
+29
-6
@@ -30,13 +30,27 @@ export type CopyMode = "move" | "copy" | "array" | "distribute"; // U / I / O /
|
|||||||
/** Welche Auswahl transformiert wird. */
|
/** Welche Auswahl transformiert wird. */
|
||||||
export interface TransformSelection {
|
export interface TransformSelection {
|
||||||
wallIds: string[];
|
wallIds: string[];
|
||||||
|
/**
|
||||||
|
* Einzeln gewähltes 2D-Element (Legacy-Feld, s. `CommandSelection.drawingId`).
|
||||||
|
* `drawingIds` (Mehrfachauswahl) wird bevorzugt ausgewertet; beide zusammen
|
||||||
|
* ergeben die MENGE der zu transformierenden 2D-Elemente (Union, dedupliziert).
|
||||||
|
*/
|
||||||
drawingId: string | null;
|
drawingId: string | null;
|
||||||
|
/** ALLE gewählten 2D-Elemente (Mehrfachauswahl) — s. `CommandSelection.drawingIds`. */
|
||||||
|
drawingIds?: string[];
|
||||||
/** Gewählter extrudierter Körper (truck-Integration); nur move.ts setzt ihn. */
|
/** Gewählter extrudierter Körper (truck-Integration); nur move.ts setzt ihn. */
|
||||||
extrudedSolidId?: string | null;
|
extrudedSolidId?: string | null;
|
||||||
/** Gewählte Stütze (Tragwerk); von move/copy/mirror gesetzt. */
|
/** Gewählte Stütze (Tragwerk); von move/copy/mirror gesetzt. */
|
||||||
columnId?: string | null;
|
columnId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vereinigt `drawingId`/`drawingIds` zu einer eindeutigen ID-Menge (dedupliziert). */
|
||||||
|
function drawingIdSet(sel: TransformSelection): Set<string> {
|
||||||
|
const ids = new Set(sel.drawingIds ?? []);
|
||||||
|
if (sel.drawingId) ids.add(sel.drawingId);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
/** Wie viele Klickpunkte die Operation braucht (Drehen = 3, sonst 2). */
|
/** Wie viele Klickpunkte die Operation braucht (Drehen = 3, sonst 2). */
|
||||||
export const requiredPoints = (op: TransformOp): number => (op === "rotate" ? 3 : 2);
|
export const requiredPoints = (op: TransformOp): number => (op === "rotate" ? 3 : 2);
|
||||||
|
|
||||||
@@ -116,10 +130,8 @@ function gatherItems(project: Project, sel: TransformSelection): AnyItem[] {
|
|||||||
const items: AnyItem[] = [];
|
const items: AnyItem[] = [];
|
||||||
const ids = new Set(sel.wallIds);
|
const ids = new Set(sel.wallIds);
|
||||||
for (const w of project.walls) if (ids.has(w.id)) items.push({ kind: "wall", wall: w });
|
for (const w of project.walls) if (ids.has(w.id)) items.push({ kind: "wall", wall: w });
|
||||||
if (sel.drawingId) {
|
const drawIds = drawingIdSet(sel);
|
||||||
const d = project.drawings2d.find((x) => x.id === sel.drawingId);
|
for (const d of project.drawings2d) if (drawIds.has(d.id)) items.push({ kind: "drawing", drawing: d });
|
||||||
if (d) items.push({ kind: "drawing", drawing: d });
|
|
||||||
}
|
|
||||||
if (sel.extrudedSolidId) {
|
if (sel.extrudedSolidId) {
|
||||||
const s = (project.extrudedSolids ?? []).find((x) => x.id === sel.extrudedSolidId);
|
const s = (project.extrudedSolids ?? []).find((x) => x.id === sel.extrudedSolidId);
|
||||||
if (s) items.push({ kind: "extrudedSolid", solid: s });
|
if (s) items.push({ kind: "extrudedSolid", solid: s });
|
||||||
@@ -179,6 +191,16 @@ const mvGeom = (geom: Drawing2DGeom, fn: (p: Vec2) => Vec2): Drawing2DGeom => {
|
|||||||
return { ...geom, center: fn(geom.center) };
|
return { ...geom, center: fn(geom.center) };
|
||||||
case "arc":
|
case "arc":
|
||||||
return { ...geom, center: fn(geom.center) };
|
return { ...geom, center: fn(geom.center) };
|
||||||
|
case "ellipse":
|
||||||
|
return { ...geom, center: fn(geom.center) };
|
||||||
|
case "bezier":
|
||||||
|
return { ...geom, pts: geom.pts.map(fn) };
|
||||||
|
case "spline":
|
||||||
|
return { ...geom, pts: geom.pts.map(fn) };
|
||||||
|
case "dimension":
|
||||||
|
return { ...geom, pts: geom.pts.map(fn) };
|
||||||
|
case "kote":
|
||||||
|
return { ...geom, at: fn(geom.at) };
|
||||||
case "text":
|
case "text":
|
||||||
return { ...geom, at: fn(geom.at) };
|
return { ...geom, at: fn(geom.at) };
|
||||||
case "image": {
|
case "image": {
|
||||||
@@ -282,9 +304,10 @@ export function commitTransform(
|
|||||||
if (movesOriginal(mode)) {
|
if (movesOriginal(mode)) {
|
||||||
const fn = transformFn(op, pts, 1);
|
const fn = transformFn(op, pts, 1);
|
||||||
walls = walls.map((w) => (wallIds.has(w.id) ? { ...w, start: fn(w.start), end: fn(w.end) } : w));
|
walls = walls.map((w) => (wallIds.has(w.id) ? { ...w, start: fn(w.start), end: fn(w.end) } : w));
|
||||||
if (sel.drawingId) {
|
const moveDrawIds = drawingIdSet(sel);
|
||||||
|
if (moveDrawIds.size > 0) {
|
||||||
drawings2d = drawings2d.map((d) =>
|
drawings2d = drawings2d.map((d) =>
|
||||||
d.id === sel.drawingId ? { ...d, geom: mvGeom(d.geom, fn) } : d,
|
moveDrawIds.has(d.id) ? { ...d, geom: mvGeom(d.geom, fn) } : d,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (sel.extrudedSolidId) {
|
if (sel.extrudedSolidId) {
|
||||||
|
|||||||
+35
-3
@@ -21,6 +21,11 @@ export type ToolId =
|
|||||||
| "rect"
|
| "rect"
|
||||||
| "circle"
|
| "circle"
|
||||||
| "arc"
|
| "arc"
|
||||||
|
| "ellipse"
|
||||||
|
| "spline"
|
||||||
|
| "bezier"
|
||||||
|
| "dimension"
|
||||||
|
| "kote"
|
||||||
| "text"
|
| "text"
|
||||||
| "textbox"
|
| "textbox"
|
||||||
| "image";
|
| "image";
|
||||||
@@ -37,7 +42,15 @@ export type SnapKind =
|
|||||||
| "grid"
|
| "grid"
|
||||||
| "ortho"
|
| "ortho"
|
||||||
| "extension"
|
| "extension"
|
||||||
| "angle";
|
| "angle"
|
||||||
|
/**
|
||||||
|
* Teilpunkt einer Linie, NUR fangbar, solange der Cursor lange genug (s.
|
||||||
|
* `SnapSettings.hoverDivideDelayMs`) auf DERSELBEN Linie verweilt hat (s.
|
||||||
|
* `computeSnap`s Verweil-Erkennung) — bewusst NICHT dauerhaft aktiv wie die
|
||||||
|
* übrigen Snap-Arten, sonst würde jede N-tel-Position jeder Linie den
|
||||||
|
* Fang-Kandidatenraum überfluten.
|
||||||
|
*/
|
||||||
|
| "divide";
|
||||||
|
|
||||||
export interface SnapResult {
|
export interface SnapResult {
|
||||||
/** Gefangener Punkt (Meter). */
|
/** Gefangener Punkt (Meter). */
|
||||||
@@ -71,15 +84,32 @@ export interface SnapSettings {
|
|||||||
commonAngles: boolean;
|
commonAngles: boolean;
|
||||||
/** Toleranz (Grad) für das weiche Winkel-Einrasten. */
|
/** Toleranz (Grad) für das weiche Winkel-Einrasten. */
|
||||||
commonAngleTolerance: number;
|
commonAngleTolerance: number;
|
||||||
|
/**
|
||||||
|
* Verweilt der Cursor länger als diese Zeit (ms) ruhig auf einer Linie
|
||||||
|
* (2D-Element, kein aktiver Zug/Werkzeug), werden ihre Teilpunkte (s.
|
||||||
|
* `hoverDivideCount`) kurz mit Akzentfarbe eingeblendet — reine
|
||||||
|
* Sichtbarkeits-Hilfe, kein zusätzliches Snap-Ziel.
|
||||||
|
*/
|
||||||
|
hoverDivideDelayMs: number;
|
||||||
|
/**
|
||||||
|
* In wie viele gleiche Teile die gehoverte Linie dabei angezeigt wird
|
||||||
|
* (2 = nur der Mittelpunkt, 3 = zwei Drittelpunkte, usw.).
|
||||||
|
*/
|
||||||
|
hoverDivideCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default-Snap-Einstellungen (endpoint/midpoint/intersection/onEdge + grid). */
|
/**
|
||||||
|
* Default-Snap-Einstellungen (endpoint/midpoint/intersection/onEdge/center +
|
||||||
|
* grid). `center` (Kreis-/Bogen-Mittelpunkt + Quadranten 0/90/180/270°) war
|
||||||
|
* lange im Modell vorhanden, aber ohne UI-Schalter unerreichbar (Default
|
||||||
|
* `false`) — auf Nutzer-Wunsch jetzt an UND im Footer (StatusBar) umschaltbar.
|
||||||
|
*/
|
||||||
export const DEFAULT_SNAP: SnapSettings = {
|
export const DEFAULT_SNAP: SnapSettings = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
endpoint: true,
|
endpoint: true,
|
||||||
midpoint: true,
|
midpoint: true,
|
||||||
intersection: true,
|
intersection: true,
|
||||||
center: false,
|
center: true,
|
||||||
onEdge: true,
|
onEdge: true,
|
||||||
grid: true,
|
grid: true,
|
||||||
gridSize: 0.1,
|
gridSize: 0.1,
|
||||||
@@ -88,6 +118,8 @@ export const DEFAULT_SNAP: SnapSettings = {
|
|||||||
tolerancePx: 12,
|
tolerancePx: 12,
|
||||||
commonAngles: true,
|
commonAngles: true,
|
||||||
commonAngleTolerance: 2.5,
|
commonAngleTolerance: 2.5,
|
||||||
|
hoverDivideDelayMs: 300,
|
||||||
|
hoverDivideCount: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Werkzeug-Schnittstelle ──────────────────────────────────────────────────
|
// ── Werkzeug-Schnittstelle ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -172,6 +172,15 @@ function SnapControl({
|
|||||||
/>
|
/>
|
||||||
<span>{t("snap.midpoint")}</span>
|
<span>{t("snap.midpoint")}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="sb-snap-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={snap.center}
|
||||||
|
disabled={!snap.enabled}
|
||||||
|
onChange={(e) => onSnapChange({ ...snap, center: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>{t("snap.center")}</span>
|
||||||
|
</label>
|
||||||
<label className="sb-snap-row">
|
<label className="sb-snap-row">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -245,6 +254,35 @@ function SnapControl({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{/* Hover-Teilpunkte (rein visuell, unabhängig vom Fang selbst — daher
|
||||||
|
NICHT an snap.enabled gekoppelt): Ruht der Cursor auf einer Linie,
|
||||||
|
erscheinen nach der Verzögerung ihre Teilpunkte mit Akzentfarbe. */}
|
||||||
|
<label className="sb-snap-row sb-snap-row-num">
|
||||||
|
<span>{t("snap.hoverDivideDelayMs")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={50}
|
||||||
|
min={0}
|
||||||
|
max={3000}
|
||||||
|
value={snap.hoverDivideDelayMs}
|
||||||
|
onChange={(e) =>
|
||||||
|
onSnapChange({ ...snap, hoverDivideDelayMs: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sb-snap-row sb-snap-row-num">
|
||||||
|
<span>{t("snap.hoverDivideCount")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={1}
|
||||||
|
min={2}
|
||||||
|
max={12}
|
||||||
|
value={snap.hoverDivideCount}
|
||||||
|
onChange={(e) =>
|
||||||
|
onSnapChange({ ...snap, hoverDivideCount: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ export function CommandIcon({ name }: { name: string }) {
|
|||||||
<rect x="5.5" y="5.5" width="8" height="8" />
|
<rect x="5.5" y="5.5" width="8" height="8" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case "array":
|
||||||
|
// Drei Kopien eines Quadrats entlang eines Richtungspfeils (Feld).
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<rect x="1.5" y="10.5" width="3.6" height="3.6" />
|
||||||
|
<rect x="6.2" y="6" width="3.6" height="3.6" strokeDasharray="1.6 1.2" />
|
||||||
|
<rect x="10.9" y="1.5" width="3.6" height="3.6" strokeDasharray="1.6 1.2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case "measure":
|
case "measure":
|
||||||
// Lineal/Massband: Strecke mit Endmarken + kleiner Skala.
|
// Lineal/Massband: Strecke mit Endmarken + kleiner Skala.
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function drawingGripVertices(d: Drawing2D): Vec2[] {
|
|||||||
{ x: g.min.x, y: g.max.y },
|
{ x: g.min.x, y: g.max.y },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (g.shape === "bezier" || g.shape === "spline" || g.shape === "dimension") return [...g.pts];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ export function drawingMoveAnchor(d: Drawing2D, verts: Vec2[]): Vec2 | null {
|
|||||||
return { x: sx / verts.length, y: sy / verts.length };
|
return { x: sx / verts.length, y: sy / verts.length };
|
||||||
}
|
}
|
||||||
const g = d.geom;
|
const g = d.geom;
|
||||||
if (g.shape === "circle" || g.shape === "arc") return g.center;
|
if (g.shape === "circle" || g.shape === "arc" || g.shape === "ellipse") return g.center;
|
||||||
if (g.shape === "text") return g.at;
|
if (g.shape === "text" || g.shape === "kote") return g.at;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-30
@@ -9,7 +9,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { formatM } from "../model/types";
|
|
||||||
import type {
|
import type {
|
||||||
Ceiling,
|
Ceiling,
|
||||||
Drawing2D,
|
Drawing2D,
|
||||||
@@ -28,9 +27,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,
|
||||||
@@ -62,6 +63,8 @@ export function Content({
|
|||||||
hairline,
|
hairline,
|
||||||
marqueeColor,
|
marqueeColor,
|
||||||
snapColor,
|
snapColor,
|
||||||
|
hoverDivideDelayMs,
|
||||||
|
hoverDivideCount,
|
||||||
renderMode,
|
renderMode,
|
||||||
view3d,
|
view3d,
|
||||||
fov,
|
fov,
|
||||||
@@ -84,6 +87,7 @@ export function Content({
|
|||||||
selectedStairIds,
|
selectedStairIds,
|
||||||
selectedExtrudedSolidIds,
|
selectedExtrudedSolidIds,
|
||||||
selectedColumnIds,
|
selectedColumnIds,
|
||||||
|
selectedGroupIds,
|
||||||
selectedRoomIds,
|
selectedRoomIds,
|
||||||
selectedRoofIds,
|
selectedRoofIds,
|
||||||
selectedContextObjectIds,
|
selectedContextObjectIds,
|
||||||
@@ -92,6 +96,9 @@ export function Content({
|
|||||||
onStampMove,
|
onStampMove,
|
||||||
onStampEdit,
|
onStampEdit,
|
||||||
onEditDrawingText,
|
onEditDrawingText,
|
||||||
|
onEnterGroup,
|
||||||
|
groupPath,
|
||||||
|
onLeaveGroup,
|
||||||
editTextId,
|
editTextId,
|
||||||
editTextMinHeightM,
|
editTextMinHeightM,
|
||||||
onCommitTextEdit,
|
onCommitTextEdit,
|
||||||
@@ -115,6 +122,7 @@ export function Content({
|
|||||||
toolHandlers,
|
toolHandlers,
|
||||||
hudFields,
|
hudFields,
|
||||||
grips,
|
grips,
|
||||||
|
gripHandleLines,
|
||||||
edgeGrips,
|
edgeGrips,
|
||||||
selectedDrawingId,
|
selectedDrawingId,
|
||||||
selectedDrawingIds,
|
selectedDrawingIds,
|
||||||
@@ -141,6 +149,8 @@ export function Content({
|
|||||||
marqueeColor: string;
|
marqueeColor: string;
|
||||||
/** Farbe der Snap-/Endpunkt-Hervorhebung im Grundriss. */
|
/** Farbe der Snap-/Endpunkt-Hervorhebung im Grundriss. */
|
||||||
snapColor: string;
|
snapColor: string;
|
||||||
|
hoverDivideDelayMs?: number;
|
||||||
|
hoverDivideCount?: number;
|
||||||
renderMode: RenderMode;
|
renderMode: RenderMode;
|
||||||
/** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */
|
/** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */
|
||||||
view3d: View3d;
|
view3d: View3d;
|
||||||
@@ -174,6 +184,7 @@ export function Content({
|
|||||||
selectedStairIds: string[];
|
selectedStairIds: string[];
|
||||||
selectedExtrudedSolidIds: string[];
|
selectedExtrudedSolidIds: string[];
|
||||||
selectedColumnIds: string[];
|
selectedColumnIds: string[];
|
||||||
|
selectedGroupIds: string[];
|
||||||
selectedRoomIds: string[];
|
selectedRoomIds: string[];
|
||||||
selectedRoofIds: string[];
|
selectedRoofIds: string[];
|
||||||
selectedContextObjectIds: string[];
|
selectedContextObjectIds: string[];
|
||||||
@@ -182,6 +193,9 @@ 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[];
|
||||||
|
onLeaveGroup?: () => void;
|
||||||
editTextId?: string | null;
|
editTextId?: string | null;
|
||||||
editTextMinHeightM?: number | null;
|
editTextMinHeightM?: number | null;
|
||||||
onCommitTextEdit?: (doc: RichTextDoc) => void;
|
onCommitTextEdit?: (doc: RichTextDoc) => void;
|
||||||
@@ -214,6 +228,7 @@ export function Content({
|
|||||||
toolHandlers: ToolHandlers;
|
toolHandlers: ToolHandlers;
|
||||||
hudFields: HudFieldsState | null;
|
hudFields: HudFieldsState | null;
|
||||||
grips: Vec2[];
|
grips: Vec2[];
|
||||||
|
gripHandleLines: { a: Vec2; b: Vec2 }[];
|
||||||
edgeGrips: EdgeGrip[];
|
edgeGrips: EdgeGrip[];
|
||||||
selectedDrawingId: string | null;
|
selectedDrawingId: string | null;
|
||||||
selectedDrawingIds?: string[];
|
selectedDrawingIds?: string[];
|
||||||
@@ -291,6 +306,8 @@ export function Content({
|
|||||||
hairline={hairline}
|
hairline={hairline}
|
||||||
marqueeColor={marqueeColor}
|
marqueeColor={marqueeColor}
|
||||||
snapColor={snapColor}
|
snapColor={snapColor}
|
||||||
|
hoverDivideDelayMs={hoverDivideDelayMs}
|
||||||
|
hoverDivideCount={hoverDivideCount}
|
||||||
mono={mono}
|
mono={mono}
|
||||||
planViewRef={planViewRef}
|
planViewRef={planViewRef}
|
||||||
onCursor={onCursor}
|
onCursor={onCursor}
|
||||||
@@ -301,6 +318,7 @@ export function Content({
|
|||||||
selectedStairIds={selectedStairIds}
|
selectedStairIds={selectedStairIds}
|
||||||
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
|
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
|
||||||
selectedColumnIds={selectedColumnIds}
|
selectedColumnIds={selectedColumnIds}
|
||||||
|
selectedGroupIds={selectedGroupIds}
|
||||||
selectedRoomIds={selectedRoomIds}
|
selectedRoomIds={selectedRoomIds}
|
||||||
selectedRoofIds={selectedRoofIds}
|
selectedRoofIds={selectedRoofIds}
|
||||||
selectedSectionLineId={selectedSectionLineId}
|
selectedSectionLineId={selectedSectionLineId}
|
||||||
@@ -308,6 +326,9 @@ export function Content({
|
|||||||
onStampMove={onStampMove}
|
onStampMove={onStampMove}
|
||||||
onStampEdit={onStampEdit}
|
onStampEdit={onStampEdit}
|
||||||
onEditDrawingText={onEditDrawingText}
|
onEditDrawingText={onEditDrawingText}
|
||||||
|
onEnterGroup={onEnterGroup}
|
||||||
|
groupPath={groupPath}
|
||||||
|
onLeaveGroup={onLeaveGroup}
|
||||||
editTextId={editTextId}
|
editTextId={editTextId}
|
||||||
editTextMinHeightM={editTextMinHeightM}
|
editTextMinHeightM={editTextMinHeightM}
|
||||||
onCommitTextEdit={onCommitTextEdit}
|
onCommitTextEdit={onCommitTextEdit}
|
||||||
@@ -322,6 +343,7 @@ export function Content({
|
|||||||
toolHandlers={toolHandlers}
|
toolHandlers={toolHandlers}
|
||||||
hudFields={hudFields}
|
hudFields={hudFields}
|
||||||
grips={grips}
|
grips={grips}
|
||||||
|
gripHandleLines={gripHandleLines}
|
||||||
edgeGrips={edgeGrips}
|
edgeGrips={edgeGrips}
|
||||||
selectedDrawingId={selectedDrawingId}
|
selectedDrawingId={selectedDrawingId}
|
||||||
selectedDrawingIds={selectedDrawingIds}
|
selectedDrawingIds={selectedDrawingIds}
|
||||||
@@ -353,6 +375,8 @@ export function Content({
|
|||||||
hairline={hairline}
|
hairline={hairline}
|
||||||
marqueeColor={marqueeColor}
|
marqueeColor={marqueeColor}
|
||||||
snapColor={snapColor}
|
snapColor={snapColor}
|
||||||
|
hoverDivideDelayMs={hoverDivideDelayMs}
|
||||||
|
hoverDivideCount={hoverDivideCount}
|
||||||
mono={mono}
|
mono={mono}
|
||||||
planViewRef={planViewRef}
|
planViewRef={planViewRef}
|
||||||
onCursor={onCursor}
|
onCursor={onCursor}
|
||||||
@@ -363,6 +387,7 @@ export function Content({
|
|||||||
selectedStairIds={selectedStairIds}
|
selectedStairIds={selectedStairIds}
|
||||||
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
|
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
|
||||||
selectedColumnIds={selectedColumnIds}
|
selectedColumnIds={selectedColumnIds}
|
||||||
|
selectedGroupIds={selectedGroupIds}
|
||||||
selectedRoomIds={selectedRoomIds}
|
selectedRoomIds={selectedRoomIds}
|
||||||
selectedRoofIds={selectedRoofIds}
|
selectedRoofIds={selectedRoofIds}
|
||||||
selectedContextObjectIds={selectedContextObjectIds}
|
selectedContextObjectIds={selectedContextObjectIds}
|
||||||
@@ -371,6 +396,9 @@ export function Content({
|
|||||||
onStampMove={onStampMove}
|
onStampMove={onStampMove}
|
||||||
onStampEdit={onStampEdit}
|
onStampEdit={onStampEdit}
|
||||||
onEditDrawingText={onEditDrawingText}
|
onEditDrawingText={onEditDrawingText}
|
||||||
|
onEnterGroup={onEnterGroup}
|
||||||
|
groupPath={groupPath}
|
||||||
|
onLeaveGroup={onLeaveGroup}
|
||||||
editTextId={editTextId}
|
editTextId={editTextId}
|
||||||
editTextMinHeightM={editTextMinHeightM}
|
editTextMinHeightM={editTextMinHeightM}
|
||||||
onCommitTextEdit={onCommitTextEdit}
|
onCommitTextEdit={onCommitTextEdit}
|
||||||
@@ -385,6 +413,7 @@ export function Content({
|
|||||||
toolHandlers={toolHandlers}
|
toolHandlers={toolHandlers}
|
||||||
hudFields={hudFields}
|
hudFields={hudFields}
|
||||||
grips={grips}
|
grips={grips}
|
||||||
|
gripHandleLines={gripHandleLines}
|
||||||
edgeGrips={edgeGrips}
|
edgeGrips={edgeGrips}
|
||||||
selectedDrawingId={selectedDrawingId}
|
selectedDrawingId={selectedDrawingId}
|
||||||
selectedDrawingIds={selectedDrawingIds}
|
selectedDrawingIds={selectedDrawingIds}
|
||||||
@@ -458,6 +487,8 @@ export function Content({
|
|||||||
hairline={hairline}
|
hairline={hairline}
|
||||||
marqueeColor={marqueeColor}
|
marqueeColor={marqueeColor}
|
||||||
snapColor={snapColor}
|
snapColor={snapColor}
|
||||||
|
hoverDivideDelayMs={hoverDivideDelayMs}
|
||||||
|
hoverDivideCount={hoverDivideCount}
|
||||||
mono={mono}
|
mono={mono}
|
||||||
planViewRef={planViewRef}
|
planViewRef={planViewRef}
|
||||||
onCursor={onCursor}
|
onCursor={onCursor}
|
||||||
@@ -468,6 +499,7 @@ export function Content({
|
|||||||
selectedStairIds={selectedStairIds}
|
selectedStairIds={selectedStairIds}
|
||||||
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
|
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
|
||||||
selectedColumnIds={selectedColumnIds}
|
selectedColumnIds={selectedColumnIds}
|
||||||
|
selectedGroupIds={selectedGroupIds}
|
||||||
selectedRoomIds={selectedRoomIds}
|
selectedRoomIds={selectedRoomIds}
|
||||||
selectedRoofIds={selectedRoofIds}
|
selectedRoofIds={selectedRoofIds}
|
||||||
selectedContextObjectIds={selectedContextObjectIds}
|
selectedContextObjectIds={selectedContextObjectIds}
|
||||||
@@ -476,6 +508,9 @@ export function Content({
|
|||||||
onStampMove={onStampMove}
|
onStampMove={onStampMove}
|
||||||
onStampEdit={onStampEdit}
|
onStampEdit={onStampEdit}
|
||||||
onEditDrawingText={onEditDrawingText}
|
onEditDrawingText={onEditDrawingText}
|
||||||
|
onEnterGroup={onEnterGroup}
|
||||||
|
groupPath={groupPath}
|
||||||
|
onLeaveGroup={onLeaveGroup}
|
||||||
editTextId={editTextId}
|
editTextId={editTextId}
|
||||||
editTextMinHeightM={editTextMinHeightM}
|
editTextMinHeightM={editTextMinHeightM}
|
||||||
onCommitTextEdit={onCommitTextEdit}
|
onCommitTextEdit={onCommitTextEdit}
|
||||||
@@ -490,6 +525,7 @@ export function Content({
|
|||||||
toolHandlers={toolHandlers}
|
toolHandlers={toolHandlers}
|
||||||
hudFields={hudFields}
|
hudFields={hudFields}
|
||||||
grips={grips}
|
grips={grips}
|
||||||
|
gripHandleLines={gripHandleLines}
|
||||||
edgeGrips={edgeGrips}
|
edgeGrips={edgeGrips}
|
||||||
selectedDrawingId={selectedDrawingId}
|
selectedDrawingId={selectedDrawingId}
|
||||||
selectedDrawingIds={selectedDrawingIds}
|
selectedDrawingIds={selectedDrawingIds}
|
||||||
@@ -511,6 +547,8 @@ function LevelPlanView({
|
|||||||
hairline,
|
hairline,
|
||||||
marqueeColor,
|
marqueeColor,
|
||||||
snapColor,
|
snapColor,
|
||||||
|
hoverDivideDelayMs,
|
||||||
|
hoverDivideCount,
|
||||||
mono,
|
mono,
|
||||||
planViewRef,
|
planViewRef,
|
||||||
onCursor,
|
onCursor,
|
||||||
@@ -521,6 +559,7 @@ function LevelPlanView({
|
|||||||
selectedStairIds,
|
selectedStairIds,
|
||||||
selectedExtrudedSolidIds,
|
selectedExtrudedSolidIds,
|
||||||
selectedColumnIds,
|
selectedColumnIds,
|
||||||
|
selectedGroupIds,
|
||||||
selectedRoomIds,
|
selectedRoomIds,
|
||||||
selectedRoofIds,
|
selectedRoofIds,
|
||||||
selectedContextObjectIds,
|
selectedContextObjectIds,
|
||||||
@@ -529,6 +568,9 @@ function LevelPlanView({
|
|||||||
onStampMove,
|
onStampMove,
|
||||||
onStampEdit,
|
onStampEdit,
|
||||||
onEditDrawingText,
|
onEditDrawingText,
|
||||||
|
onEnterGroup,
|
||||||
|
groupPath,
|
||||||
|
onLeaveGroup,
|
||||||
editTextId,
|
editTextId,
|
||||||
editTextMinHeightM,
|
editTextMinHeightM,
|
||||||
onCommitTextEdit,
|
onCommitTextEdit,
|
||||||
@@ -543,6 +585,7 @@ function LevelPlanView({
|
|||||||
toolHandlers,
|
toolHandlers,
|
||||||
hudFields,
|
hudFields,
|
||||||
grips,
|
grips,
|
||||||
|
gripHandleLines,
|
||||||
edgeGrips,
|
edgeGrips,
|
||||||
selectedDrawingId,
|
selectedDrawingId,
|
||||||
selectedDrawingIds,
|
selectedDrawingIds,
|
||||||
@@ -557,6 +600,8 @@ function LevelPlanView({
|
|||||||
hairline: boolean;
|
hairline: boolean;
|
||||||
marqueeColor: string;
|
marqueeColor: string;
|
||||||
snapColor: string;
|
snapColor: string;
|
||||||
|
hoverDivideDelayMs?: number;
|
||||||
|
hoverDivideCount?: number;
|
||||||
mono: boolean;
|
mono: boolean;
|
||||||
planViewRef: React.RefObject<PlanViewHandle>;
|
planViewRef: React.RefObject<PlanViewHandle>;
|
||||||
onCursor: (model: Vec2 | null) => void;
|
onCursor: (model: Vec2 | null) => void;
|
||||||
@@ -567,6 +612,7 @@ function LevelPlanView({
|
|||||||
selectedStairIds: string[];
|
selectedStairIds: string[];
|
||||||
selectedExtrudedSolidIds: string[];
|
selectedExtrudedSolidIds: string[];
|
||||||
selectedColumnIds: string[];
|
selectedColumnIds: string[];
|
||||||
|
selectedGroupIds: string[];
|
||||||
selectedRoomIds: string[];
|
selectedRoomIds: string[];
|
||||||
selectedRoofIds: string[];
|
selectedRoofIds: string[];
|
||||||
selectedContextObjectIds?: string[];
|
selectedContextObjectIds?: string[];
|
||||||
@@ -575,6 +621,9 @@ 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[];
|
||||||
|
onLeaveGroup?: () => void;
|
||||||
editTextId?: string | null;
|
editTextId?: string | null;
|
||||||
editTextMinHeightM?: number | null;
|
editTextMinHeightM?: number | null;
|
||||||
onCommitTextEdit?: (doc: RichTextDoc) => void;
|
onCommitTextEdit?: (doc: RichTextDoc) => void;
|
||||||
@@ -593,6 +642,7 @@ function LevelPlanView({
|
|||||||
toolHandlers: ToolHandlers;
|
toolHandlers: ToolHandlers;
|
||||||
hudFields: HudFieldsState | null;
|
hudFields: HudFieldsState | null;
|
||||||
grips: Vec2[];
|
grips: Vec2[];
|
||||||
|
gripHandleLines: { a: Vec2; b: Vec2 }[];
|
||||||
edgeGrips: EdgeGrip[];
|
edgeGrips: EdgeGrip[];
|
||||||
selectedDrawingId: string | null;
|
selectedDrawingId: string | null;
|
||||||
selectedDrawingIds?: string[];
|
selectedDrawingIds?: string[];
|
||||||
@@ -600,19 +650,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
|
||||||
@@ -624,22 +686,18 @@ function LevelPlanView({
|
|||||||
pushNativeWalls(project);
|
pushNativeWalls(project);
|
||||||
}, [project]);
|
}, [project]);
|
||||||
|
|
||||||
// Bei einer freien Zeichnung gibt es keine Schnitthöhe → eigene Legende/Titel.
|
|
||||||
const isDrawing = level.kind === "drawing";
|
|
||||||
const caption = isDrawing
|
|
||||||
? t("content.drawingLegend")
|
|
||||||
: t("content.grundrissLegend", {
|
|
||||||
name: level.name,
|
|
||||||
cutHeight: formatM(level.cutHeight ?? 0),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="pane">
|
<section className="pane">
|
||||||
<div className="pane-title">
|
{groupPath.length > 0 && (
|
||||||
{isDrawing
|
<button
|
||||||
? t("content.drawingTitle", { name: level.name })
|
type="button"
|
||||||
: t("content.grundrissTitle", { name: level.name })}
|
className="pane-leave-group-btn"
|
||||||
</div>
|
onClick={onLeaveGroup}
|
||||||
|
title={t("group.leave.hint")}
|
||||||
|
>
|
||||||
|
{t("group.leave")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<PlanView
|
<PlanView
|
||||||
ref={planViewRef}
|
ref={planViewRef}
|
||||||
plan={plan}
|
plan={plan}
|
||||||
@@ -652,6 +710,7 @@ function LevelPlanView({
|
|||||||
selectedStairIds={selectedStairIds}
|
selectedStairIds={selectedStairIds}
|
||||||
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
|
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
|
||||||
selectedColumnIds={selectedColumnIds}
|
selectedColumnIds={selectedColumnIds}
|
||||||
|
selectedGroupIds={selectedGroupIds}
|
||||||
selectedRoomIds={selectedRoomIds}
|
selectedRoomIds={selectedRoomIds}
|
||||||
selectedRoofIds={selectedRoofIds}
|
selectedRoofIds={selectedRoofIds}
|
||||||
selectedContextObjectIds={selectedContextObjectIds}
|
selectedContextObjectIds={selectedContextObjectIds}
|
||||||
@@ -660,6 +719,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}
|
||||||
@@ -674,15 +734,20 @@ function LevelPlanView({
|
|||||||
toolHandlers={toolHandlers}
|
toolHandlers={toolHandlers}
|
||||||
hudFields={hudFields}
|
hudFields={hudFields}
|
||||||
grips={grips}
|
grips={grips}
|
||||||
|
gripHandleLines={gripHandleLines}
|
||||||
edgeGrips={edgeGrips}
|
edgeGrips={edgeGrips}
|
||||||
hairline={hairline}
|
hairline={hairline}
|
||||||
marqueeColor={marqueeColor}
|
marqueeColor={marqueeColor}
|
||||||
snapColor={snapColor}
|
snapColor={snapColor}
|
||||||
|
hoverDivideDelayMs={hoverDivideDelayMs}
|
||||||
|
hoverDivideCount={hoverDivideCount}
|
||||||
selectedDrawingId={selectedDrawingId}
|
selectedDrawingId={selectedDrawingId}
|
||||||
selectedDrawingIds={selectedDrawingIds}
|
selectedDrawingIds={selectedDrawingIds}
|
||||||
gripHandlers={gripHandlers}
|
gripHandlers={gripHandlers}
|
||||||
/>
|
/>
|
||||||
<div className="plan-legend">{caption}</div>
|
{/* NACH PlanView gerendert (nicht als box-shadow auf .pane), damit der
|
||||||
|
Isolations-Rahmen über der PlanView-SVG sichtbar bleibt. */}
|
||||||
|
{groupPath.length > 0 && <div className="pane-isolation-frame" />}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -703,6 +768,8 @@ function SectionPlanView({
|
|||||||
hairline,
|
hairline,
|
||||||
marqueeColor,
|
marqueeColor,
|
||||||
snapColor,
|
snapColor,
|
||||||
|
hoverDivideDelayMs,
|
||||||
|
hoverDivideCount,
|
||||||
mono,
|
mono,
|
||||||
planViewRef,
|
planViewRef,
|
||||||
onCursor,
|
onCursor,
|
||||||
@@ -713,6 +780,7 @@ function SectionPlanView({
|
|||||||
selectedStairIds,
|
selectedStairIds,
|
||||||
selectedExtrudedSolidIds,
|
selectedExtrudedSolidIds,
|
||||||
selectedColumnIds,
|
selectedColumnIds,
|
||||||
|
selectedGroupIds,
|
||||||
selectedRoomIds,
|
selectedRoomIds,
|
||||||
selectedRoofIds,
|
selectedRoofIds,
|
||||||
selectedSectionLineId,
|
selectedSectionLineId,
|
||||||
@@ -720,6 +788,9 @@ function SectionPlanView({
|
|||||||
onStampMove,
|
onStampMove,
|
||||||
onStampEdit,
|
onStampEdit,
|
||||||
onEditDrawingText,
|
onEditDrawingText,
|
||||||
|
onEnterGroup,
|
||||||
|
groupPath,
|
||||||
|
onLeaveGroup,
|
||||||
editTextId,
|
editTextId,
|
||||||
editTextMinHeightM,
|
editTextMinHeightM,
|
||||||
onCommitTextEdit,
|
onCommitTextEdit,
|
||||||
@@ -734,6 +805,7 @@ function SectionPlanView({
|
|||||||
toolHandlers,
|
toolHandlers,
|
||||||
hudFields,
|
hudFields,
|
||||||
grips,
|
grips,
|
||||||
|
gripHandleLines,
|
||||||
edgeGrips,
|
edgeGrips,
|
||||||
selectedDrawingId,
|
selectedDrawingId,
|
||||||
selectedDrawingIds,
|
selectedDrawingIds,
|
||||||
@@ -744,6 +816,8 @@ function SectionPlanView({
|
|||||||
hairline: boolean;
|
hairline: boolean;
|
||||||
marqueeColor: string;
|
marqueeColor: string;
|
||||||
snapColor: string;
|
snapColor: string;
|
||||||
|
hoverDivideDelayMs?: number;
|
||||||
|
hoverDivideCount?: number;
|
||||||
mono: boolean;
|
mono: boolean;
|
||||||
planViewRef: React.RefObject<PlanViewHandle>;
|
planViewRef: React.RefObject<PlanViewHandle>;
|
||||||
onCursor: (model: Vec2 | null) => void;
|
onCursor: (model: Vec2 | null) => void;
|
||||||
@@ -754,6 +828,7 @@ function SectionPlanView({
|
|||||||
selectedStairIds: string[];
|
selectedStairIds: string[];
|
||||||
selectedExtrudedSolidIds: string[];
|
selectedExtrudedSolidIds: string[];
|
||||||
selectedColumnIds: string[];
|
selectedColumnIds: string[];
|
||||||
|
selectedGroupIds: string[];
|
||||||
selectedRoomIds: string[];
|
selectedRoomIds: string[];
|
||||||
selectedRoofIds: string[];
|
selectedRoofIds: string[];
|
||||||
selectedSectionLineId: string | null;
|
selectedSectionLineId: string | null;
|
||||||
@@ -761,6 +836,9 @@ 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[];
|
||||||
|
onLeaveGroup?: () => void;
|
||||||
editTextId?: string | null;
|
editTextId?: string | null;
|
||||||
editTextMinHeightM?: number | null;
|
editTextMinHeightM?: number | null;
|
||||||
onCommitTextEdit?: (doc: RichTextDoc) => void;
|
onCommitTextEdit?: (doc: RichTextDoc) => void;
|
||||||
@@ -775,6 +853,7 @@ function SectionPlanView({
|
|||||||
toolHandlers: ToolHandlers;
|
toolHandlers: ToolHandlers;
|
||||||
hudFields: HudFieldsState | null;
|
hudFields: HudFieldsState | null;
|
||||||
grips: Vec2[];
|
grips: Vec2[];
|
||||||
|
gripHandleLines: { a: Vec2; b: Vec2 }[];
|
||||||
edgeGrips: EdgeGrip[];
|
edgeGrips: EdgeGrip[];
|
||||||
selectedDrawingId: string | null;
|
selectedDrawingId: string | null;
|
||||||
selectedDrawingIds?: string[];
|
selectedDrawingIds?: string[];
|
||||||
@@ -828,7 +907,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");
|
||||||
@@ -853,6 +941,16 @@ function SectionPlanView({
|
|||||||
<div className="pane-title">
|
<div className="pane-title">
|
||||||
{t("content.sectionTitle", { name: level.name, kind: kindLabel })}
|
{t("content.sectionTitle", { name: level.name, kind: kindLabel })}
|
||||||
</div>
|
</div>
|
||||||
|
{groupPath.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pane-leave-group-btn"
|
||||||
|
onClick={onLeaveGroup}
|
||||||
|
title={t("group.leave.hint")}
|
||||||
|
>
|
||||||
|
{t("group.leave")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<PlanView
|
<PlanView
|
||||||
ref={planViewRef}
|
ref={planViewRef}
|
||||||
plan={plan}
|
plan={plan}
|
||||||
@@ -865,6 +963,7 @@ function SectionPlanView({
|
|||||||
selectedStairIds={selectedStairIds}
|
selectedStairIds={selectedStairIds}
|
||||||
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
|
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
|
||||||
selectedColumnIds={selectedColumnIds}
|
selectedColumnIds={selectedColumnIds}
|
||||||
|
selectedGroupIds={selectedGroupIds}
|
||||||
selectedRoomIds={selectedRoomIds}
|
selectedRoomIds={selectedRoomIds}
|
||||||
selectedRoofIds={selectedRoofIds}
|
selectedRoofIds={selectedRoofIds}
|
||||||
selectedSectionLineIds={selectedSectionLineId ? [selectedSectionLineId] : []}
|
selectedSectionLineIds={selectedSectionLineId ? [selectedSectionLineId] : []}
|
||||||
@@ -872,6 +971,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}
|
||||||
@@ -886,14 +986,20 @@ function SectionPlanView({
|
|||||||
toolHandlers={toolHandlers}
|
toolHandlers={toolHandlers}
|
||||||
hudFields={hudFields}
|
hudFields={hudFields}
|
||||||
grips={grips}
|
grips={grips}
|
||||||
|
gripHandleLines={gripHandleLines}
|
||||||
edgeGrips={edgeGrips}
|
edgeGrips={edgeGrips}
|
||||||
hairline={hairline}
|
hairline={hairline}
|
||||||
marqueeColor={marqueeColor}
|
marqueeColor={marqueeColor}
|
||||||
snapColor={snapColor}
|
snapColor={snapColor}
|
||||||
|
hoverDivideDelayMs={hoverDivideDelayMs}
|
||||||
|
hoverDivideCount={hoverDivideCount}
|
||||||
selectedDrawingId={selectedDrawingId}
|
selectedDrawingId={selectedDrawingId}
|
||||||
selectedDrawingIds={selectedDrawingIds}
|
selectedDrawingIds={selectedDrawingIds}
|
||||||
gripHandlers={gripHandlers}
|
gripHandlers={gripHandlers}
|
||||||
/>
|
/>
|
||||||
|
{/* NACH PlanView gerendert (nicht als box-shadow auf .pane), damit der
|
||||||
|
Isolations-Rahmen über der PlanView-SVG sichtbar bleibt. */}
|
||||||
|
{groupPath.length > 0 && <div className="pane-isolation-frame" />}
|
||||||
<div className="plan-legend">{t("section.legend", { kind: kindLabel })}</div>
|
<div className="plan-legend">{t("section.legend", { kind: kindLabel })}</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user