Files
DOSSIER-STANDALONE/src/ui/ImportDialog.tsx
T

408 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (CONVENTIONS.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, ImportDiagnostics } from "../io/dxfParser";
import {
contoursToDrawings,
textsToDrawings,
countContours,
distinctLayers,
} from "../io/dxfToDrawings";
import type { CategoryMode } from "../io/dxfToDrawings";
import { Dropdown } from "./Dropdown";
/** 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;
/**
* Platzierung der importierten Geometrie: `origin` = 1:1 an den DXF-Koordinaten
* (relativ zum Nullpunkt); `viewCenter` = der Gesamt-Umriss wird in die Mitte
* des aktuellen Ausschnitts geschoben.
*/
placement: "origin" | "viewCenter";
/** 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";
}
}
/**
* Formatiert das Entity-Typ-Histogramm der Import-Diagnose zu einer kompakten
* Daten-Zeile, z. B. „120 Entities — INSERT×80, ARC×30, LINE×10". Die Entity-
* Typ-Codes sind technische DWG-Bezeichner (keine übersetzbare UI-Prosa); nur
* die Gesamtzahl ist eine reine Zahl. Bei 0 Entities zeigt sie „0 Entities" —
* das deutet auf ein Encoding-/Versions-Problem (nicht auf eine Abdeckungslücke).
*/
function formatDiagnostics(d: ImportDiagnostics): string {
const parts = Object.entries(d.entityCounts)
.sort((a, b) => b[1] - a[1])
.map(([type, n]) => `${type}×${n}`);
const head = `${d.total} Entities`;
return parts.length > 0 ? `${head}${parts.join(", ")}` : head;
}
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 texts = parsed?.texts ?? [];
const contourCount = countContours(contourSets);
const textCount = texts.length;
const layers = distinctLayers(contourSets);
const hasDrawings = contourCount > 0 || textCount > 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 [placement, setPlacement] = useState<"origin" | "viewCenter">("origin");
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, placement, 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>
{textCount > 0 && (
<li>{t("import.summary.texts", { n: textCount })}</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")}
{/* Diagnose-Histogramm (vom DWG-Parser): macht sichtbar,
WORAN es liegt — 0 Entities (Encoding/Version) vs.
Abdeckungslücke (z. B. „500 ARC, 200 INSERT"). Entity-Typ-
Codes + Zahlen sind technische DWG-Bezeichner (Daten,
keine übersetzbare Prosa). */}
{parsed?.diagnostics && (
<div className="imp-diag">
{formatDiagnostics(parsed.diagnostics)}
</div>
)}
</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" && (
<Dropdown
value={existingLevelId}
onChange={(v) => setExistingLevelId(v)}
title={t("import.target.existing")}
options={project.drawingLevels.map((z) => ({
value: z.id,
label: `${z.name} · ${t(kindBadgeKey(z.kind))}`,
}))}
/>
)}
<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>
)}
{/* Platzierung: an den DXF-Koordinaten (relativ zu 0) ODER in die
Mitte des aktuellen Ausschnitts geschoben. */}
{hasDrawings && (
<section className="imp-section">
<div className="imp-section-title">
{t("import.placement.heading")}
</div>
<label className="imp-radio">
<input
type="radio"
name="imp-place"
checked={placement === "origin"}
onChange={() => setPlacement("origin")}
/>
<span>{t("import.placement.origin")}</span>
</label>
<label className="imp-radio">
<input
type="radio"
name="imp-place"
checked={placement === "viewCenter"}
onChange={() => setPlacement("viewCenter")}
/>
<span>{t("import.placement.viewCenter")}</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;
const fromContours = contoursToDrawings(
decision.parsed.contours,
levelId,
decision.categoryMode,
activeCategoryCode,
layerToCode,
);
const fromTexts = textsToDrawings(
decision.parsed.texts ?? [],
levelId,
decision.categoryMode,
activeCategoryCode,
layerToCode,
);
return [...fromContours, ...fromTexts];
}