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
+333
View File
@@ -0,0 +1,333 @@
// Import-Dialog (modal) für DXF/DWG. Öffnet sich beim Import (Button ODER
// Datei-Drop) und fragt, WIE mit der Datei umzugehen ist:
// • Ziel-Zeichnungsebene: bestehende wählen ODER neue „Zeichnung" anlegen.
// • Ebenen-Handling: DXF-Layer als Kategorien (categoryCode) übernehmen vs.
// alles auf die aktive Kategorie.
// • Mesh-Behandlung: vorhandene Meshes als Kontext-Geometrie (3D) importieren.
//
// Muster wie ResourceManager: abgedunkeltes Overlay, Klick auf den Hintergrund
// und Esc schließen. Diese Komponente HÄLT nur Formular-Zustand und liefert beim
// Bestätigen eine strukturierte Entscheidung (`ImportDecision`) nach oben; die
// eigentlichen Store-Mutationen (Ebene anlegen, Drawings anhängen, Kontext)
// führt App in der richtigen Reihenfolge aus.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CLAUDE.md). Alle sichtbaren
// Texte über t(...).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import type { TranslationKey } from "../i18n";
import type { DrawingLevelKind, ImportedMesh, Project } from "../model/types";
import type { DxfImportResult } from "../io/dxfParser";
import {
contoursToDrawings,
countContours,
distinctLayers,
} from "../io/dxfToDrawings";
import type { CategoryMode } from "../io/dxfToDrawings";
/** Strukturierte Import-Entscheidung, die der Dialog beim Bestätigen liefert. */
export interface ImportDecision {
/** Ziel: bestehende Ebene (id) ODER neue Zeichnung (name). */
target: { kind: "existing"; levelId: string } | { kind: "new"; name: string };
/** Wie DXF-Layer auf Kategorien abgebildet werden. */
categoryMode: CategoryMode;
/** Ob die Meshes als Kontext-Geometrie übernommen werden. */
importMeshes: boolean;
/** Die geparsten Konturen-Sätze (für die spätere 2D-Konvertierung). */
parsed: DxfImportResult;
}
/** i18n-Badge-Key je Zeichnungsebenen-Art. */
function kindBadgeKey(kind: DrawingLevelKind): TranslationKey {
switch (kind) {
case "floor":
return "import.badge.floor";
case "section":
return "import.badge.section";
case "elevation":
return "import.badge.elevation";
case "drawing":
return "import.badge.drawing";
}
}
export interface ImportDialogProps {
fileName: string;
/**
* true, wenn eine DWG-Datei NICHT gelesen werden konnte (LibreDWG-WASM schlug
* fehl) → ODA-Hinweis als Fallback. Bei erfolgreichem DWG-Parsing ist das false
* und `parsed` enthält das Ergebnis (gleicher Fluss wie DXF).
*/
isDwg: boolean;
/** true, solange eine DWG-Datei noch geparst wird (WASM lädt/läuft). */
loading?: boolean;
/** Geparstes Ergebnis (null während Laden / bei DWG-Parse-Fehler). */
parsed: DxfImportResult | null;
project: Project;
activeCategoryCode: string;
onImport: (decision: ImportDecision) => void;
onClose: () => void;
}
export function ImportDialog({
fileName,
isDwg,
loading = false,
parsed,
project,
activeCategoryCode,
onImport,
onClose,
}: ImportDialogProps) {
// Esc schließt den Dialog (wie ResourceManager).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const contourSets = parsed?.contours ?? [];
const meshes: ImportedMesh[] = parsed?.meshes ?? [];
const contourCount = countContours(contourSets);
const layers = distinctLayers(contourSets);
const hasDrawings = contourCount > 0;
const hasMeshes = meshes.length > 0;
// ── Formular-Zustand ──────────────────────────────────────────────────────
const firstLevelId = project.drawingLevels[0]?.id ?? "";
const [targetKind, setTargetKind] = useState<"existing" | "new">("new");
const [existingLevelId, setExistingLevelId] = useState<string>(firstLevelId);
// Default-Name der neuen Zeichnung aus dem Dateinamen (ohne Endung).
const [newName, setNewName] = useState<string>(() =>
fileName.replace(/\.[^.]+$/, "") || "",
);
const [categoryMode, setCategoryMode] = useState<CategoryMode>(
layers.length > 0 ? "byLayer" : "active",
);
const [importMeshes, setImportMeshes] = useState<boolean>(true);
const canImport =
!isDwg && !loading && (hasDrawings || (hasMeshes && importMeshes));
const onConfirm = () => {
if (!parsed || !canImport) return;
const target =
targetKind === "existing"
? ({ kind: "existing", levelId: existingLevelId } as const)
: ({ kind: "new", name: newName } as const);
onImport({ target, categoryMode, importMeshes, parsed });
};
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("import.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("import.title")}</span>
<button
className="imp-close"
onClick={onClose}
aria-label={t("import.close")}
>
×
</button>
</header>
<div className="imp-body">
{/* Datei-Zusammenfassung. */}
<div className="imp-file">
<span className="imp-file-label">{t("import.file")}</span>
<span className="imp-file-name" title={fileName}>
{fileName}
</span>
</div>
{loading ? (
// DWG wird gelesen (WASM lädt/parst) → Ladehinweis, nur Abbrechen.
<section className="imp-section">
<div className="imp-section-title">{t("import.dwg.heading")}</div>
<div className="imp-dwg-hint">{t("import.dwg.loading")}</div>
</section>
) : isDwg ? (
// DWG konnte nicht gelesen werden → ODA-Fallback-Hinweis.
<section className="imp-section">
<div className="imp-section-title">{t("import.dwg.heading")}</div>
<div className="imp-dwg-hint">{t("import.dwg.hint")}</div>
</section>
) : (
<>
{/* Inhalt der Datei. */}
<section className="imp-section">
<div className="imp-section-title">
{t("import.summary.heading")}
</div>
<ul className="imp-summary">
<li>{t("import.summary.contours", { n: contourCount })}</li>
<li>{t("import.summary.meshes", { n: meshes.length })}</li>
<li>
{layers.length > 0
? t("import.summary.layers", { layers: layers.join(", ") })
: t("import.summary.noLayers")}
</li>
</ul>
{!hasDrawings && !hasMeshes && (
<div className="imp-warn">{t("import.summary.empty")}</div>
)}
</section>
{/* Ziel-Zeichnungsebene. */}
{hasDrawings && (
<section className="imp-section">
<div className="imp-section-title">
{t("import.target.heading")}
</div>
<label className="imp-radio">
<input
type="radio"
name="imp-target"
checked={targetKind === "existing"}
onChange={() => setTargetKind("existing")}
/>
<span>{t("import.target.existing")}</span>
</label>
{targetKind === "existing" && (
<select
className="imp-select"
value={existingLevelId}
onChange={(e) => setExistingLevelId(e.target.value)}
aria-label={t("import.target.existing")}
>
{project.drawingLevels.map((z) => (
<option key={z.id} value={z.id}>
{z.name} · {t(kindBadgeKey(z.kind))}
</option>
))}
</select>
)}
<label className="imp-radio">
<input
type="radio"
name="imp-target"
checked={targetKind === "new"}
onChange={() => setTargetKind("new")}
/>
<span>{t("import.target.new")}</span>
</label>
{targetKind === "new" && (
<input
type="text"
className="imp-input"
value={newName}
placeholder={t("import.target.newNamePlaceholder")}
onChange={(e) => setNewName(e.target.value)}
aria-label={t("import.target.newNamePlaceholder")}
/>
)}
</section>
)}
{/* Ebenen-Handling (DXF-Layer → Kategorien). */}
{hasDrawings && (
<section className="imp-section">
<div className="imp-section-title">
{t("import.layers.heading")}
</div>
<label className="imp-radio">
<input
type="radio"
name="imp-cat"
checked={categoryMode === "byLayer"}
disabled={layers.length === 0}
onChange={() => setCategoryMode("byLayer")}
/>
<span>{t("import.layers.asCategories")}</span>
</label>
<label className="imp-radio">
<input
type="radio"
name="imp-cat"
checked={categoryMode === "active"}
onChange={() => setCategoryMode("active")}
/>
<span>
{t("import.layers.toActive", { code: activeCategoryCode })}
</span>
</label>
</section>
)}
{/* Mesh-Behandlung. */}
{hasMeshes && (
<section className="imp-section">
<div className="imp-section-title">
{t("import.mesh.heading")}
</div>
<label className="imp-check">
<input
type="checkbox"
checked={importMeshes}
onChange={(e) => setImportMeshes(e.target.checked)}
/>
<span>{t("import.mesh.asContext")}</span>
</label>
<div className="imp-note">{t("import.mesh.note")}</div>
</section>
)}
</>
)}
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("import.cancel")}
</button>
<button
className="imp-btn primary"
onClick={onConfirm}
disabled={!canImport}
>
{t("import.confirm")}
</button>
</footer>
</div>
</div>
);
}
/**
* Baut aus einer Import-Entscheidung die fertigen Drawing2D-Elemente für die
* Ziel-Ebene `levelId`. `byLayer` mappt jeden DXF-Layer-Namen deterministisch
* auf einen Kategorie-Code: existiert eine Kategorie mit passendem Namen, wird
* deren Code genutzt; sonst fällt der Layer auf den aktiven Code zurück.
*
* (Die App ruft diese Funktion, NACHDEM sie ggf. die neue Ebene angelegt hat —
* sie braucht die echte Ziel-`levelId`.)
*/
export function buildDrawings(
decision: ImportDecision,
levelId: string,
project: Project,
activeCategoryCode: string,
) {
// Layer-Name → Kategorie-Code: bevorzugt eine Kategorie mit demselben Namen
// (case-insensitiv), sonst der aktive Code. Identifier bleiben so stabil.
const byName = new Map<string, string>();
for (const c of project.layers) byName.set(c.name.toLowerCase(), c.code);
const layerToCode = (layerName: string): string =>
byName.get(layerName.toLowerCase()) ?? activeCategoryCode;
return contoursToDrawings(
decision.parsed.contours,
levelId,
decision.categoryMode,
activeCategoryCode,
layerToCode,
);
}