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:
@@ -0,0 +1,51 @@
|
||||
// App-Store: komponiert die vier Domänen-Slices (project/selection/view/layout)
|
||||
// über `createStore` zu EINEM RootState und exportiert die Store-Schnittstelle
|
||||
// (useStore/getState/setState). Jede Slice ist eine Factory `(api) => slice`;
|
||||
// alle bekommen dieselbe `set/get`-API über den Gesamt-RootState, sodass
|
||||
// Cross-Slice-Aktionen (z. B. deleteLevel ↔ activeLevelId) typsicher arbeiten.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { createStore } from "./store";
|
||||
import type { StoreApi } from "./store";
|
||||
import { createProjectSlice } from "./projectSlice";
|
||||
import type { ProjectSlice } from "./projectSlice";
|
||||
import { createSelectionSlice } from "./selectionSlice";
|
||||
import type { SelectionSlice } from "./selectionSlice";
|
||||
import { createViewSlice } from "./viewSlice";
|
||||
import type { ViewSlice } from "./viewSlice";
|
||||
import { createLayoutSlice } from "./layoutSlice";
|
||||
import type { LayoutSlice } from "./layoutSlice";
|
||||
import { createSiteSlice } from "./siteSlice";
|
||||
import type { SiteSlice } from "./siteSlice";
|
||||
|
||||
/** Gesamtzustand: Vereinigung aller Slice-Felder + Actions. */
|
||||
export type RootState = ProjectSlice &
|
||||
SelectionSlice &
|
||||
ViewSlice &
|
||||
LayoutSlice &
|
||||
SiteSlice;
|
||||
|
||||
const store = createStore<RootState>((api) => ({
|
||||
// Jede Slice-Factory bekommt die volle RootState-API. Die Slices tippen nur
|
||||
// ihren eigenen Ausschnitt; die Cast auf die enge Slice-API ist sicher, weil
|
||||
// `set/get` immer über den Gesamt-RootState laufen.
|
||||
...createProjectSlice(api as unknown as StoreApi<ProjectSlice & { activeLevelId: string }>),
|
||||
...createSelectionSlice(api as unknown as StoreApi<SelectionSlice>),
|
||||
...createViewSlice(api as unknown as StoreApi<ViewSlice>),
|
||||
...createLayoutSlice(api as unknown as StoreApi<LayoutSlice>),
|
||||
// Site-/Kontext-Slice liest `project` und schreibt über `setProject`
|
||||
// (beide aus der Projekt-Slice) — daher die kombinierte Sicht im Cast.
|
||||
...createSiteSlice(
|
||||
api as unknown as StoreApi<
|
||||
SiteSlice & {
|
||||
project: ProjectSlice["project"];
|
||||
setProject: ProjectSlice["setProject"];
|
||||
}
|
||||
>,
|
||||
),
|
||||
}));
|
||||
|
||||
export const useStore = store.useStore;
|
||||
export const getState = store.getState;
|
||||
export const setState = store.setState;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
// Selektions-Attribut-Kontrakt: leitet aus der aktuellen Auswahl
|
||||
// (selectedWallIds/selectedDrawingId) + dem Projekt ein normalisiertes,
|
||||
// darstellungsfertiges Attribut-Objekt für das ERSTE selektierte Element ab.
|
||||
// Die kommenden Paletten (Attributes, Object-Info) lesen ausschließlich diesen
|
||||
// Kontrakt — sie kennen weder das Modell noch die Auflösungs-Logik.
|
||||
//
|
||||
// „Effektiv aufgelöst" = dieselbe Reihenfolge wie generatePlan: explizite
|
||||
// Übersteuerung am Element → LineStyle → Kategorie-Default. So zeigt die Palette
|
||||
// den TATSÄCHLICH gezeichneten Wert, nicht nur das (oft leere) Override-Feld.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { flattenCategories, getWallType, wallTypeThickness } from "../model/types";
|
||||
import type {
|
||||
Drawing2D,
|
||||
Drawing2DGeom,
|
||||
LayerCategory,
|
||||
Project,
|
||||
VerticalAnchor,
|
||||
Wall,
|
||||
WallReferenceLine,
|
||||
} from "../model/types";
|
||||
import { nextFloorAbove, wallVerticalExtent } from "../model/wall";
|
||||
|
||||
/** Eintrag für die WallType-Auswahl (Preset-Dropdown) im Object-Info-Panel. */
|
||||
export interface WallTypeChoice {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Gesamtdicke (Meter) — informativ im Dropdown-Label. */
|
||||
thickness: number;
|
||||
/** Anzahl Schichten (1 = einschichtig). */
|
||||
layerCount: number;
|
||||
}
|
||||
|
||||
/** Eintrag für die Geschoss-Auswahl (Referenzgeschoss / OK-Bindung). */
|
||||
export interface FloorChoice {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wand-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl einer
|
||||
* einzelnen Wand gesetzt). Effektiv aufgelöste, anzeigefertige Werte plus die
|
||||
* Listen, die die Dropdowns brauchen.
|
||||
*/
|
||||
export interface WallInfo {
|
||||
referenceLine: WallReferenceLine;
|
||||
wallTypeId: string;
|
||||
/** Aktuelle Gesamtdicke (Meter). */
|
||||
thickness: number;
|
||||
/** Ob der aktuelle Wandtyp einschichtig ist (1 Schicht). */
|
||||
singleLayer: boolean;
|
||||
/** Verfügbare Wandtyp-Presets (für den Mehrschicht-Aufbau). */
|
||||
wallTypes: WallTypeChoice[];
|
||||
/** Geschoss, dem die Wand zugeordnet ist (Referenzgeschoss). */
|
||||
floorId: string;
|
||||
floorName: string;
|
||||
/** Alle Geschosse (für UK/OK-„an Geschoss gebunden"-Auswahl). */
|
||||
floors: FloorChoice[];
|
||||
/** Geschoss direkt darüber (für „Verknüpfung zum oberen Geschoss"). */
|
||||
floorAbove?: FloorChoice;
|
||||
/** UK-Bindung (undefined = Geschoss-Default). */
|
||||
bottom?: VerticalAnchor;
|
||||
/** OK-Bindung (undefined = UK + height). */
|
||||
top?: VerticalAnchor;
|
||||
/** Aufgelöste absolute UK/OK (Meter) + abgeleitete Höhe. */
|
||||
zBottom: number;
|
||||
zTop: number;
|
||||
}
|
||||
|
||||
/** Achsparallele Bounding-Box eines Elements in Modell-Metern. */
|
||||
export interface SelectionBBox {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisierte Sicht auf das erste selektierte Element. Farbe/Strichstärke sind
|
||||
* EFFEKTIV aufgelöst (Override sonst Default), damit die Palette den real
|
||||
* gezeichneten Wert zeigt. `fillHatchId`/`closed` sind nur für Drawing2D sinnvoll.
|
||||
*/
|
||||
export interface Selection {
|
||||
kind: "wall" | "drawing2d" | "door";
|
||||
id: string;
|
||||
/** Grafik-Kategorie (Ebene) des Elements. */
|
||||
categoryCode: string;
|
||||
/** Effektiv aufgelöste Strich-/Umrandungsfarbe (hex). */
|
||||
color: string;
|
||||
/** Effektiv aufgelöste Strichstärke in Millimetern. */
|
||||
weightMm: number;
|
||||
/**
|
||||
* Schraffur-ID der Füllung (nur Drawing2D, geschlossene Form); `null` = keine.
|
||||
* Bei Wänden/Türen `undefined` (Konzept nicht anwendbar).
|
||||
*/
|
||||
fillHatchId?: string | null;
|
||||
/**
|
||||
* Vollton-Füllfarbe der Füllung (nur Drawing2D, geschlossene Form); `null` =
|
||||
* keine (transparent). Bei Wänden/Türen `undefined` (nicht anwendbar).
|
||||
*/
|
||||
fillColor?: string | null;
|
||||
/** Ob die Form geschlossen ist (rect/circle/closed polyline) — nur Drawing2D. */
|
||||
closed?: boolean;
|
||||
/** Achsparallele Bounding-Box in Modell-Metern. */
|
||||
bbox: SelectionBBox;
|
||||
/**
|
||||
* Wand-Attribute (nur bei `kind === "wall"`). Trägt Referenzlinie, Aufbau-Typ
|
||||
* (Wandtyp/Dicke), Referenzgeschoss + UK/OK für den Wand-Abschnitt im Panel.
|
||||
*/
|
||||
wall?: WallInfo;
|
||||
}
|
||||
|
||||
const WALL_FALLBACK_MM = 0.18;
|
||||
const DRAW_FALLBACK_COLOR = "#d0d0d0";
|
||||
const WALL_FALLBACK_COLOR = "#2b3039";
|
||||
|
||||
/** Kategorie (per Code) im Baum suchen — flach, da Codes baumweit eindeutig sind. */
|
||||
function findCategory(
|
||||
layers: LayerCategory[],
|
||||
code: string,
|
||||
): LayerCategory | undefined {
|
||||
return flattenCategories(layers).find((c) => c.code === code);
|
||||
}
|
||||
|
||||
/** Ob eine 2D-Form geschlossen ist (Füllung/Schraffur sinnvoll). */
|
||||
function isClosedGeom(g: Drawing2DGeom): boolean {
|
||||
return (
|
||||
g.shape === "rect" ||
|
||||
g.shape === "circle" ||
|
||||
(g.shape === "polyline" && g.closed)
|
||||
);
|
||||
}
|
||||
|
||||
/** Bounding-Box einer 2D-Form. */
|
||||
function geomBBox(g: Drawing2DGeom): SelectionBBox {
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
const acc = (x: number, y: number) => {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
};
|
||||
switch (g.shape) {
|
||||
case "line":
|
||||
acc(g.a.x, g.a.y);
|
||||
acc(g.b.x, g.b.y);
|
||||
break;
|
||||
case "polyline":
|
||||
for (const p of g.pts) acc(p.x, p.y);
|
||||
break;
|
||||
case "rect":
|
||||
acc(g.min.x, g.min.y);
|
||||
acc(g.max.x, g.max.y);
|
||||
break;
|
||||
case "circle":
|
||||
case "arc":
|
||||
acc(g.center.x - g.r, g.center.y - g.r);
|
||||
acc(g.center.x + g.r, g.center.y + g.r);
|
||||
break;
|
||||
case "text":
|
||||
acc(g.at.x, g.at.y);
|
||||
break;
|
||||
}
|
||||
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/** Selektion für eine Wand ableiten (Farbe übersteuerbar, Strichstärke = Kategorie). */
|
||||
function wallSelection(project: Project, wall: Wall): Selection {
|
||||
const cat = findCategory(project.layers, wall.categoryCode);
|
||||
const color = wall.color ?? cat?.color ?? WALL_FALLBACK_COLOR;
|
||||
const weightMm = cat?.lw ?? WALL_FALLBACK_MM;
|
||||
const minX = Math.min(wall.start.x, wall.end.x);
|
||||
const minY = Math.min(wall.start.y, wall.end.y);
|
||||
const maxX = Math.max(wall.start.x, wall.end.x);
|
||||
const maxY = Math.max(wall.start.y, wall.end.y);
|
||||
// Wand-Attribute auflösen (Referenzlinie, Aufbau, Referenzgeschoss, UK/OK).
|
||||
const wt = getWallType(project, wall);
|
||||
const thickness = wallTypeThickness(wt);
|
||||
const floor = project.drawingLevels.find((z) => z.id === wall.floorId);
|
||||
const { zBottom, zTop } = wallVerticalExtent(project, wall);
|
||||
const above = nextFloorAbove(project, wall);
|
||||
const wallInfo: WallInfo = {
|
||||
referenceLine: wall.referenceLine ?? "center",
|
||||
wallTypeId: wall.wallTypeId,
|
||||
thickness,
|
||||
singleLayer: wt.layers.length <= 1,
|
||||
wallTypes: project.wallTypes.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
thickness: wallTypeThickness(t),
|
||||
layerCount: t.layers.length,
|
||||
})),
|
||||
floorId: wall.floorId,
|
||||
floorName: floor?.name ?? wall.floorId,
|
||||
floors: project.drawingLevels
|
||||
.filter((z) => z.kind === "floor")
|
||||
.map((z) => ({ id: z.id, name: z.name })),
|
||||
floorAbove: above,
|
||||
bottom: wall.bottom,
|
||||
top: wall.top,
|
||||
zBottom,
|
||||
zTop,
|
||||
};
|
||||
// bbox aus der Wandachse (start/end); die Schichtdicke bleibt unberücksichtigt,
|
||||
// damit die Box der Achs-Geometrie folgt (genügt für die Paletten).
|
||||
return {
|
||||
kind: "wall",
|
||||
id: wall.id,
|
||||
categoryCode: wall.categoryCode,
|
||||
color,
|
||||
weightMm,
|
||||
fillHatchId: undefined,
|
||||
fillColor: undefined,
|
||||
closed: undefined,
|
||||
bbox: { minX, minY, maxX, maxY },
|
||||
wall: wallInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */
|
||||
function drawingSelection(project: Project, d: Drawing2D): Selection {
|
||||
const ls = d.lineStyleId
|
||||
? project.lineStyles.find((l) => l.id === d.lineStyleId)
|
||||
: undefined;
|
||||
const cat = findCategory(project.layers, d.categoryCode);
|
||||
const color = d.color ?? ls?.color ?? cat?.color ?? DRAW_FALLBACK_COLOR;
|
||||
const weightMm = d.weightMm ?? ls?.weight ?? cat?.lw ?? WALL_FALLBACK_MM;
|
||||
const closed = isClosedGeom(d.geom);
|
||||
return {
|
||||
kind: "drawing2d",
|
||||
id: d.id,
|
||||
categoryCode: d.categoryCode,
|
||||
color,
|
||||
weightMm,
|
||||
fillHatchId: closed ? d.hatchId ?? null : null,
|
||||
fillColor: closed ? d.fillColor ?? null : null,
|
||||
closed,
|
||||
bbox: geomBBox(d.geom),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet den Selektions-Kontrakt für das ERSTE selektierte Element ab.
|
||||
* Drawing-Auswahl und Wand-Auswahl schließen sich aus; bei Drawing-Auswahl
|
||||
* hat diese Vorrang. Liefert `null`, wenn nichts (oder kein auffindbares
|
||||
* Element) selektiert ist.
|
||||
*/
|
||||
export function deriveSelection(
|
||||
project: Project,
|
||||
selectedWallIds: string[],
|
||||
selectedDrawingId: string | null,
|
||||
): Selection | null {
|
||||
if (selectedDrawingId) {
|
||||
const d = project.drawings2d.find((x) => x.id === selectedDrawingId);
|
||||
return d ? drawingSelection(project, d) : null;
|
||||
}
|
||||
const wallId = selectedWallIds[0];
|
||||
if (wallId) {
|
||||
const w = project.walls.find((x) => x.id === wallId);
|
||||
return w ? wallSelection(project, w) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Auswahl-Slice: gewählte Wände (Menge) und das gewählte 2D-Zeichenelement.
|
||||
// Wand- und 2D-Auswahl schließen sich gegenseitig aus. Reiner Umzug aus
|
||||
// App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { StoreApi } from "./store";
|
||||
|
||||
/** Felder, die diese Slice in den RootState beisteuert. */
|
||||
export interface SelectionSlice {
|
||||
// Gewählte Wände als MENGE von IDs (Marquee/Einzelauswahl).
|
||||
selectedWallIds: string[];
|
||||
// Gewählte 2D-Zeichenelemente als MENGE (Marquee/Mehrfach/Einzel) — getrennt
|
||||
// von der Wand-Auswahl. Einzel-Auswahl = Array der Länge 1.
|
||||
selectedDrawingIds: string[];
|
||||
|
||||
setSelectedWallIds: (ids: string[]) => void;
|
||||
setSelectedDrawingIds: (ids: string[]) => void;
|
||||
/** Auswahl (Wände + 2D-Elemente) vollständig leeren. */
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
export function createSelectionSlice(
|
||||
api: StoreApi<SelectionSlice>,
|
||||
): SelectionSlice {
|
||||
const { set } = api;
|
||||
return {
|
||||
selectedWallIds: [],
|
||||
selectedDrawingIds: [],
|
||||
setSelectedWallIds: (ids) => set({ selectedWallIds: ids }),
|
||||
setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }),
|
||||
clearSelection: () =>
|
||||
set({ selectedWallIds: [], selectedDrawingIds: [] }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Site-/Kontext-Slice: verwaltet die Kontext-Schicht `project.context`
|
||||
// (importierte Meshes, Konturen-Sätze, abgeleitetes Gelände-TIN). Diese
|
||||
// Geometrie ist „dumm"/nicht-semantisch — sie liegt bewusst NEBEN dem BIM-Modell.
|
||||
//
|
||||
// Entwurfsentscheidung: `context` ist Teil von `project` (ein einziger
|
||||
// Wahrheits-Baum, konsistent mit projectSlice). Diese Slice MUTIERT daher
|
||||
// `project.context` IMMUTABEL über die vorhandene `setProject`-Aktion
|
||||
// (Cross-Slice), genau wie projectSlice ein neues Project-Objekt liefert. So
|
||||
// bleiben Persistenz/Undo später einheitlich am Project hängen.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { generateTerrainFromContours } from "../model/terrain";
|
||||
import type { ContextObject, Project } from "../model/types";
|
||||
import type { StoreApi } from "./store";
|
||||
|
||||
/** Felder/Actions, die diese Slice in den RootState beisteuert. */
|
||||
export interface SiteSlice {
|
||||
/** Fügt ein Kontext-Objekt (Mesh/Konturen/Gelände) hinzu (immutabel). */
|
||||
addContextObject: (obj: ContextObject) => void;
|
||||
/** Fügt mehrere Kontext-Objekte in einem Schritt hinzu (z. B. DXF-Import). */
|
||||
addContextObjects: (objs: ContextObject[]) => void;
|
||||
/** Entfernt ein Kontext-Objekt per ID (No-op bei unbekannter ID). */
|
||||
removeContextObject: (id: string) => void;
|
||||
/**
|
||||
* Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN und fügt es als
|
||||
* weiteres Kontext-Objekt hinzu. Liefert die ID des neuen TerrainMesh, oder
|
||||
* null, wenn der ContourSet nicht existiert / kein Mesh entsteht.
|
||||
*/
|
||||
generateTerrain: (contourSetId: string) => string | null;
|
||||
/** Leert die gesamte Kontext-Schicht. */
|
||||
clearContext: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-Slice-Abhängigkeit: diese Slice liest `project` und schreibt es über
|
||||
* `setProject` (beide aus projectSlice). Der Store komponiert SiteSlice mit
|
||||
* dieser Sicht, sodass `get()`/Actions typsicher auf das Projekt zugreifen.
|
||||
*/
|
||||
type SiteSliceDeps = {
|
||||
project: Project;
|
||||
setProject: (next: Project | ((p: Project) => Project)) => void;
|
||||
};
|
||||
|
||||
export function createSiteSlice(
|
||||
api: StoreApi<SiteSlice & SiteSliceDeps>,
|
||||
): SiteSlice {
|
||||
const { get } = api;
|
||||
|
||||
// Immutabler Kontext-Updater: ersetzt `project.context` über die vorhandene
|
||||
// setProject-Aktion (konsistent mit projectSlice-Stil).
|
||||
const setContext = (
|
||||
update: (ctx: ContextObject[]) => ContextObject[],
|
||||
): void => {
|
||||
get().setProject((p) => ({ ...p, context: update(p.context ?? []) }));
|
||||
};
|
||||
|
||||
return {
|
||||
addContextObject: (obj) => setContext((ctx) => [...ctx, obj]),
|
||||
|
||||
addContextObjects: (objs) =>
|
||||
setContext((ctx) => (objs.length ? [...ctx, ...objs] : ctx)),
|
||||
|
||||
removeContextObject: (id) =>
|
||||
setContext((ctx) => ctx.filter((o) => o.id !== id)),
|
||||
|
||||
generateTerrain: (contourSetId) => {
|
||||
const ctx = get().project.context ?? [];
|
||||
const set = ctx.find(
|
||||
(o) => o.id === contourSetId && o.type === "contourSet",
|
||||
);
|
||||
if (!set || set.type !== "contourSet") return null;
|
||||
const terrain = generateTerrainFromContours(set.contours);
|
||||
if (terrain.indices.length === 0) return null; // kein sinnvolles TIN
|
||||
setContext((c) => [...c, terrain]);
|
||||
return terrain.id;
|
||||
},
|
||||
|
||||
clearContext: () => setContext(() => []),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Globaler Store — dependency-freier Kern (useSyncExternalStore).
|
||||
//
|
||||
// Bewusst leichtgewichtig: kein Zustand-/Redux-Paket, sondern ein winziger
|
||||
// Store nach demselben Muster wie src/i18n (externe Quelle + React-Hook). Er
|
||||
// kombiniert die Slices (project/selection/view/layout) zu EINEM Zustand und
|
||||
// bietet selektives Lesen über `useStore(selector)`, sodass Komponenten nur bei
|
||||
// Änderung ihres Ausschnitts neu rendern (kein Re-Render-Sturm, kein
|
||||
// Prop-Drilling).
|
||||
//
|
||||
// Eine Slice ist eine Factory `(set, get) => ({ ...state, ...actions })`. `set`
|
||||
// nimmt ein Teil-Patch ODER eine Updater-Funktion (wie React-setState); `get`
|
||||
// liest den aktuellen Gesamtzustand. Actions leben damit IM Zustand (z. B.
|
||||
// `useStore(s => s.addFloor)`), genau wie in der Spec (state-architecture.md).
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/** Patch-Form für `set`: Teilobjekt oder Updater über den Gesamtzustand. */
|
||||
export type SetStatePatch<T> = Partial<T> | ((state: T) => Partial<T>);
|
||||
|
||||
/** Was eine Slice-Factory bekommt, um Zustand zu lesen/schreiben. */
|
||||
export type StoreApi<T> = {
|
||||
/** Schreibt ein Teil-Patch (oder Updater) und benachrichtigt Abonnenten. */
|
||||
set: (patch: SetStatePatch<T>) => void;
|
||||
/** Liefert den aktuellen Gesamtzustand (für Actions, die andere Felder lesen). */
|
||||
get: () => T;
|
||||
};
|
||||
|
||||
/** Eine Slice: Factory, die ihren Zustands-/Action-Ausschnitt liefert. */
|
||||
export type SliceCreator<T, S> = (api: StoreApi<T>) => S;
|
||||
|
||||
/** Öffentliche Store-Schnittstelle (Hook + imperative Helfer). */
|
||||
export interface Store<T> {
|
||||
/** React-Hook: liest einen Ausschnitt; rendert nur bei dessen Änderung neu. */
|
||||
useStore: <U>(selector: (state: T) => U) => U;
|
||||
/** Imperatives Lesen (außerhalb von React, z. B. in Event-Handlern). */
|
||||
getState: () => T;
|
||||
/** Imperatives Schreiben (Teil-Patch oder Updater). */
|
||||
setState: (patch: SetStatePatch<T>) => void;
|
||||
/** Abonnement (selten direkt nötig; useStore nutzt es intern). */
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut den Store aus einer Initializer-Funktion, die mit `(set, get)` den
|
||||
* kompletten Zustand (Felder + Actions) erzeugt. Der Zustand wird flach mit dem
|
||||
* Patch zusammengeführt (`{ ...state, ...patch }`); für tiefe/immutable Updates
|
||||
* sind die Actions selbst verantwortlich (sie liefern fertige neue Werte).
|
||||
*/
|
||||
export function createStore<T>(init: SliceCreator<T, T>): Store<T> {
|
||||
let state: T;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const set: StoreApi<T>["set"] = (patch) => {
|
||||
const partial = typeof patch === "function" ? (patch as (s: T) => Partial<T>)(state) : patch;
|
||||
// Referenz-Gleichheit als No-op-Schutz: identisches Objekt → kein Notify.
|
||||
let changed = false;
|
||||
for (const k in partial) {
|
||||
if ((partial as Record<string, unknown>)[k] !== (state as Record<string, unknown>)[k]) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
state = { ...state, ...partial };
|
||||
for (const l of listeners) l();
|
||||
};
|
||||
|
||||
const get: StoreApi<T>["get"] = () => state;
|
||||
|
||||
state = init({ set, get });
|
||||
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
const useStore = <U,>(selector: (s: T) => U): U =>
|
||||
useSyncExternalStore(
|
||||
subscribe,
|
||||
() => selector(state),
|
||||
() => selector(state),
|
||||
);
|
||||
|
||||
return { useStore, getState: get, setState: set, subscribe };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// View-Slice: alles, was die Darstellung steuert, ohne Modell zu sein —
|
||||
// Ansichtstyp, Detailgrad, Render-Modus, Referenzlinien, aktive Zeichnungsebene,
|
||||
// Massstab, Linien-Modus sowie der Darstellungsmodus je Panel. Reiner Umzug aus
|
||||
// App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
//
|
||||
// Entscheidung `activeCategoryCode`: liegt hier (View), NICHT in selectionSlice.
|
||||
// Es ist keine Element-Selektion, sondern die „aktive Ebene" (Arbeitsfokus für
|
||||
// neue Zeichnungen / Status-Leiste) — view-seitiger Zustand wie activeLevelId.
|
||||
|
||||
import { sampleProject } from "../model/sampleProject";
|
||||
import type { DetailLevel, RenderMode, View3d, ViewType } from "../ui/TopBar";
|
||||
import type { DisplayMode } from "../panels/types";
|
||||
import type { StoreApi } from "./store";
|
||||
|
||||
/** Standard-Darstellungsmodus je Panel (wie bisher: alles sichtbar normal). */
|
||||
const DEFAULT_DISPLAY_MODE: DisplayMode = "all";
|
||||
|
||||
/** Felder, die diese Slice in den RootState beisteuert. */
|
||||
export interface ViewSlice {
|
||||
// Aktive Zeichnungsebene (Geschoss/Schnitt/Zeichnung).
|
||||
activeLevelId: string;
|
||||
// Ansichtstyp (Grundriss / Perspektive).
|
||||
viewType: ViewType;
|
||||
// Detailgrad im Grundriss.
|
||||
detail: DetailLevel;
|
||||
// Render-Modus der 3D-Ansicht.
|
||||
renderMode: RenderMode;
|
||||
// Kanonischer Blickwinkel der 3D-Ansicht (Front/Top/Seite/Iso/freie Persp.).
|
||||
view3d: View3d;
|
||||
// Sichtwinkel (FOV) der 3D-Perspektivkamera in Grad.
|
||||
fov: number;
|
||||
// Farb-Modus des Grundrisses: "color" = Kategorie-/Element-Farben (heute),
|
||||
// "mono" = reiner Schwarz-Weiss-Plan (Tinte, keine Farb-/Schraffurfarben).
|
||||
planColorMode: "color" | "mono";
|
||||
// Wand-Achslinien im Grundriss.
|
||||
referenceLines: boolean;
|
||||
// Linien-Darstellung: "display" = Haarlinie, "print" = echte mm-Stärken.
|
||||
lineMode: "display" | "print";
|
||||
// Gewählter (angewandter) Papier-Massstab „1:N".
|
||||
scaleDenominator: number;
|
||||
// Aktive Ebene (Kategorie-Code) — Ziel neuer Zeichnungen (Arbeitsfokus).
|
||||
activeCategoryCode: string;
|
||||
// Darstellungsmodus je Panel-ID (z. B. "drawing-levels", "layers").
|
||||
displayModes: Record<string, DisplayMode>;
|
||||
|
||||
setActiveLevelId: (id: string) => void;
|
||||
setViewType: (v: ViewType) => void;
|
||||
setDetail: (d: DetailLevel) => void;
|
||||
setRenderMode: (m: RenderMode) => void;
|
||||
setView3d: (v: View3d) => void;
|
||||
setFov: (n: number) => void;
|
||||
setPlanColorMode: (m: "color" | "mono") => void;
|
||||
setReferenceLines: (b: boolean) => void;
|
||||
setLineMode: (m: "display" | "print") => void;
|
||||
setScaleDenominator: (n: number) => void;
|
||||
setActiveCategoryCode: (code: string) => void;
|
||||
/** Darstellungsmodus eines Panels lesen (Default „all"). */
|
||||
modeFor: (id: string | null) => DisplayMode;
|
||||
/** Darstellungsmodus eines Panels setzen (No-op bei null id). */
|
||||
setModeFor: (id: string | null, mode: DisplayMode) => void;
|
||||
}
|
||||
|
||||
export function createViewSlice(api: StoreApi<ViewSlice>): ViewSlice {
|
||||
const { set, get } = api;
|
||||
return {
|
||||
activeLevelId: sampleProject.drawingLevels[0].id,
|
||||
viewType: "grundriss",
|
||||
detail: "mittel",
|
||||
renderMode: "shaded",
|
||||
view3d: "perspective",
|
||||
fov: 50,
|
||||
planColorMode: "color",
|
||||
referenceLines: false,
|
||||
lineMode: "display",
|
||||
scaleDenominator: 100,
|
||||
activeCategoryCode:
|
||||
sampleProject.walls[0]?.categoryCode ?? sampleProject.layers[0].code,
|
||||
displayModes: {},
|
||||
|
||||
setActiveLevelId: (id) => set({ activeLevelId: id }),
|
||||
setViewType: (v) => set({ viewType: v }),
|
||||
setDetail: (d) => set({ detail: d }),
|
||||
setRenderMode: (m) => set({ renderMode: m }),
|
||||
setView3d: (v) => set({ view3d: v }),
|
||||
setFov: (n) => set({ fov: n }),
|
||||
setPlanColorMode: (m) => set({ planColorMode: m }),
|
||||
setReferenceLines: (b) => set({ referenceLines: b }),
|
||||
setLineMode: (m) => set({ lineMode: m }),
|
||||
setScaleDenominator: (n) => set({ scaleDenominator: n }),
|
||||
setActiveCategoryCode: (code) => set({ activeCategoryCode: code }),
|
||||
modeFor: (id) => (id && get().displayModes[id]) || DEFAULT_DISPLAY_MODE,
|
||||
setModeFor: (id, mode) => {
|
||||
if (!id) return;
|
||||
set((s) => ({ displayModes: { ...s.displayModes, [id]: mode } }));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Sichtbarkeits-Kombinationen — Persistenz (localStorage).
|
||||
//
|
||||
// Speichert benannte Sichtbarkeits-Sätze (Vectorworks: „Ebenenkombinationen"
|
||||
// / „Zeichnungskombinationen") für zwei unabhängige Achsen:
|
||||
// • Ebenen-Kombination (LayerCombo) — pro Kategorie-`code` → sichtbar?
|
||||
// • Zeichnungs-Komb. (DrawingCombo) — pro DrawingLevel-`id` → sichtbar?
|
||||
//
|
||||
// Analog zu panels/layout.ts: reine Speicher-Helfer OHNE React-Abhängigkeit,
|
||||
// sämtlicher localStorage-Zugriff in try/catch + storage()-Guard (SSR/Tests/
|
||||
// Privatmodus). Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
//
|
||||
// Hinweis: Diese Sätze liegen (wie die benannten Layouts) im localStorage und
|
||||
// sind damit GERÄTE-/Browser-lokal, nicht im Projekt-Modell. Sollen sie mit dem
|
||||
// Projekt mitwandern (Export/Teilen), gehören sie später ins `Project` (siehe
|
||||
// Report).
|
||||
|
||||
/** Eine gespeicherte Ebenen-Kombination: Kategorie-Code → sichtbar?. */
|
||||
export interface LayerCombo {
|
||||
codes: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/** Eine gespeicherte Zeichnungs-Kombination: DrawingLevel-Id → sichtbar?. */
|
||||
export interface DrawingCombo {
|
||||
ids: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/** Präfix der localStorage-Schlüssel für Ebenen-Kombinationen. */
|
||||
const LAYER_PREFIX = "cad.layercombos.";
|
||||
/** Präfix der localStorage-Schlüssel für Zeichnungs-Kombinationen. */
|
||||
const DRAWING_PREFIX = "cad.drawingcombos.";
|
||||
|
||||
// ── localStorage-Sicherung (wie panels/layout.ts) ───────────────────────────
|
||||
|
||||
/** Liefert das localStorage-Objekt oder `null`, wenn nicht verfügbar. */
|
||||
function storage(): Storage | null {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
return localStorage;
|
||||
} catch {
|
||||
// Zugriff kann werfen (z. B. blockierte Cookies/Privatmodus).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Liest und parst einen JSON-Wert; `null` bei Fehlen/ungültig. */
|
||||
function readJson<T>(key: string): T | null {
|
||||
const ls = storage();
|
||||
if (!ls) return null;
|
||||
try {
|
||||
const raw = ls.getItem(key);
|
||||
if (raw === null) return null;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Schreibt einen JSON-Wert; liefert `false`, wenn das Schreiben scheitert. */
|
||||
function writeJson(key: string, value: unknown): boolean {
|
||||
const ls = storage();
|
||||
if (!ls) return false;
|
||||
try {
|
||||
ls.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch {
|
||||
// Kontingent überschritten oder Zugriff verweigert — still ignorieren.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Löscht einen Schlüssel; liefert `true`, wenn der Zugriff gelang. */
|
||||
function removeKey(key: string): boolean {
|
||||
const ls = storage();
|
||||
if (!ls) return false;
|
||||
try {
|
||||
ls.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Listet alle Namen unter einem Präfix (alphabetisch sortiert). */
|
||||
function listNames(prefix: string): string[] {
|
||||
const ls = storage();
|
||||
if (!ls) return [];
|
||||
const names: string[] = [];
|
||||
try {
|
||||
for (let i = 0; i < ls.length; i++) {
|
||||
const key = ls.key(i);
|
||||
if (key && key.startsWith(prefix)) {
|
||||
names.push(key.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Typwächter für eine `Record<string, boolean>`-Map (toleriert Fremddaten aus
|
||||
* localStorage). Liefert eine bereinigte Map (nur boolesche Werte), sonst `null`.
|
||||
*/
|
||||
function asBoolMap(v: unknown): Record<string, boolean> | null {
|
||||
if (typeof v !== "object" || v === null) return null;
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||
if (typeof val === "boolean") out[k] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Ebenen-Kombinationen ─────────────────────────────────────────────────────
|
||||
|
||||
/** Speichert eine Ebenen-Kombination unter einem Namen. `true` bei Erfolg. */
|
||||
export function saveLayerCombo(
|
||||
name: string,
|
||||
codes: Record<string, boolean>,
|
||||
): boolean {
|
||||
if (!name) return false;
|
||||
const value: LayerCombo = { codes };
|
||||
return writeJson(LAYER_PREFIX + name, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt eine Ebenen-Kombination (nur die `codes`-Map). Fehlt sie oder ist sie
|
||||
* beschädigt, kommt `null` zurück (so erkennt der Aufrufer „nicht gefunden").
|
||||
*/
|
||||
export function loadLayerCombo(name: string): Record<string, boolean> | null {
|
||||
const raw = readJson<unknown>(LAYER_PREFIX + name);
|
||||
if (raw === null || typeof raw !== "object") return null;
|
||||
return asBoolMap((raw as { codes?: unknown }).codes);
|
||||
}
|
||||
|
||||
/** Listet die Namen aller gespeicherten Ebenen-Kombinationen (alphabetisch). */
|
||||
export function listLayerCombos(): string[] {
|
||||
return listNames(LAYER_PREFIX);
|
||||
}
|
||||
|
||||
/** Löscht eine Ebenen-Kombination. `true`, wenn der Zugriff gelang. */
|
||||
export function deleteLayerCombo(name: string): boolean {
|
||||
return removeKey(LAYER_PREFIX + name);
|
||||
}
|
||||
|
||||
// ── Zeichnungs-Kombinationen ─────────────────────────────────────────────────
|
||||
|
||||
/** Speichert eine Zeichnungs-Kombination unter einem Namen. `true` bei Erfolg. */
|
||||
export function saveDrawingCombo(
|
||||
name: string,
|
||||
ids: Record<string, boolean>,
|
||||
): boolean {
|
||||
if (!name) return false;
|
||||
const value: DrawingCombo = { ids };
|
||||
return writeJson(DRAWING_PREFIX + name, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt eine Zeichnungs-Kombination (nur die `ids`-Map). Fehlt sie oder ist sie
|
||||
* beschädigt, kommt `null` zurück.
|
||||
*/
|
||||
export function loadDrawingCombo(name: string): Record<string, boolean> | null {
|
||||
const raw = readJson<unknown>(DRAWING_PREFIX + name);
|
||||
if (raw === null || typeof raw !== "object") return null;
|
||||
return asBoolMap((raw as { ids?: unknown }).ids);
|
||||
}
|
||||
|
||||
/** Listet die Namen aller gespeicherten Zeichnungs-Kombinationen (alphabetisch). */
|
||||
export function listDrawingCombos(): string[] {
|
||||
return listNames(DRAWING_PREFIX);
|
||||
}
|
||||
|
||||
/** Löscht eine Zeichnungs-Kombination. `true`, wenn der Zugriff gelang. */
|
||||
export function deleteDrawingCombo(name: string): boolean {
|
||||
return removeKey(DRAWING_PREFIX + name);
|
||||
}
|
||||
Reference in New Issue
Block a user