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,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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user