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
+51
View File
@@ -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;
+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,
};
}
File diff suppressed because it is too large Load Diff
+268
View File
@@ -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;
}
+33
View File
@@ -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: [] }),
};
}
+81
View File
@@ -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(() => []),
};
}
+87
View File
@@ -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 };
}
+97
View File
@@ -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 } }));
},
};
}
+176
View File
@@ -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);
}