Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge

Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit
Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem,
dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en)
sowie Projektdokumentation und Probe-Harness.
This commit is contained in:
2026-06-30 20:52:27 +02:00
commit ca859c4aa4
157 changed files with 37921 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
// Layout-Slice: das Panel-Layout (Docks/Tabs/Größen + schwebende Fenster) und
// alle Mutationen darauf. Nutzt die reinen Helfer aus src/panels/layout.ts
// (nicht dupliziert). Reiner Umzug aus App.tsx. Bezeichner englisch, Kommentare
// deutsch (CONVENTIONS.md).
//
// Persistenz (saveLayout) und das Init-Reconcile bleiben in App (useState-Init +
// useEffect); diese Slice liefert nur die Mutationen + reconcileLayout-Helfer.
import {
bringToFront,
defaultLayout,
loadLayout,
moveToDock,
moveToFloat,
moveToGroup,
moveToNewGroup,
removeFloat,
reorderInGroup,
setActiveTab,
setGroupWeights,
updateFloat,
} from "../panels/layout";
import { DEFAULT_FLOAT_W, DEFAULT_FLOAT_H } from "../panels/panelDrag";
import { hasPanel } from "../panels/registry";
// Seiteneffekt: registriert die dockbaren Kern-Panels in der Registry. MUSS vor
// `reconcileLayout(loadLayout())` (Slice-Init) laufen, sonst filtert hasPanel
// alle Tabs weg und die Docks blieben leer (Import-Reihenfolge!).
import "../panels/builtinPanels";
import type { DockId, DockState, LayoutState } from "../panels/types";
import type { StoreApi } from "./store";
/** Felder, die diese Slice in den RootState beisteuert. */
export interface LayoutSlice {
layout: LayoutState;
/** Layout direkt ersetzen (z. B. nach „Layout anwenden" aus dem Menü). */
setLayout: (next: LayoutState | ((l: LayoutState) => LayoutState)) => void;
// ── Dock-Größen / Gruppen ────────────────────────────────────────────────
resizeDock: (side: "left" | "right", size: number) => void;
resizeGroups: (side: "left" | "right", weights: number[]) => void;
// ── Tab-Drag-Auflösung (Reorder / Einreihen / Stapeln / Lösen) ────────────
activateTab: (panelId: string) => void;
joinGroup: (
panelId: string,
dock: DockId,
group: number,
index?: number,
) => void;
newGroup: (panelId: string, dock: DockId, at: number) => void;
dropFloat: (panelId: string, x: number, y: number) => void;
// ── Schwebende Fenster ────────────────────────────────────────────────────
focusFloat: (panelId: string) => void;
moveFloat: (panelId: string, x: number, y: number) => void;
resizeFloat: (panelId: string, w: number, h: number) => void;
closeFloat: (panelId: string) => void;
redockFloat: (panelId: string) => void;
/** Titel-Drop eines schwebenden Fensters auf eine Rand-Zone (dock). */
dockFloatTo: (panelId: string, dock: DockId) => void;
}
export function createLayoutSlice(api: StoreApi<LayoutSlice>): LayoutSlice {
const { set, get } = api;
const setLayout: LayoutSlice["setLayout"] = (next) =>
set((s) => ({
layout: typeof next === "function" ? (next as (l: LayoutState) => LayoutState)(s.layout) : next,
}));
// Welcher Dock enthält ein Panel gerade?
const dockOf = (panelId: string): DockId | null => {
const layout = get().layout;
if (layout.left.groups.some((g) => g.tabs.includes(panelId))) return "left";
if (layout.right.groups.some((g) => g.tabs.includes(panelId))) return "right";
return null;
};
// Genaue Lage eines Panels im Layout: Dock + Gruppen-Index + Tab-Index.
const locate = (
panelId: string,
): { dock: DockId; group: number; tab: number } | null => {
const layout = get().layout;
for (const dock of ["left", "right"] as const) {
const groups = layout[dock].groups;
for (let g = 0; g < groups.length; g++) {
const tab = groups[g].tabs.indexOf(panelId);
if (tab >= 0) return { dock, group: g, tab };
}
}
return null;
};
return {
layout: reconcileLayout(loadLayout()),
setLayout,
resizeDock: (side, size) =>
setLayout((l) => ({ ...l, [side]: { ...l[side], size } })),
resizeGroups: (side, weights) =>
setLayout((l) => setGroupWeights(l, side, weights)),
// Unter der Schwelle losgelassen → Klick: Tab in seiner Gruppe aktivieren.
activateTab: (panelId) => {
const side = dockOf(panelId);
if (side) setLayout((l) => setActiveTab(l, side, panelId));
},
// Über einer Tab-Leiste → in DIESE Gruppe einreihen (Stapel zusammenlegen).
joinGroup: (panelId, dock, group, index) => {
const from = locate(panelId);
const layout = get().layout;
if (from && from.dock === dock && from.group === group && index !== undefined) {
setLayout((l) =>
reorderInGroup(l, dock, group, from.tab, clampReorderIndex(from.tab, index)),
);
return;
}
const shift =
from &&
from.dock === dock &&
from.group < group &&
layout[dock].groups[from.group].tabs.length === 1
? 1
: 0;
setLayout((l) => moveToGroup(l, panelId, dock, group - shift, index));
},
// Über einem Gruppen-Körper / einer Rand-Zone → NEUE gestapelte Gruppe.
newGroup: (panelId, dock, at) => {
const from = locate(panelId);
const layout = get().layout;
const shift =
from &&
from.dock === dock &&
from.group < at &&
layout[dock].groups[from.group].tabs.length === 1
? 1
: 0;
setLayout((l) => moveToNewGroup(l, panelId, dock, at - shift));
},
// Über der Arbeitsfläche → lösen (schweben) an der Cursorposition.
dropFloat: (panelId, x, y) =>
setLayout((l) =>
moveToFloat(l, panelId, {
x: Math.max(0, x),
y: Math.max(0, y),
w: DEFAULT_FLOAT_W,
h: DEFAULT_FLOAT_H,
}),
),
focusFloat: (panelId) => setLayout((l) => bringToFront(l, panelId)),
moveFloat: (panelId, x, y) =>
setLayout((l) => updateFloat(l, panelId, { x: Math.max(0, x), y: Math.max(0, y) })),
resizeFloat: (panelId, w, h) =>
setLayout((l) => updateFloat(l, panelId, { w, h })),
closeFloat: (panelId) => setLayout((l) => removeFloat(l, panelId)),
redockFloat: (panelId) => setLayout((l) => moveToDock(l, panelId, "left")),
dockFloatTo: (panelId, dock) => setLayout((l) => moveToDock(l, panelId, dock)),
};
}
// ── Layout-Helfer (verschoben aus App.tsx, 1:1) ──────────────────────────────
/**
* Übersetzt einen Einfüge-Index (0..n) in einen Reorder-Zielindex (0..n-1).
*/
export function clampReorderIndex(fromIndex: number, insertIndex: number): number {
const to = insertIndex > fromIndex ? insertIndex - 1 : insertIndex;
return Math.max(0, to);
}
/**
* Gleicht ein (geladenes) Layout gegen die Registry ab: Tabs ohne registriertes
* Panel werden entfernt, ein verwaister activeTab korrigiert. Wird ein Dock
* dadurch leer, bleibt es leer (Größe erhalten).
*/
export function reconcileLayout(layout: LayoutState): LayoutState {
const def = defaultLayout();
const fix = (dock: DockState): DockState => {
const groups = dock.groups
.map((g) => {
const tabs = g.tabs.filter((id) => hasPanel(id));
const activeTab =
g.activeTab && tabs.includes(g.activeTab) ? g.activeTab : (tabs[0] ?? null);
return { tabs, activeTab, weight: g.weight };
})
.filter((g) => g.tabs.length > 0);
return { groups, size: dock.size };
};
const floating = layout.floating.filter((f) => hasPanel(f.panelId));
return {
left: fix(layout.left),
right: fix(layout.right),
floating,
version: def.version,
};
}