ca859c4aa4
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.
1138 lines
39 KiB
TypeScript
1138 lines
39 KiB
TypeScript
// Projekt-Slice: das semantische Modell (`project`) plus ALLE Mutationen, die
|
||
// heute in App.tsx als Closures über setProject existieren. Reiner Umzug — die
|
||
// Logik ist 1:1 übernommen (inkl. recomputeFloorElevations, refs-aware delete,
|
||
// Tree-Helfern). Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||
//
|
||
// Cross-Slice-Zugriffe: einige Aktionen lesen/schreiben View-Felder
|
||
// (activeLevelId) — das geht, weil `get()`/`set` über den Gesamt-RootState
|
||
// laufen. Die Slice tippt daher nur ihre eigenen Felder; `get()` liefert
|
||
// RootState (siehe appStore.ts), wir greifen punktuell auf View-Felder zu.
|
||
|
||
import { sampleProject } from "../model/sampleProject";
|
||
import {
|
||
flattenCategories,
|
||
recomputeFloorElevations,
|
||
} from "../model/types";
|
||
import type {
|
||
Component,
|
||
DrawingLevel,
|
||
HatchStyle,
|
||
LayerCategory,
|
||
LineStyle,
|
||
Project,
|
||
Vec2,
|
||
} from "../model/types";
|
||
import { commitTransform } from "../tools/transform";
|
||
import type { CopyMode, TransformOp, TransformSelection } from "../tools/transform";
|
||
import { t } from "../i18n";
|
||
import type { StoreApi } from "./store";
|
||
|
||
/** Felder, die diese Slice in den RootState beisteuert. */
|
||
export interface ProjectSlice {
|
||
project: Project;
|
||
|
||
// ── Projekt direkt setzen (für Werkzeuge/Transformationen aus App) ────────
|
||
/** Ersetzt das Projekt (Teil-/Updater wie React-setState). */
|
||
setProject: (next: Project | ((p: Project) => Project)) => void;
|
||
|
||
// ── Zeichnungsebenen (Levels/Geschosse) ──────────────────────────────────
|
||
toggleLevelVisible: (id: string) => void;
|
||
patchActiveFloor: (patch: Partial<DrawingLevel>) => void;
|
||
addFloor: () => void;
|
||
addDrawing: () => void;
|
||
/**
|
||
* Legt eine neue freie Zeichnungsebene (kind:"drawing") mit dem gegebenen
|
||
* Namen an und liefert ihre ID zurück (z. B. als Ziel eines DXF-Imports).
|
||
*/
|
||
addDrawingLevel: (name: string) => string;
|
||
/** Hängt fertige Drawing2D-Elemente an (z. B. aus einem DXF-Import). */
|
||
importDrawings: (drawings: import("../model/types").Drawing2D[]) => void;
|
||
patchLevel: (id: string, patch: Partial<DrawingLevel>) => void;
|
||
duplicateLevel: (id: string) => void;
|
||
deleteLevel: (id: string) => void;
|
||
|
||
// ── Ebenen (Kategorien) ───────────────────────────────────────────────────
|
||
toggleCategoryVisible: (code: string) => void;
|
||
addCategory: () => void;
|
||
patchCategory: (code: string, patch: Partial<LayerCategory>) => void;
|
||
addSubCategory: (parentCode: string) => void;
|
||
duplicateCategory: (code: string) => void;
|
||
deleteCategory: (code: string) => void;
|
||
assignWallCategory: (wallId: string, code: string) => void;
|
||
|
||
// ── Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungs-Komb.) ────────────────
|
||
/** Liest je Kategorie-`code` → `visible` aus dem Layer-Baum (rekursiv). */
|
||
snapshotLayerVisibility: () => Record<string, boolean>;
|
||
/**
|
||
* Setzt im Layer-Baum jede Kategorie, deren `code` in `codes` vorkommt, auf
|
||
* den gespeicherten Wert (rekursiv, immutabel). Unbekannte Codes werden
|
||
* ignoriert; nicht enthaltene Kategorien bleiben unverändert.
|
||
*/
|
||
applyLayerVisibility: (codes: Record<string, boolean>) => void;
|
||
/** Liest je DrawingLevel-`id` → `visible`. */
|
||
snapshotDrawingVisibility: () => Record<string, boolean>;
|
||
/** Setzt `drawingLevels[].visible` gemäß `ids` (unbekannte Ids ignorieren). */
|
||
applyDrawingVisibility: (ids: Record<string, boolean>) => void;
|
||
|
||
// ── Ressourcen (Components / Hatches / LineStyles) ────────────────────────
|
||
patchComponent: (id: string, patch: Partial<Component>) => void;
|
||
addComponent: () => void;
|
||
deleteComponent: (id: string) => void;
|
||
patchHatch: (id: string, patch: Partial<HatchStyle>) => void;
|
||
addHatch: () => void;
|
||
deleteHatch: (id: string) => void;
|
||
patchLineStyle: (id: string, patch: Partial<LineStyle>) => void;
|
||
addLineStyle: () => void;
|
||
deleteLineStyle: (id: string) => void;
|
||
importLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
|
||
importHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
|
||
|
||
// ── Editier-Griffe + Body-Move des selektierten Elements ──────────────────
|
||
moveGripOf: (
|
||
drawingId: string | null,
|
||
wallId: string | null,
|
||
index: number,
|
||
pt: Vec2,
|
||
) => void;
|
||
moveElementByOf: (
|
||
drawingId: string | null,
|
||
wallId: string | null,
|
||
delta: Vec2,
|
||
) => void;
|
||
/**
|
||
* Verschiebt eine SEITE (Kante) des selektierten Elements: BEIDE Vertices der
|
||
* Kante (`aIndex`/`bIndex`) um denselben `delta` (immutabel). Beim Rechteck
|
||
* werden min/max anschließend neu normalisiert; bei der Wand bewegt sich
|
||
* `start`+`end` gemeinsam (ganze Wand senkrecht).
|
||
*/
|
||
moveEdgeOf: (
|
||
drawingId: string | null,
|
||
wallId: string | null,
|
||
aIndex: number,
|
||
bIndex: number,
|
||
delta: Vec2,
|
||
) => void;
|
||
|
||
// ── Selektions-Attribute (für Attributes-/Object-Info-Paletten) ───────────
|
||
/** Setzt die (Strich-)Farbe eines Drawing2D bzw. einer Wand (Override). */
|
||
setElementColor: (
|
||
kind: "wall" | "drawing2d",
|
||
id: string,
|
||
color: string,
|
||
) => void;
|
||
/** Setzt die direkte Strichstärke (mm) — NUR Drawing2D (Wände haben keine). */
|
||
setElementWeight: (id: string, weightMm: number) => void;
|
||
/** Setzt/entfernt die Füllschraffur — NUR Drawing2D (geschlossene Formen). */
|
||
setElementFill: (id: string, hatchId: string | null) => void;
|
||
/**
|
||
* Setzt/entfernt die Vollton-Füllfarbe — NUR Drawing2D (geschlossene Formen).
|
||
* `null` entfernt das Feld (Fläche wieder transparent).
|
||
*/
|
||
setElementFillColor: (id: string, fillColor: string | null) => void;
|
||
/**
|
||
* Skaliert die Geometrie eines Elements auf Zielbreite×-höhe (Meter) um einen
|
||
* Ankerpunkt (fx/fy ∈ [0,1] relativ zur bbox; 0,0 = oben-links).
|
||
*/
|
||
resizeElement: (
|
||
kind: "wall" | "drawing2d",
|
||
id: string,
|
||
w: number,
|
||
h: number,
|
||
anchor: { fx: number; fy: number },
|
||
) => void;
|
||
|
||
// ── Transformation (Bewegen/Spiegeln/Drehen) committen ────────────────────
|
||
commitTransformOn: (
|
||
sel: TransformSelection,
|
||
op: TransformOp,
|
||
points: Vec2[],
|
||
copyMode: CopyMode,
|
||
count: number,
|
||
) => void;
|
||
|
||
// ── Wand-Attribute (Object-Info-Panel) ────────────────────────────────────
|
||
/** Generischer immutabler Patch auf eine Wand (per ID). */
|
||
updateWall: (id: string, patch: Partial<import("../model/types").Wall>) => void;
|
||
/**
|
||
* Setzt die Gesamtdicke einer EINSCHICHTIGEN Wand. Ansatz: editiert die
|
||
* Schichtdicke des Wandtyps. Ist der Wandtyp mehrschichtig oder von mehr als
|
||
* dieser einen Wand genutzt, wird ein dedizierter einschichtiger Wandtyp
|
||
* (Klon der ersten Schicht) erzeugt und der Wand zugewiesen — so wirkt die
|
||
* Dicke nur auf diese Wand und teilt keine Presets versehentlich.
|
||
*/
|
||
setWallThickness: (wallId: string, thickness: number) => void;
|
||
}
|
||
|
||
/**
|
||
* Minimaler View-Ausschnitt, den Projekt-Aktionen lesen/schreiben dürfen
|
||
* (Cross-Slice). Der Store komponiert ProjectSlice mit dieser Sicht, sodass
|
||
* `get()`/`set` typsicher auf `activeLevelId` zugreifen können.
|
||
*/
|
||
type ProjectSliceDeps = {
|
||
activeLevelId: string;
|
||
};
|
||
|
||
export function createProjectSlice(
|
||
api: StoreApi<ProjectSlice & ProjectSliceDeps>,
|
||
): ProjectSlice {
|
||
const { set, get } = api;
|
||
|
||
// Bequemer immutabler Projekt-Updater (wie setProject in App).
|
||
const setProject: ProjectSlice["setProject"] = (next) =>
|
||
set((s) => ({
|
||
project: typeof next === "function" ? (next as (p: Project) => Project)(s.project) : next,
|
||
}));
|
||
|
||
return {
|
||
project: sampleProject,
|
||
|
||
setProject,
|
||
|
||
// ── Zeichnungsebenen ───────────────────────────────────────────────────
|
||
toggleLevelVisible: (id) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawingLevels: p.drawingLevels.map((z) =>
|
||
z.id === id ? { ...z, visible: !z.visible } : z,
|
||
),
|
||
})),
|
||
|
||
patchActiveFloor: (patch) => {
|
||
const activeLevelId = get().activeLevelId;
|
||
setProject((p) => ({
|
||
...p,
|
||
drawingLevels: recomputeFloorElevations(
|
||
p.drawingLevels.map((z) =>
|
||
z.id === activeLevelId ? { ...z, ...patch } : z,
|
||
),
|
||
),
|
||
}));
|
||
},
|
||
|
||
addFloor: () =>
|
||
setProject((p) => {
|
||
const n = p.drawingLevels.filter((z) => z.kind === "floor").length + 1;
|
||
const floor: DrawingLevel = {
|
||
id: `floor-${Date.now()}`,
|
||
name: t("default.floorName", { n }),
|
||
kind: "floor",
|
||
visible: true,
|
||
locked: false,
|
||
floorHeight: 2.6,
|
||
cutHeight: 1.0,
|
||
};
|
||
return {
|
||
...p,
|
||
drawingLevels: recomputeFloorElevations([...p.drawingLevels, floor]),
|
||
};
|
||
}),
|
||
|
||
addDrawing: () =>
|
||
setProject((p) => {
|
||
const n = p.drawingLevels.filter((z) => z.kind === "drawing").length + 1;
|
||
const drawing: DrawingLevel = {
|
||
id: `drawing-${Date.now()}`,
|
||
name: t("default.drawingName", { n }),
|
||
kind: "drawing",
|
||
visible: true,
|
||
locked: false,
|
||
};
|
||
return { ...p, drawingLevels: [...p.drawingLevels, drawing] };
|
||
}),
|
||
|
||
// Neue freie Zeichnungsebene mit explizitem Namen anlegen und ihre ID
|
||
// zurückgeben (Ziel-Ebene eines DXF-Imports). Der Name kommt vom Aufrufer
|
||
// (Dialog); leere Eingabe fällt auf den Default-Namen zurück.
|
||
addDrawingLevel: (name) => {
|
||
const id = `drawing-${Date.now()}`;
|
||
setProject((p) => {
|
||
const n = p.drawingLevels.filter((z) => z.kind === "drawing").length + 1;
|
||
const drawing: DrawingLevel = {
|
||
id,
|
||
name: name.trim() || t("default.drawingName", { n }),
|
||
kind: "drawing",
|
||
visible: true,
|
||
locked: false,
|
||
};
|
||
return { ...p, drawingLevels: [...p.drawingLevels, drawing] };
|
||
});
|
||
return id;
|
||
},
|
||
|
||
importDrawings: (drawings) =>
|
||
setProject((p) =>
|
||
drawings.length
|
||
? { ...p, drawings2d: [...p.drawings2d, ...drawings] }
|
||
: p,
|
||
),
|
||
|
||
patchLevel: (id, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawingLevels: recomputeFloorElevations(
|
||
p.drawingLevels.map((z) => (z.id === id ? { ...z, ...patch } : z)),
|
||
),
|
||
})),
|
||
|
||
duplicateLevel: (id) =>
|
||
setProject((p) => {
|
||
const i = p.drawingLevels.findIndex((z) => z.id === id);
|
||
if (i < 0) return p;
|
||
const src = p.drawingLevels[i];
|
||
const clone: DrawingLevel = {
|
||
...src,
|
||
id: `${src.kind}-${Date.now()}`,
|
||
name: t("default.copySuffixLevel", { name: src.name }),
|
||
};
|
||
const next = [...p.drawingLevels];
|
||
next.splice(i + 1, 0, clone);
|
||
return { ...p, drawingLevels: recomputeFloorElevations(next) };
|
||
}),
|
||
|
||
// Zeichnungsebene löschen (≥1 behalten). War es das aktive Geschoss, auf
|
||
// die erste verbleibende Ebene wechseln (View-Feld, Cross-Slice).
|
||
deleteLevel: (id) => {
|
||
const { project, activeLevelId } = get();
|
||
if (project.drawingLevels.length <= 1) return; // letzte behalten
|
||
if (!project.drawingLevels.some((z) => z.id === id)) return;
|
||
if (activeLevelId === id) {
|
||
const fallback = project.drawingLevels.find((z) => z.id !== id);
|
||
if (fallback) set({ activeLevelId: fallback.id });
|
||
}
|
||
setProject((p) => {
|
||
const removedWallIds = new Set(
|
||
p.walls.filter((w) => w.floorId === id).map((w) => w.id),
|
||
);
|
||
return {
|
||
...p,
|
||
drawingLevels: recomputeFloorElevations(
|
||
p.drawingLevels.filter((z) => z.id !== id),
|
||
),
|
||
walls: p.walls.filter((w) => w.floorId !== id),
|
||
doors: p.doors.filter((d) => !removedWallIds.has(d.hostWallId)),
|
||
};
|
||
});
|
||
},
|
||
|
||
// ── Ebenen (Kategorien) ────────────────────────────────────────────────
|
||
toggleCategoryVisible: (code) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
layers: toggleCategoryByCode(p.layers, code),
|
||
})),
|
||
|
||
addCategory: () =>
|
||
setProject((p) => {
|
||
const code = nextFreeCode(p.layers);
|
||
const category: LayerCategory = {
|
||
code,
|
||
name: t("default.categoryName"),
|
||
color: "#888888",
|
||
lw: 0.18,
|
||
visible: true,
|
||
locked: false,
|
||
};
|
||
return { ...p, layers: [...p.layers, category] };
|
||
}),
|
||
|
||
patchCategory: (code, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
layers: patchCategoryByCode(p.layers, code, patch),
|
||
})),
|
||
|
||
addSubCategory: (parentCode) =>
|
||
setProject((p) => {
|
||
const code = nextFreeCode(p.layers);
|
||
const child: LayerCategory = {
|
||
code,
|
||
name: t("default.subCategoryName"),
|
||
color: "#888888",
|
||
lw: 0.18,
|
||
visible: true,
|
||
locked: false,
|
||
};
|
||
return { ...p, layers: addChildByCode(p.layers, parentCode, child) };
|
||
}),
|
||
|
||
duplicateCategory: (code) =>
|
||
setProject((p) => {
|
||
const src = findCategory(p.layers, code);
|
||
if (!src) return p;
|
||
const clone: LayerCategory = {
|
||
...src,
|
||
code: nextFreeCode(p.layers),
|
||
name: t("default.copySuffixLayer", { name: src.name }),
|
||
children: undefined,
|
||
};
|
||
return { ...p, layers: insertAfterCode(p.layers, code, clone) };
|
||
}),
|
||
|
||
deleteCategory: (code) =>
|
||
setProject((p) => {
|
||
if (flattenCategories(p.layers).length <= 1) return p; // letzte behalten
|
||
const src = findCategory(p.layers, code);
|
||
if (!src) return p;
|
||
const codes = new Set(flattenCategories([src]).map((c) => c.code));
|
||
const usedByWall = p.walls.some((w) => codes.has(w.categoryCode));
|
||
const usedByDoor = p.doors.some((d) => codes.has(d.categoryCode));
|
||
if (usedByWall || usedByDoor) {
|
||
window.alert(t("alert.layerInUse", { code: src.code, name: src.name }));
|
||
return p;
|
||
}
|
||
return { ...p, layers: removeByCode(p.layers, code) };
|
||
}),
|
||
|
||
assignWallCategory: (wallId, code) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
walls: p.walls.map((w) =>
|
||
w.id === wallId ? { ...w, categoryCode: code } : w,
|
||
),
|
||
})),
|
||
|
||
// ── Sichtbarkeits-Kombinationen ────────────────────────────────────────
|
||
snapshotLayerVisibility: () => {
|
||
const out: Record<string, boolean> = {};
|
||
for (const c of flattenCategories(get().project.layers)) {
|
||
out[c.code] = c.visible;
|
||
}
|
||
return out;
|
||
},
|
||
|
||
applyLayerVisibility: (codes) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
layers: applyVisibilityByCode(p.layers, codes),
|
||
})),
|
||
|
||
snapshotDrawingVisibility: () => {
|
||
const out: Record<string, boolean> = {};
|
||
for (const z of get().project.drawingLevels) out[z.id] = z.visible;
|
||
return out;
|
||
},
|
||
|
||
applyDrawingVisibility: (ids) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawingLevels: p.drawingLevels.map((z) =>
|
||
z.id in ids ? { ...z, visible: ids[z.id] } : z,
|
||
),
|
||
})),
|
||
|
||
// ── Ressourcen ─────────────────────────────────────────────────────────
|
||
patchComponent: (id, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
components: p.components.map((c) =>
|
||
c.id === id ? { ...c, ...patch } : c,
|
||
),
|
||
})),
|
||
|
||
addComponent: () =>
|
||
setProject((p) => {
|
||
let hatches = p.hatches;
|
||
let hatchId = hatches[0]?.id;
|
||
if (!hatchId) {
|
||
const fallback: HatchStyle = {
|
||
id: `hatch-${Date.now()}`,
|
||
name: t("default.hatchNone"),
|
||
pattern: "none",
|
||
scale: 1,
|
||
angle: 0,
|
||
color: "#2b3039",
|
||
};
|
||
hatches = [fallback];
|
||
hatchId = fallback.id;
|
||
}
|
||
const component: Component = {
|
||
id: `component-${Date.now()}`,
|
||
name: t("default.componentName"),
|
||
color: "#9aa0a6",
|
||
hatchId,
|
||
joinPriority: 50,
|
||
};
|
||
return { ...p, hatches, components: [...p.components, component] };
|
||
}),
|
||
|
||
deleteComponent: (id) =>
|
||
setProject((p) => {
|
||
const used = p.wallTypes.some((wt) =>
|
||
wt.layers.some((l) => l.componentId === id),
|
||
);
|
||
if (used) {
|
||
const name = p.components.find((c) => c.id === id)?.name ?? id;
|
||
window.alert(t("alert.componentInUse", { name }));
|
||
return p;
|
||
}
|
||
return { ...p, components: p.components.filter((c) => c.id !== id) };
|
||
}),
|
||
|
||
patchHatch: (id, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
hatches: p.hatches.map((h) => (h.id === id ? { ...h, ...patch } : h)),
|
||
})),
|
||
|
||
addHatch: () =>
|
||
setProject((p) => {
|
||
const hatch: HatchStyle = {
|
||
id: `hatch-${Date.now()}`,
|
||
name: t("default.hatchName"),
|
||
pattern: "diagonal",
|
||
scale: 1,
|
||
angle: 45,
|
||
color: "#2b3039",
|
||
lineStyleId: p.lineStyles[0]?.id,
|
||
};
|
||
return { ...p, hatches: [...p.hatches, hatch] };
|
||
}),
|
||
|
||
deleteHatch: (id) =>
|
||
setProject((p) => {
|
||
const used = p.components.some((c) => c.hatchId === id);
|
||
if (used) {
|
||
const name = p.hatches.find((h) => h.id === id)?.name ?? id;
|
||
window.alert(t("alert.hatchInUse", { name }));
|
||
return p;
|
||
}
|
||
return { ...p, hatches: p.hatches.filter((h) => h.id !== id) };
|
||
}),
|
||
|
||
patchLineStyle: (id, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
lineStyles: p.lineStyles.map((l) =>
|
||
l.id === id ? { ...l, ...patch } : l,
|
||
),
|
||
})),
|
||
|
||
addLineStyle: () =>
|
||
setProject((p) => {
|
||
const lineStyle: LineStyle = {
|
||
id: `line-${Date.now()}`,
|
||
name: t("default.lineStyleName"),
|
||
weight: 0.18,
|
||
color: "#2b3039",
|
||
dash: null,
|
||
};
|
||
return { ...p, lineStyles: [...p.lineStyles, lineStyle] };
|
||
}),
|
||
|
||
deleteLineStyle: (id) =>
|
||
setProject((p) => {
|
||
const used = p.hatches.some((h) => h.lineStyleId === id);
|
||
if (used) {
|
||
const name = p.lineStyles.find((l) => l.id === id)?.name ?? id;
|
||
window.alert(t("alert.lineStyleInUse", { name }));
|
||
return p;
|
||
}
|
||
return { ...p, lineStyles: p.lineStyles.filter((l) => l.id !== id) };
|
||
}),
|
||
|
||
importLineStyles: (styles) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
lineStyles: [
|
||
...p.lineStyles,
|
||
...styles.map((s, i) => ({ ...s, id: `ls-${Date.now()}-${i}` })),
|
||
],
|
||
})),
|
||
|
||
importHatches: (hatches) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
hatches: [
|
||
...p.hatches,
|
||
...hatches.map((h, i) => ({ ...h, id: `ht-${Date.now()}-${i}` })),
|
||
],
|
||
})),
|
||
|
||
// ── Editier-Griffe ─────────────────────────────────────────────────────
|
||
moveGripOf: (drawingId, wallId, index, pt) =>
|
||
setProject((p) => moveGrip(p, drawingId, wallId, index, pt)),
|
||
|
||
moveElementByOf: (drawingId, wallId, delta) =>
|
||
setProject((p) => moveElementBy(p, drawingId, wallId, delta)),
|
||
|
||
moveEdgeOf: (drawingId, wallId, aIndex, bIndex, delta) =>
|
||
setProject((p) => moveEdge(p, drawingId, wallId, aIndex, bIndex, delta)),
|
||
|
||
// ── Selektions-Attribute ───────────────────────────────────────────────
|
||
setElementColor: (kind, id, color) =>
|
||
setProject((p) => {
|
||
if (kind === "drawing2d") {
|
||
return {
|
||
...p,
|
||
drawings2d: p.drawings2d.map((d) =>
|
||
d.id === id ? { ...d, color } : d,
|
||
),
|
||
};
|
||
}
|
||
return {
|
||
...p,
|
||
walls: p.walls.map((w) => (w.id === id ? { ...w, color } : w)),
|
||
};
|
||
}),
|
||
|
||
// Strichstärke gibt es als direktes Feld NUR auf Drawing2D; bei unbekannter
|
||
// ID bleibt das Projekt unverändert (No-op).
|
||
setElementWeight: (id, weightMm) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawings2d: p.drawings2d.map((d) =>
|
||
d.id === id ? { ...d, weightMm } : d,
|
||
),
|
||
})),
|
||
|
||
// Füllschraffur NUR für Drawing2D; `null` entfernt die Schraffur (Feld weg).
|
||
setElementFill: (id, hatchId) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawings2d: p.drawings2d.map((d) => {
|
||
if (d.id !== id) return d;
|
||
if (hatchId === null) {
|
||
const { hatchId: _omit, ...rest } = d;
|
||
return rest;
|
||
}
|
||
return { ...d, hatchId };
|
||
}),
|
||
})),
|
||
|
||
// Vollton-Füllfarbe NUR für Drawing2D; `null` entfernt das Feld.
|
||
setElementFillColor: (id, fillColor) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawings2d: p.drawings2d.map((d) => {
|
||
if (d.id !== id) return d;
|
||
if (fillColor === null) {
|
||
const { fillColor: _omit, ...rest } = d;
|
||
return rest;
|
||
}
|
||
return { ...d, fillColor };
|
||
}),
|
||
})),
|
||
|
||
// ── Größe (Resize um Anker) ────────────────────────────────────────────
|
||
resizeElement: (kind, id, w, h, anchor) =>
|
||
setProject((p) => resizeElement(p, kind, id, w, h, anchor)),
|
||
|
||
// ── Transformation ─────────────────────────────────────────────────────
|
||
commitTransformOn: (sel, op, points, copyMode, count) =>
|
||
setProject((p) => commitTransform(p, sel, op, points, copyMode, count)),
|
||
|
||
// ── Wand-Attribute ─────────────────────────────────────────────────────
|
||
updateWall: (id, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
walls: p.walls.map((w) => (w.id === id ? { ...w, ...patch } : w)),
|
||
})),
|
||
|
||
setWallThickness: (wallId, thickness) =>
|
||
setProject((p) => setWallThickness(p, wallId, thickness)),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Setzt die Gesamtdicke einer einschichtigen Wand über ihre Wandtyp-Schicht.
|
||
* Wenn der Wandtyp einschichtig ist UND nur von dieser Wand genutzt wird, wird
|
||
* er direkt editiert. Sonst wird ein dedizierter einschichtiger Wandtyp (Klon
|
||
* der ersten Schicht des aktuellen Typs) erzeugt und der Wand zugewiesen.
|
||
*/
|
||
function setWallThickness(
|
||
project: Project,
|
||
wallId: string,
|
||
thickness: number,
|
||
): Project {
|
||
if (!isFinite(thickness) || thickness <= 0) return project;
|
||
const wall = project.walls.find((w) => w.id === wallId);
|
||
if (!wall) return project;
|
||
const wt = project.wallTypes.find((t) => t.id === wall.wallTypeId);
|
||
if (!wt || wt.layers.length === 0) return project;
|
||
|
||
const single = wt.layers.length === 1;
|
||
const usedByOthers = project.walls.some(
|
||
(w) => w.id !== wallId && w.wallTypeId === wt.id,
|
||
);
|
||
|
||
if (single && !usedByOthers) {
|
||
// Direkt die Schichtdicke des (exklusiven, einschichtigen) Wandtyps setzen.
|
||
return {
|
||
...project,
|
||
wallTypes: project.wallTypes.map((t) =>
|
||
t.id === wt.id
|
||
? { ...t, layers: [{ ...t.layers[0], thickness }] }
|
||
: t,
|
||
),
|
||
};
|
||
}
|
||
|
||
// Dedizierten einschichtigen Wandtyp erzeugen (erste Schicht klonen) und der
|
||
// Wand zuweisen — damit die Dicke nur diese Wand betrifft.
|
||
const id = `wt-${Date.now()}`;
|
||
const clone = {
|
||
id,
|
||
name: t("default.wallTypeSingle", { thickness: thickness.toFixed(2) }),
|
||
layers: [{ ...wt.layers[0], thickness }],
|
||
};
|
||
return {
|
||
...project,
|
||
wallTypes: [...project.wallTypes, clone],
|
||
walls: project.walls.map((w) =>
|
||
w.id === wallId ? { ...w, wallTypeId: id } : w,
|
||
),
|
||
};
|
||
}
|
||
|
||
// ── Resize (Skalierung auf Zielmaß um einen Anker) ───────────────────────────
|
||
|
||
/**
|
||
* Skaliert ein Element auf Zielbreite `w` × -höhe `h` (Meter) um einen
|
||
* Ankerpunkt. `anchor.fx/fy ∈ [0,1]` liegen relativ zur aktuellen bbox
|
||
* (0,0 = oben-links, 1,1 = unten-rechts). Der Ankerpunkt bleibt fix; alle
|
||
* übrigen Punkte werden um die Skalierungsfaktoren um ihn herum gestreckt.
|
||
*/
|
||
function resizeElement(
|
||
project: Project,
|
||
kind: "wall" | "drawing2d",
|
||
id: string,
|
||
w: number,
|
||
h: number,
|
||
anchor: { fx: number; fy: number },
|
||
): Project {
|
||
if (kind === "drawing2d") {
|
||
return {
|
||
...project,
|
||
drawings2d: project.drawings2d.map((d) =>
|
||
d.id === id ? { ...d, geom: resizeGeom(d.geom, w, h, anchor) } : d,
|
||
),
|
||
};
|
||
}
|
||
return {
|
||
...project,
|
||
walls: project.walls.map((wall) => {
|
||
if (wall.id !== id) return wall;
|
||
const box = bbox2([wall.start, wall.end]);
|
||
const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
|
||
const apply = (p: Vec2): Vec2 => ({
|
||
x: ax + (p.x - ax) * sx,
|
||
y: ay + (p.y - ay) * sy,
|
||
});
|
||
return { ...wall, start: apply(wall.start), end: apply(wall.end) };
|
||
}),
|
||
};
|
||
}
|
||
|
||
/** bbox einer Punktliste. */
|
||
function bbox2(pts: Vec2[]): { minX: number; minY: number; maxX: number; maxY: number } {
|
||
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);
|
||
}
|
||
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||
return { minX, minY, maxX, maxY };
|
||
}
|
||
|
||
/**
|
||
* Liefert Ankerpunkt (ax,ay) und Skalierungsfaktoren (sx,sy), um eine bbox auf
|
||
* Zielbreite `w` × -höhe `h` zu bringen. Bei Null-Ausdehnung in einer Achse
|
||
* bleibt der Faktor 1 (keine Division durch 0, keine Verschiebung).
|
||
*/
|
||
function scaleAround(
|
||
box: { minX: number; minY: number; maxX: number; maxY: number },
|
||
w: number,
|
||
h: number,
|
||
anchor: { fx: number; fy: number },
|
||
): { ax: number; ay: number; sx: number; sy: number } {
|
||
const bw = box.maxX - box.minX;
|
||
const bh = box.maxY - box.minY;
|
||
const ax = box.minX + anchor.fx * bw;
|
||
const ay = box.minY + anchor.fy * bh;
|
||
const sx = bw > 1e-9 ? w / bw : 1;
|
||
const sy = bh > 1e-9 ? h / bh : 1;
|
||
return { ax, ay, sx, sy };
|
||
}
|
||
|
||
/**
|
||
* Skaliert eine 2D-Form auf Zielmaß um den Anker.
|
||
* • line/polyline/rect: jeder Punkt um den Anker skaliert (B/H aus bbox).
|
||
* • circle: Radius = halbe Zielbreite (min/Mittelwert wäre auch denkbar; Breite
|
||
* ist eindeutig), Mittelpunkt um den Anker verschoben.
|
||
* • arc: wie circle (Radius aus Zielbreite), Winkel unverändert.
|
||
* • text: nur die Einfügeposition wird um den Anker verschoben; die Schrifthöhe
|
||
* bleibt (Text skaliert über `height`, nicht über bbox — bewusst unverändert).
|
||
*/
|
||
function resizeGeom(
|
||
g: import("../model/types").Drawing2DGeom,
|
||
w: number,
|
||
h: number,
|
||
anchor: { fx: number; fy: number },
|
||
): import("../model/types").Drawing2DGeom {
|
||
switch (g.shape) {
|
||
case "line": {
|
||
const { ax, ay, sx, sy } = scaleAround(bbox2([g.a, g.b]), w, h, anchor);
|
||
const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy });
|
||
return { ...g, a: f(g.a), b: f(g.b) };
|
||
}
|
||
case "polyline": {
|
||
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 "rect": {
|
||
const box = bbox2([g.min, g.max]);
|
||
const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
|
||
const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy });
|
||
const a = f(g.min);
|
||
const b = f(g.max);
|
||
return {
|
||
...g,
|
||
min: { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) },
|
||
max: { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) },
|
||
};
|
||
}
|
||
case "circle":
|
||
case "arc": {
|
||
// bbox eines Kreises ist 2r×2r; der Anker liegt relativ dazu.
|
||
const box = {
|
||
minX: g.center.x - g.r,
|
||
minY: g.center.y - g.r,
|
||
maxX: g.center.x + g.r,
|
||
maxY: g.center.y + g.r,
|
||
};
|
||
const ax = box.minX + anchor.fx * (2 * g.r);
|
||
const ay = box.minY + anchor.fy * (2 * g.r);
|
||
const r = Math.max(w, h) / 2 > 0 ? Math.max(w, h) / 2 : g.r;
|
||
const sx = g.r > 1e-9 ? (w / 2) / g.r : 1;
|
||
const sy = g.r > 1e-9 ? (h / 2) / g.r : 1;
|
||
// Mittelpunkt anhand der nicht-uniformen Skalierung um den Anker schieben,
|
||
// den Radius aber als skalaren Kreis (max der Zielmaße/2) setzen.
|
||
const center: Vec2 = {
|
||
x: ax + (g.center.x - ax) * sx,
|
||
y: ay + (g.center.y - ay) * sy,
|
||
};
|
||
return { ...g, center, r };
|
||
}
|
||
case "text": {
|
||
// 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 { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
|
||
return { ...g, at: { x: ax + (g.at.x - ax) * sx, y: ay + (g.at.y - ay) * sy } };
|
||
}
|
||
default:
|
||
return g;
|
||
}
|
||
}
|
||
|
||
// ── Editier-Griffe (verschoben aus App.tsx, 1:1) ─────────────────────────────
|
||
|
||
/**
|
||
* Editier-Griffe (Eckpunkte) eines 2D-Zeichenelements in Modell-Metern. Phase 4
|
||
* unterstützt line/polyline/rect; circle/arc/text bekommen (noch) keine Griffe.
|
||
*/
|
||
export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
|
||
const g = d.geom;
|
||
if (g.shape === "line") return [g.a, g.b];
|
||
if (g.shape === "polyline") return [...g.pts];
|
||
if (g.shape === "rect") {
|
||
return [
|
||
g.min,
|
||
{ x: g.max.x, y: g.min.y },
|
||
g.max,
|
||
{ x: g.min.x, y: g.max.y },
|
||
];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/**
|
||
* Verschiebt den Griff `index` des selektierten Elements (2D-Element ODER Wand)
|
||
* auf `pt` (immutabel). Beim Rechteck bleibt die Achsparallelität erhalten.
|
||
*/
|
||
function moveGrip(
|
||
project: Project,
|
||
drawingId: string | null,
|
||
wallId: string | null,
|
||
index: number,
|
||
pt: Vec2,
|
||
): Project {
|
||
if (drawingId) {
|
||
return {
|
||
...project,
|
||
drawings2d: project.drawings2d.map((d) => {
|
||
if (d.id !== drawingId) return d;
|
||
const g = d.geom;
|
||
if (g.shape === "line") {
|
||
return { ...d, geom: index === 0 ? { ...g, a: pt } : { ...g, b: pt } };
|
||
}
|
||
if (g.shape === "polyline") {
|
||
const pts = g.pts.map((p, i) => (i === index ? pt : p));
|
||
return { ...d, geom: { ...g, pts } };
|
||
}
|
||
if (g.shape === "rect") {
|
||
const corners = [
|
||
g.min,
|
||
{ x: g.max.x, y: g.min.y },
|
||
g.max,
|
||
{ x: g.min.x, y: g.max.y },
|
||
];
|
||
const opp = corners[(index + 2) % 4];
|
||
const min = { x: Math.min(pt.x, opp.x), y: Math.min(pt.y, opp.y) };
|
||
const max = { x: Math.max(pt.x, opp.x), y: Math.max(pt.y, opp.y) };
|
||
return { ...d, geom: { ...g, min, max } };
|
||
}
|
||
return d;
|
||
}),
|
||
};
|
||
}
|
||
if (wallId) {
|
||
return {
|
||
...project,
|
||
walls: project.walls.map((w) =>
|
||
w.id === wallId ? { ...w, ...(index === 0 ? { start: pt } : { end: pt }) } : w,
|
||
),
|
||
};
|
||
}
|
||
return project;
|
||
}
|
||
|
||
/** Verschiebt das selektierte Element (2D-Element ODER Wand) um `delta` (parallel). */
|
||
function moveElementBy(
|
||
project: Project,
|
||
drawingId: string | null,
|
||
wallId: string | null,
|
||
delta: Vec2,
|
||
): Project {
|
||
const mv = (p: Vec2): Vec2 => ({ x: p.x + delta.x, y: p.y + delta.y });
|
||
if (drawingId) {
|
||
return {
|
||
...project,
|
||
drawings2d: project.drawings2d.map((d) => {
|
||
if (d.id !== drawingId) return d;
|
||
const g = d.geom;
|
||
switch (g.shape) {
|
||
case "line":
|
||
return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
|
||
case "polyline":
|
||
return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
|
||
case "rect":
|
||
return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
|
||
case "circle":
|
||
case "arc":
|
||
return { ...d, geom: { ...g, center: mv(g.center) } };
|
||
case "text":
|
||
return { ...d, geom: { ...g, at: mv(g.at) } };
|
||
default:
|
||
return d;
|
||
}
|
||
}),
|
||
};
|
||
}
|
||
if (wallId) {
|
||
return {
|
||
...project,
|
||
walls: project.walls.map((w) =>
|
||
w.id === wallId ? { ...w, start: mv(w.start), end: mv(w.end) } : w,
|
||
),
|
||
};
|
||
}
|
||
return project;
|
||
}
|
||
|
||
/**
|
||
* Verschiebt eine SEITE (Kante) des selektierten Elements: beide Vertices der
|
||
* Kante um `delta` (immutabel). Für 2D-Elemente:
|
||
* • rect: die zwei betroffenen Ecken (per Index) verschieben und min/max neu
|
||
* normalisieren — so wächst/schrumpft das Rechteck an genau dieser Seite.
|
||
* • polyline: die zwei Vertices `aIndex`/`bIndex` um `delta` verschieben.
|
||
* • line: beide Endpunkte verschieben (das gesamte Segment senkrecht).
|
||
* Für die Wand: start+end gemeinsam (die ganze Wand senkrecht zur Achse).
|
||
*/
|
||
function moveEdge(
|
||
project: Project,
|
||
drawingId: string | null,
|
||
wallId: string | null,
|
||
aIndex: number,
|
||
bIndex: number,
|
||
delta: Vec2,
|
||
): Project {
|
||
const mv = (p: Vec2): Vec2 => ({ x: p.x + delta.x, y: p.y + delta.y });
|
||
if (drawingId) {
|
||
return {
|
||
...project,
|
||
drawings2d: project.drawings2d.map((d) => {
|
||
if (d.id !== drawingId) return d;
|
||
const g = d.geom;
|
||
if (g.shape === "line") {
|
||
// Linie hat nur eine Kante [0,1] → das ganze Segment verschieben.
|
||
return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
|
||
}
|
||
if (g.shape === "polyline") {
|
||
const pts = g.pts.map((p, i) => (i === aIndex || i === bIndex ? mv(p) : p));
|
||
return { ...d, geom: { ...g, pts } };
|
||
}
|
||
if (g.shape === "rect") {
|
||
// Ecken in derselben Reihenfolge wie drawingVertices, die zwei Kanten-
|
||
// Ecken verschieben, dann bbox neu bilden (achsparallel halten).
|
||
const corners = [
|
||
g.min,
|
||
{ x: g.max.x, y: g.min.y },
|
||
g.max,
|
||
{ x: g.min.x, y: g.max.y },
|
||
];
|
||
const moved = corners.map((c, i) =>
|
||
i === aIndex || i === bIndex ? mv(c) : c,
|
||
);
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const c of moved) {
|
||
minX = Math.min(minX, c.x); minY = Math.min(minY, c.y);
|
||
maxX = Math.max(maxX, c.x); maxY = Math.max(maxY, c.y);
|
||
}
|
||
return { ...d, geom: { ...g, min: { x: minX, y: minY }, max: { x: maxX, y: maxY } } };
|
||
}
|
||
return d;
|
||
}),
|
||
};
|
||
}
|
||
if (wallId) {
|
||
// Wand: beide Enden gemeinsam → ganze Wand senkrecht verschieben.
|
||
return {
|
||
...project,
|
||
walls: project.walls.map((w) =>
|
||
w.id === wallId ? { ...w, start: mv(w.start), end: mv(w.end) } : w,
|
||
),
|
||
};
|
||
}
|
||
return project;
|
||
}
|
||
|
||
// ── Immutable Tree-Helfer (verschoben aus App.tsx, 1:1) ──────────────────────
|
||
|
||
/** Schaltet die Sichtbarkeit der Kategorie mit `code` im Baum um (immutabel). */
|
||
export function toggleCategoryByCode(
|
||
cats: LayerCategory[],
|
||
code: string,
|
||
): LayerCategory[] {
|
||
return cats.map((c) => {
|
||
if (c.code === code) return { ...c, visible: !c.visible };
|
||
if (c.children)
|
||
return { ...c, children: toggleCategoryByCode(c.children, code) };
|
||
return c;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Setzt im Baum die `visible`-Flags gemäß `codes` (Code → sichtbar?, immutabel).
|
||
* Kategorien, deren Code nicht in der Map steht, bleiben unverändert; Codes der
|
||
* Map, die es nicht mehr gibt, werden stillschweigend ignoriert.
|
||
*/
|
||
export function applyVisibilityByCode(
|
||
cats: LayerCategory[],
|
||
codes: Record<string, boolean>,
|
||
): LayerCategory[] {
|
||
return cats.map((c) => {
|
||
const next = c.code in codes ? { ...c, visible: codes[c.code] } : c;
|
||
if (next.children)
|
||
return { ...next, children: applyVisibilityByCode(next.children, codes) };
|
||
return next;
|
||
});
|
||
}
|
||
|
||
/** Nächster freier zweistelliger Code über den ganzen Baum hinweg. */
|
||
export function nextFreeCode(cats: LayerCategory[]): string {
|
||
const used = new Set<string>();
|
||
const walk = (list: LayerCategory[]) => {
|
||
for (const c of list) {
|
||
used.add(c.code);
|
||
if (c.children) walk(c.children);
|
||
}
|
||
};
|
||
walk(cats);
|
||
const nums = [...used].map((c) => parseInt(c, 10)).filter((n) => !isNaN(n));
|
||
const start = nums.length ? Math.max(...nums) : 0;
|
||
for (let i = start + 1; i < 1000; i++) {
|
||
const c = String(i).padStart(2, "0");
|
||
if (!used.has(c)) return c;
|
||
}
|
||
return String(Date.now());
|
||
}
|
||
|
||
/** Findet eine Kategorie (per Code) im Baum oder `undefined`. */
|
||
export function findCategory(
|
||
cats: LayerCategory[],
|
||
code: string,
|
||
): LayerCategory | undefined {
|
||
for (const c of cats) {
|
||
if (c.code === code) return c;
|
||
if (c.children) {
|
||
const hit = findCategory(c.children, code);
|
||
if (hit) return hit;
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/** Patcht die Kategorie mit `code` im Baum (immutabel). */
|
||
function patchCategoryByCode(
|
||
cats: LayerCategory[],
|
||
code: string,
|
||
patch: Partial<LayerCategory>,
|
||
): LayerCategory[] {
|
||
return cats.map((c) => {
|
||
if (c.code === code) return { ...c, ...patch };
|
||
if (c.children)
|
||
return { ...c, children: patchCategoryByCode(c.children, code, patch) };
|
||
return c;
|
||
});
|
||
}
|
||
|
||
/** Hängt `child` als letztes Kind an die Kategorie mit `parentCode` (immutabel). */
|
||
function addChildByCode(
|
||
cats: LayerCategory[],
|
||
parentCode: string,
|
||
child: LayerCategory,
|
||
): LayerCategory[] {
|
||
return cats.map((c) => {
|
||
if (c.code === parentCode)
|
||
return { ...c, children: [...(c.children ?? []), child] };
|
||
if (c.children)
|
||
return { ...c, children: addChildByCode(c.children, parentCode, child) };
|
||
return c;
|
||
});
|
||
}
|
||
|
||
/** Fügt `clone` direkt nach der Kategorie mit `code` auf derselben Ebene ein. */
|
||
function insertAfterCode(
|
||
cats: LayerCategory[],
|
||
code: string,
|
||
clone: LayerCategory,
|
||
): LayerCategory[] {
|
||
const out: LayerCategory[] = [];
|
||
for (const c of cats) {
|
||
const next =
|
||
c.children && !c.children.some((k) => k.code === code)
|
||
? { ...c, children: insertAfterCode(c.children, code, clone) }
|
||
: c;
|
||
out.push(next);
|
||
if (c.code === code) out.push(clone);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Entfernt die Kategorie mit `code` (samt Kindern) aus dem Baum (immutabel). */
|
||
function removeByCode(cats: LayerCategory[], code: string): LayerCategory[] {
|
||
const out: LayerCategory[] = [];
|
||
for (const c of cats) {
|
||
if (c.code === code) continue;
|
||
out.push(c.children ? { ...c, children: removeByCode(c.children, code) } : c);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Beschriftung einer Kategorie für die Status-Leiste: „Code Name". */
|
||
export function categoryLabel(cats: LayerCategory[], code: string): string {
|
||
const c = findCategory(cats, code);
|
||
return c ? `${c.code} ${c.name}` : code;
|
||
}
|