408 lines
16 KiB
TypeScript
408 lines
16 KiB
TypeScript
// 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];
|
||
}
|