Projektdatei-I/O (Speichern/Öffnen/Lock) und Schnellexport (CSV/IFC/OBJ/

STL) sowie die Ausführung eines bestätigten DXF/DWG-Imports aus App.tsx
in useProjectFileActions() extrahiert.
This commit is contained in:
2026-07-31 17:03:41 +02:00
parent 63dba0592c
commit f2b5740d89
2 changed files with 400 additions and 308 deletions
+35 -308
View File
@@ -41,11 +41,8 @@ import { generatePlan } from "./plan/generatePlan";
import type { CategoryDisplayResolver } from "./plan/generatePlan";
import { sectionPlaneFromLevel } from "./plan/toSection";
import { roofBaseElevation, roofGeometry } from "./geometry/roof";
import { parseDxf } from "./io/dxfParser";
import type { DxfImportResult } from "./io/dxfParser";
import { setDxfImportTrigger } from "./io/dxfImportHook";
import { ImportDialog, buildDrawings } from "./ui/ImportDialog";
import type { ImportDecision } from "./ui/ImportDialog";
import { ImportDialog } from "./ui/ImportDialog";
import { ExportPdfDialog } from "./ui/ExportPdfDialog";
import type { ExportPdfDecision } from "./ui/ExportPdfDialog";
import { savePlanPdf } from "./export/exportPdf";
@@ -53,27 +50,14 @@ import { ExportDxfDialog } from "./ui/ExportDxfDialog";
import type { ExportDxfDecision } from "./ui/ExportDxfDialog";
import { savePlanDxf } from "./export/exportDxf";
import { projectToWorldCoords } from "./model/geoRebase";
import { exportScheduleCsv } from "./export/exportSchedule";
import { exportIfcSpf } from "./export/exportIfc";
import { exportObj, exportStl } from "./export/exportMesh";
import type { ScheduleKind } from "./export/exportSchedule";
import { SettingsDialog } from "./ui/SettingsDialog";
import { OpeningEditorDialog } from "./ui/OpeningEditorDialog";
import { ExportSaveDialog } from "./ui/ExportSaveDialog";
import type { ExportSaveFormat } from "./ui/ExportSaveDialog";
import { PromptDialog } from "./ui/PromptDialog";
import { LockConflictDialog } from "./ui/LockConflictDialog";
import { Toast } from "./ui/Toast";
import { saveTextFile } from "./io/saveFile";
import {
saveProjectFile,
openProjectFileNative,
acquireProjectLock,
releaseProjectLock,
registerLockAutoRelease,
isTauriRuntime,
} from "./io/projectFile";
import type { LockInfo } from "./io/projectFile";
import { isTauriRuntime } from "./io/projectFile";
import { checkForUpdate } from "./update/checkForUpdate";
import type {
PlanViewHandle,
@@ -153,6 +137,7 @@ import { useDialogState } from "./state/useDialogState";
import { useTopBarState } from "./state/useTopBarState";
import { useDrawingToolState } from "./state/useDrawingToolState";
import { useSelectionState } from "./state/useSelectionState";
import { useProjectFileActions } from "./state/useProjectFileActions";
import { useProjectActions } from "./state/useProjectActions";
import { useThemeModeState } from "./theme/useThemeMode";
import { CommandLine } from "./ui/CommandLine";
@@ -261,43 +246,6 @@ function coupledCommandFor(id: ToolId): string | null {
return TOOL_COMMAND[id] ?? null;
}
/** Umriss (bbox) aller Drawing2D-Geometrien in Modell-Metern (null = leer). */
function drawingsBBox(
drawings: Drawing2D[],
): { minX: number; minY: number; maxX: number; maxY: number } | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const acc = (x: number, y: number) => {
minX = Math.min(minX, x); minY = Math.min(minY, y);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
};
for (const d of drawings) {
const g = d.geom;
if (g.shape === "line") { acc(g.a.x, g.a.y); acc(g.b.x, g.b.y); }
else if (g.shape === "polyline") for (const p of g.pts) acc(p.x, p.y);
else if (g.shape === "rect") { acc(g.min.x, g.min.y); acc(g.max.x, g.max.y); }
else if (g.shape === "circle" || g.shape === "arc") {
acc(g.center.x - g.r, g.center.y - g.r);
acc(g.center.x + g.r, g.center.y + g.r);
} else if (g.shape === "text") acc(g.at.x, g.at.y);
}
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
}
/** Ein Drawing2D um (dx,dy) in Modell-Metern verschieben (alle Formen). */
function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D {
const mv = (p: Vec2): Vec2 => ({ x: p.x + dx, y: p.y + dy });
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": return { ...d, geom: { ...g, center: mv(g.center) } };
case "arc": return { ...d, geom: { ...g, center: mv(g.center) } };
case "text": return { ...d, geom: { ...g, at: mv(g.at) } };
default: return d;
}
}
/**
* Baut eine TransformSelection für GENAU EIN Element (Object-Info: Verschieben/
* Drehen per Zahlenfeld, siehe onMoveSelectionBy/onRotateSelectionAround unten).
@@ -512,28 +460,6 @@ export default function App() {
// Der Klick auf „DXF importieren" triggert .click() dieses Inputs; onChange
// liest die Datei als Text und reicht sie durch parseDxf → addContextObjects.
const dxfInputRef = useRef<HTMLInputElement>(null);
// Verstecktes Datei-Input zum Laden eines Projekts (Datei-Burger „Öffnen").
// Wird NUR im Browser-Pfad benutzt (ohne Tauri) — unter Tauri geht „Öffnen"
// über den nativen Dialog (openProjectFileNative), der einen echten Pfad für
// den Lock liefert (File-API im Browser hat keinen echten Dateipfad).
const projectFileInputRef = useRef<HTMLInputElement>(null);
// Pfad + Lock-Status der aktuell offenen .obp/.json-Datei (nur Tauri; sonst
// bleibt beides null/false — Browser-Blob-Pfad hat keinen echten Pfad).
const openedProjectPathRef = useRef<string | null>(null);
const projectLockOwnedRef = useRef(false);
// Konflikt-Zustand: Datei ist in einer anderen Instanz gesperrt → Dialog mit
// „Schreibgeschützt öffnen"/„Trotzdem erzwingen"/„Abbrechen" (kein window.confirm!).
const [lockConflict, setLockConflict] = useState<{
path: string;
content: string;
info: LockInfo | null;
} | null>(null);
// Best-effort Lock-Freigabe beim Fenster-Schliessen (sauberes Timing; der
// OS-Lock fällt bei Prozessende/Absturz ohnehin automatisch weg). No-op ohne Tauri.
useEffect(() => {
registerLockAutoRelease(() => openedProjectPathRef.current);
}, []);
// Den DXF-Datei-Dialog-Öffner für den „import"-Befehl registrieren (entkoppelt
// über den Modul-Hook). Beim Unmount wieder abmelden.
@@ -2215,6 +2141,38 @@ export default function App() {
setRoomStamp,
} = useProjectActions(setProject, notify, planViewRef);
// Projektdatei-I/O (Speichern/Öffnen/Lock), Schnellexport (CSV/IFC/OBJ/STL)
// und die Ausführung eines bestätigten DXF/DWG-Imports — gebündelt in
// useProjectFileActions(), siehe dort.
const {
projectFileInputRef,
lockConflict,
setLockConflict,
openImportForFile,
onSaveProject,
applyOpenedProjectFile,
projectFileName,
runExport,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
onOpenProject,
onProjectFileChosen,
runImport,
} = useProjectFileActions(
project,
setProject,
setImportState,
setExportSaveFormat,
activeCategoryCode,
addDrawingLevel,
importDrawings,
addContextObjects,
setActiveLevelId,
planViewRef,
);
// Text-Ziel der Oberleisten-Text-Gruppe: der Stempel-Text des (einzeln)
// gewählten Raums. Ohne aktive Editor-Auswahl wirkt Formatierung auf das ganze
// Doc (range: null). Ist kein Raum gewählt → null (Controls setzen nur
@@ -2458,182 +2416,6 @@ export default function App() {
[],
);
// ── DXF/DWG-Import (Dialog) ───────────────────────────────────────────────
// Eine Datei (Button ODER Drop) öffnet den modalen Import-Dialog. DXF wird als
// Text geparst, DWG als ArrayBuffer über LibreDWG-WASM (lazy, asynchron); beide
// münden in dasselbe {meshes, contours}-Format und denselben Dialog-Fluss.
// Parse-Fehler werden ruhig abgefangen: DWG fällt auf den ODA-Hinweis zurück.
const openImportForFile = (file: File) => {
const isDwg = /\.dwg$/i.test(file.name);
if (isDwg) {
// Async-Zustand: Dialog öffnet sofort und zeigt „DWG wird gelesen …",
// während WASM lädt/parst. Danach echte Zusammenfassung ODER ODA-Hinweis.
setImportState({
fileName: file.name,
isDwg: true,
parsed: null,
loading: true,
});
const r = new FileReader();
r.onload = () => {
const buf = r.result;
if (!(buf instanceof ArrayBuffer)) {
setImportState({
fileName: file.name,
isDwg: true,
parsed: null,
loading: false,
});
return;
}
// Dynamisch importieren, damit LibreDWG-WASM nicht im Initial-Bundle ist.
import("./io/dwgParser")
.then(({ parseDwg }) => parseDwg(buf))
.then((parsed) => {
setImportState({
fileName: file.name,
isDwg: false,
parsed,
loading: false,
});
})
.catch((err) => {
console.error("DWG-Import fehlgeschlagen:", err);
// Fallback: weiterhin DWG-Modus → Dialog zeigt den ODA-Hinweis.
setImportState({
fileName: file.name,
isDwg: true,
parsed: null,
loading: false,
});
});
};
r.readAsArrayBuffer(file);
return;
}
const r = new FileReader();
r.onload = () => {
let parsed: DxfImportResult | null = null;
try {
parsed = parseDxf(String(r.result ?? ""));
} catch (err) {
console.error("DXF-Import fehlgeschlagen:", err);
parsed = { meshes: [], contours: [] };
}
setImportState({ fileName: file.name, isDwg: false, parsed });
};
r.readAsText(file);
};
// Projekt als Datei speichern/laden (Datei-Burger-Menü, TopBar/AppMenu).
// Reines JSON des ProjectSlice-Zustands. Eigene Endung „.obp" (openbureau
// project) statt generischem „.json" — unter Tauri über den nativen
// Speichern-/Öffnen-Dialog mit passendem Datei-Filter (saveProjectFile/
// openProjectFileNative in io/projectFile.ts), im Browser Blob-Download wie
// bisher. Laden parst die Datei und ersetzt das Projekt — nur ein grobes
// Shape-Sanity-Check (walls/layers als Array), damit ein fremdes/kaputtes
// JSON nicht die App zerlegt. Alte „.json"-Projekte bleiben ladbar (Filter
// erlaubt beide Endungen).
const onSaveProject = () => {
const json = JSON.stringify(project, null, 2);
void saveProjectFile(json, project.name);
};
// Gibt den Lock der zuletzt geöffneten Projektdatei frei (falls wir ihn
// halten) und vergisst den Pfad — vor dem Wechsel auf ein anderes Projekt.
const releaseCurrentProjectLock = () => {
if (openedProjectPathRef.current && projectLockOwnedRef.current) {
void releaseProjectLock(openedProjectPathRef.current);
}
openedProjectPathRef.current = null;
projectLockOwnedRef.current = false;
};
// Übernimmt ein per Pfad geöffnetes Projekt (Tauri-Fluss): parst, prüft grob
// die Form, gibt einen evtl. vorher gehaltenen Lock frei und merkt sich Pfad
// + ob WIR den Schreib-Lock für die neue Datei halten.
const applyOpenedProjectFile = (path: string, content: string, lockOwned: boolean) => {
try {
const parsed = JSON.parse(content) as Partial<Project>;
if (!Array.isArray(parsed.walls) || !Array.isArray(parsed.layers)) {
console.warn("Projekt-Datei hat kein gültiges Projekt-Format (walls/layers fehlen).");
return;
}
releaseCurrentProjectLock();
setProject(parsed as Project);
openedProjectPathRef.current = path;
projectLockOwnedRef.current = lockOwned;
} catch (err) {
console.error("Projekt-Import fehlgeschlagen:", err);
}
};
// Öffnen unter Tauri: nativer Dialog (echter Pfad) → Lock versuchen. Klappt
// er → Projekt übernehmen. Schon gesperrt → Konflikt-Dialog (kein
// window.confirm!) mit „Schreibgeschützt öffnen"/„Trotzdem erzwingen"/
// „Abbrechen". Beide „öffnen"-Optionen übernehmen das Projekt ohne eigenen
// Lock (die andere Instanz hält ihn) — „erzwingen" bewusst mit Warn-Hinweis
// im Dialog, „schreibgeschützt" als sichere Standardwahl.
const openProjectViaNativeDialog = async () => {
let opened: Awaited<ReturnType<typeof openProjectFileNative>>;
try {
opened = await openProjectFileNative();
} catch (err) {
console.error(t("file.openFailed"), err);
return;
}
if (!opened) return; // Nutzer hat den Öffnen-Dialog abgebrochen
const lock = await acquireProjectLock(opened.path);
if (lock.ok) {
applyOpenedProjectFile(opened.path, opened.content, true);
} else {
setLockConflict({ path: opened.path, content: opened.content, info: lock.info });
}
};
// Datei speichern: unter Tauri nativer „Speichern unter"-Dialog (Ordner + Name),
// im Browser Blob-Download-Fallback — die gesamte Logik (inkl. WKWebView-
// Härtung: octet-stream-MIME + verzögertes revoke) steckt in saveTextFile.
const downloadTextFile = (
content: string,
filename: string,
mime = "application/octet-stream",
) => {
void saveTextFile(content, filename, mime);
};
const projectFileName = () => project.name || "modell";
// Führt den gewählten Direkt-Export (CSV/IFC/OBJ/STL) mit dem im Dialog
// gewählten Dateinamen aus. Erzeugung aus dem jeweiligen reinen Kern, Download
// über den robusten Helfer.
const runExport = (format: ExportSaveFormat, filename: string) => {
switch (format) {
case "csv":
downloadTextFile("" + exportScheduleCsv(project), filename, "text/csv;charset=utf-8;");
break;
case "ifc":
downloadTextFile(exportIfcSpf(project), filename);
break;
case "obj":
downloadTextFile(exportObj(project), filename);
break;
case "stl":
downloadTextFile(exportStl(project), filename);
break;
}
};
// Schnellexport aus dem Topbar-Menü öffnet den Format-/Dateiname-Dialog
// (vorausgewähltes Format). PDF/DXF haben eigene Options-Dialoge.
const onExportSchedule = () => setExportSaveFormat("csv");
const onExportIfc = () => setExportSaveFormat("ifc");
const onExportObj = () => setExportSaveFormat("obj");
const onExportStl = () => setExportSaveFormat("stl");
// Unter Tauri: nativer Öffnen-Dialog (echter Pfad → Lock möglich). Im
// Browser: verstecktes <input type=file> wie bisher (kein echter Pfad, also
// ohne Lock — File-API im Browser liefert keinen Dateisystempfad).
const onOpenProject = () => {
if (isTauriRuntime()) {
void openProjectViaNativeDialog();
} else {
projectFileInputRef.current?.click();
}
};
// Panel-Liste für „Fenster > Panels" (native Menüleiste) — die Registry ist
// statisch (Panels tragen sich beim Modul-Import ein), daher einmalig.
const nativeMenuPanels = useMemo(
@@ -2683,61 +2465,6 @@ export default function App() {
nativeMenuPanels,
);
const onProjectFileChosen = (file: File) => {
const r = new FileReader();
r.onload = () => {
try {
const parsed = JSON.parse(String(r.result ?? "")) as Partial<Project>;
if (!Array.isArray(parsed.walls) || !Array.isArray(parsed.layers)) {
console.warn("Projekt-Datei hat kein gültiges Projekt-Format (walls/layers fehlen).");
return;
}
// Browser-Pfad hat keinen echten Dateipfad → kein Lock für die neue
// Datei, aber ein evtl. vorher (unter Tauri) gehaltener Lock wird frei.
releaseCurrentProjectLock();
setProject(parsed as Project);
} catch (err) {
console.error("Projekt-Import fehlgeschlagen:", err);
}
};
r.readAsText(file);
};
// Bestätigte Import-Entscheidung ausführen: ggf. neue Zeichnung anlegen, die
// 2D-Konturen als Drawing2D anhängen, optional die Meshes als Kontext
// übernehmen. Reihenfolge: erst Ebene (echte ID), dann Drawings/Kontext.
const runImport = (decision: ImportDecision) => {
const levelId =
decision.target.kind === "new"
? addDrawingLevel(decision.target.name)
: decision.target.levelId;
let drawings = buildDrawings(
decision,
levelId,
project,
activeCategoryCode,
);
// Platzierung „viewCenter": den Gesamt-Umriss so verschieben, dass seine
// Mitte auf die Mitte des aktuellen Ausschnitts fällt (sonst bleibt alles an
// den DXF-Koordinaten relativ zum Nullpunkt).
if (decision.placement === "viewCenter" && drawings.length) {
const center = planViewRef.current?.viewCenterModel();
const bb = drawingsBBox(drawings);
if (center && bb) {
const dx = center.x - (bb.minX + bb.maxX) / 2;
const dy = center.y - (bb.minY + bb.maxY) / 2;
drawings = drawings.map((d) => translateDrawing(d, dx, dy));
}
}
if (drawings.length) importDrawings(drawings);
if (decision.importMeshes && decision.parsed.meshes.length) {
addContextObjects(decision.parsed.meshes);
}
// Auf die Ziel-Ebene wechseln, damit die importierte Geometrie sichtbar ist.
setActiveLevelId(levelId);
setImportState(null);
};
// ── Host-Context für die Panels ───────────────────────────────────────────
// Alles außer dem (dock-spezifischen) Darstellungsmodus ist gemeinsam; den
// Modus hängt jedes Dock weiter unten passend zu seinem aktiven Tab an.
+365
View File
@@ -0,0 +1,365 @@
// Bündelt Projektdatei-I/O (Speichern/Öffnen/Schreib-Lock der .obp/.json-
// Datei), den Schnellexport (CSV/IFC/OBJ/STL) sowie die Ausführung eines
// bestätigten DXF/DWG-Imports (`runImport`). Nur aus App() herausgezogen, um
// die Funktion zu entlasten — reine Zustands-/Aktions-Bündelung, keine
// Verhaltensänderung.
//
// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { t } from "../i18n";
import { parseDxf } from "../io/dxfParser";
import type { DxfImportResult } from "../io/dxfParser";
import { buildDrawings } from "../ui/ImportDialog";
import type { ImportDecision } from "../ui/ImportDialog";
import { exportScheduleCsv } from "../export/exportSchedule";
import { exportIfcSpf } from "../export/exportIfc";
import { exportObj, exportStl } from "../export/exportMesh";
import type { ExportSaveFormat } from "../ui/ExportSaveDialog";
import { saveTextFile } from "../io/saveFile";
import {
saveProjectFile,
openProjectFileNative,
acquireProjectLock,
releaseProjectLock,
registerLockAutoRelease,
isTauriRuntime,
} from "../io/projectFile";
import type { LockInfo } from "../io/projectFile";
import type { ContextObject, Drawing2D, Project, Vec2 } from "../model/types";
import type { PlanViewHandle } from "../plan/PlanView";
/** Umriss (bbox) aller Drawing2D-Geometrien in Modell-Metern (null = leer). */
function drawingsBBox(
drawings: Drawing2D[],
): { minX: number; minY: number; maxX: number; maxY: number } | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const acc = (x: number, y: number) => {
minX = Math.min(minX, x); minY = Math.min(minY, y);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
};
for (const d of drawings) {
const g = d.geom;
if (g.shape === "line") { acc(g.a.x, g.a.y); acc(g.b.x, g.b.y); }
else if (g.shape === "polyline") for (const p of g.pts) acc(p.x, p.y);
else if (g.shape === "rect") { acc(g.min.x, g.min.y); acc(g.max.x, g.max.y); }
else if (g.shape === "circle" || g.shape === "arc") {
acc(g.center.x - g.r, g.center.y - g.r);
acc(g.center.x + g.r, g.center.y + g.r);
} else if (g.shape === "text") acc(g.at.x, g.at.y);
}
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
}
/** Ein Drawing2D um (dx,dy) in Modell-Metern verschieben (alle Formen). */
function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D {
const mv = (p: Vec2): Vec2 => ({ x: p.x + dx, y: p.y + dy });
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": return { ...d, geom: { ...g, center: mv(g.center) } };
case "arc": return { ...d, geom: { ...g, center: mv(g.center) } };
case "text": return { ...d, geom: { ...g, at: mv(g.at) } };
default: return d;
}
}
/** Modaler Import-Dialog-Zustand (DXF/DWG) — dieselbe Form wie in useDialogState. */
export type ImportDialogState = {
fileName: string;
isDwg: boolean;
parsed: DxfImportResult | null;
loading?: boolean;
} | null;
export function useProjectFileActions(
project: Project,
setProject: (next: Project | ((p: Project) => Project)) => void,
setImportState: (next: ImportDialogState) => void,
setExportSaveFormat: (format: ExportSaveFormat | null) => void,
activeCategoryCode: string,
addDrawingLevel: (name: string) => string,
importDrawings: (drawings: Drawing2D[]) => void,
addContextObjects: (objs: ContextObject[]) => void,
setActiveLevelId: (id: string) => void,
planViewRef: RefObject<PlanViewHandle | null>,
) {
// Verstecktes Datei-Input zum Laden eines Projekts (Datei-Burger „Öffnen").
// Wird NUR im Browser-Pfad benutzt (ohne Tauri) — unter Tauri geht „Öffnen"
// über den nativen Dialog (openProjectFileNative), der einen echten Pfad für
// den Lock liefert (File-API im Browser hat keinen echten Dateipfad).
const projectFileInputRef = useRef<HTMLInputElement>(null);
// Pfad + Lock-Status der aktuell offenen .obp/.json-Datei (nur Tauri; sonst
// bleibt beides null/false — Browser-Blob-Pfad hat keinen echten Pfad).
const openedProjectPathRef = useRef<string | null>(null);
const projectLockOwnedRef = useRef(false);
// Konflikt-Zustand: Datei ist in einer anderen Instanz gesperrt → Dialog mit
// „Schreibgeschützt öffnen"/„Trotzdem erzwingen"/„Abbrechen" (kein window.confirm!).
const [lockConflict, setLockConflict] = useState<{
path: string;
content: string;
info: LockInfo | null;
} | null>(null);
// Best-effort Lock-Freigabe beim Fenster-Schliessen (sauberes Timing; der
// OS-Lock fällt bei Prozessende/Absturz ohnehin automatisch weg). No-op ohne Tauri.
useEffect(() => {
registerLockAutoRelease(() => openedProjectPathRef.current);
}, []);
// Eine Datei (Button ODER Drop) öffnet den modalen Import-Dialog. DXF wird als
// Text geparst, DWG als ArrayBuffer über LibreDWG-WASM (lazy, asynchron); beide
// münden in dasselbe {meshes, contours}-Format und denselben Dialog-Fluss.
// Parse-Fehler werden ruhig abgefangen: DWG fällt auf den ODA-Hinweis zurück.
const openImportForFile = (file: File) => {
const isDwg = /\.dwg$/i.test(file.name);
if (isDwg) {
// Async-Zustand: Dialog öffnet sofort und zeigt „DWG wird gelesen …",
// während WASM lädt/parst. Danach echte Zusammenfassung ODER ODA-Hinweis.
setImportState({
fileName: file.name,
isDwg: true,
parsed: null,
loading: true,
});
const r = new FileReader();
r.onload = () => {
const buf = r.result;
if (!(buf instanceof ArrayBuffer)) {
setImportState({
fileName: file.name,
isDwg: true,
parsed: null,
loading: false,
});
return;
}
// Dynamisch importieren, damit LibreDWG-WASM nicht im Initial-Bundle ist.
import("../io/dwgParser")
.then(({ parseDwg }) => parseDwg(buf))
.then((parsed) => {
setImportState({
fileName: file.name,
isDwg: false,
parsed,
loading: false,
});
})
.catch((err) => {
console.error("DWG-Import fehlgeschlagen:", err);
// Fallback: weiterhin DWG-Modus → Dialog zeigt den ODA-Hinweis.
setImportState({
fileName: file.name,
isDwg: true,
parsed: null,
loading: false,
});
});
};
r.readAsArrayBuffer(file);
return;
}
const r = new FileReader();
r.onload = () => {
let parsed: DxfImportResult | null = null;
try {
parsed = parseDxf(String(r.result ?? ""));
} catch (err) {
console.error("DXF-Import fehlgeschlagen:", err);
parsed = { meshes: [], contours: [] };
}
setImportState({ fileName: file.name, isDwg: false, parsed });
};
r.readAsText(file);
};
// Projekt als Datei speichern/laden (Datei-Burger-Menü, TopBar/AppMenu).
// Reines JSON des ProjectSlice-Zustands. Eigene Endung „.obp" (openbureau
// project) statt generischem „.json" — unter Tauri über den nativen
// Speichern-/Öffnen-Dialog mit passendem Datei-Filter (saveProjectFile/
// openProjectFileNative in io/projectFile.ts), im Browser Blob-Download wie
// bisher. Laden parst die Datei und ersetzt das Projekt — nur ein grobes
// Shape-Sanity-Check (walls/layers als Array), damit ein fremdes/kaputtes
// JSON nicht die App zerlegt. Alte „.json"-Projekte bleiben ladbar (Filter
// erlaubt beide Endungen).
const onSaveProject = () => {
const json = JSON.stringify(project, null, 2);
void saveProjectFile(json, project.name);
};
// Gibt den Lock der zuletzt geöffneten Projektdatei frei (falls wir ihn
// halten) und vergisst den Pfad — vor dem Wechsel auf ein anderes Projekt.
const releaseCurrentProjectLock = () => {
if (openedProjectPathRef.current && projectLockOwnedRef.current) {
void releaseProjectLock(openedProjectPathRef.current);
}
openedProjectPathRef.current = null;
projectLockOwnedRef.current = false;
};
// Übernimmt ein per Pfad geöffnetes Projekt (Tauri-Fluss): parst, prüft grob
// die Form, gibt einen evtl. vorher gehaltenen Lock frei und merkt sich Pfad
// + ob WIR den Schreib-Lock für die neue Datei halten.
const applyOpenedProjectFile = (path: string, content: string, lockOwned: boolean) => {
try {
const parsed = JSON.parse(content) as Partial<Project>;
if (!Array.isArray(parsed.walls) || !Array.isArray(parsed.layers)) {
console.warn("Projekt-Datei hat kein gültiges Projekt-Format (walls/layers fehlen).");
return;
}
releaseCurrentProjectLock();
setProject(parsed as Project);
openedProjectPathRef.current = path;
projectLockOwnedRef.current = lockOwned;
} catch (err) {
console.error("Projekt-Import fehlgeschlagen:", err);
}
};
// Öffnen unter Tauri: nativer Dialog (echter Pfad) → Lock versuchen. Klappt
// er → Projekt übernehmen. Schon gesperrt → Konflikt-Dialog (kein
// window.confirm!) mit „Schreibgeschützt öffnen"/„Trotzdem erzwingen"/
// „Abbrechen". Beide „öffnen"-Optionen übernehmen das Projekt ohne eigenen
// Lock (die andere Instanz hält ihn) — „erzwingen" bewusst mit Warn-Hinweis
// im Dialog, „schreibgeschützt" als sichere Standardwahl.
const openProjectViaNativeDialog = async () => {
let opened: Awaited<ReturnType<typeof openProjectFileNative>>;
try {
opened = await openProjectFileNative();
} catch (err) {
console.error(t("file.openFailed"), err);
return;
}
if (!opened) return; // Nutzer hat den Öffnen-Dialog abgebrochen
const lock = await acquireProjectLock(opened.path);
if (lock.ok) {
applyOpenedProjectFile(opened.path, opened.content, true);
} else {
setLockConflict({ path: opened.path, content: opened.content, info: lock.info });
}
};
// Datei speichern: unter Tauri nativer „Speichern unter"-Dialog (Ordner + Name),
// im Browser Blob-Download-Fallback — die gesamte Logik (inkl. WKWebView-
// Härtung: octet-stream-MIME + verzögertes revoke) steckt in saveTextFile.
const downloadTextFile = (
content: string,
filename: string,
mime = "application/octet-stream",
) => {
void saveTextFile(content, filename, mime);
};
const projectFileName = () => project.name || "modell";
// Führt den gewählten Direkt-Export (CSV/IFC/OBJ/STL) mit dem im Dialog
// gewählten Dateinamen aus. Erzeugung aus dem jeweiligen reinen Kern, Download
// über den robusten Helfer.
const runExport = (format: ExportSaveFormat, filename: string) => {
switch (format) {
case "csv":
downloadTextFile("" + exportScheduleCsv(project), filename, "text/csv;charset=utf-8;");
break;
case "ifc":
downloadTextFile(exportIfcSpf(project), filename);
break;
case "obj":
downloadTextFile(exportObj(project), filename);
break;
case "stl":
downloadTextFile(exportStl(project), filename);
break;
}
};
// Schnellexport aus dem Topbar-Menü öffnet den Format-/Dateiname-Dialog
// (vorausgewähltes Format). PDF/DXF haben eigene Options-Dialoge.
const onExportSchedule = () => setExportSaveFormat("csv");
const onExportIfc = () => setExportSaveFormat("ifc");
const onExportObj = () => setExportSaveFormat("obj");
const onExportStl = () => setExportSaveFormat("stl");
// Unter Tauri: nativer Öffnen-Dialog (echter Pfad → Lock möglich). Im
// Browser: verstecktes <input type=file> wie bisher (kein echter Pfad, also
// ohne Lock — File-API im Browser liefert keinen Dateisystempfad).
const onOpenProject = () => {
if (isTauriRuntime()) {
void openProjectViaNativeDialog();
} else {
projectFileInputRef.current?.click();
}
};
const onProjectFileChosen = (file: File) => {
const r = new FileReader();
r.onload = () => {
try {
const parsed = JSON.parse(String(r.result ?? "")) as Partial<Project>;
if (!Array.isArray(parsed.walls) || !Array.isArray(parsed.layers)) {
console.warn("Projekt-Datei hat kein gültiges Projekt-Format (walls/layers fehlen).");
return;
}
// Browser-Pfad hat keinen echten Dateipfad → kein Lock für die neue
// Datei, aber ein evtl. vorher (unter Tauri) gehaltener Lock wird frei.
releaseCurrentProjectLock();
setProject(parsed as Project);
} catch (err) {
console.error("Projekt-Import fehlgeschlagen:", err);
}
};
r.readAsText(file);
};
// Bestätigte Import-Entscheidung ausführen: ggf. neue Zeichnung anlegen, die
// 2D-Konturen als Drawing2D anhängen, optional die Meshes als Kontext
// übernehmen. Reihenfolge: erst Ebene (echte ID), dann Drawings/Kontext.
const runImport = (decision: ImportDecision) => {
const levelId =
decision.target.kind === "new"
? addDrawingLevel(decision.target.name)
: decision.target.levelId;
let drawings = buildDrawings(
decision,
levelId,
project,
activeCategoryCode,
);
// Platzierung „viewCenter": den Gesamt-Umriss so verschieben, dass seine
// Mitte auf die Mitte des aktuellen Ausschnitts fällt (sonst bleibt alles an
// den DXF-Koordinaten relativ zum Nullpunkt).
if (decision.placement === "viewCenter" && drawings.length) {
const center = planViewRef.current?.viewCenterModel();
const bb = drawingsBBox(drawings);
if (center && bb) {
const dx = center.x - (bb.minX + bb.maxX) / 2;
const dy = center.y - (bb.minY + bb.maxY) / 2;
drawings = drawings.map((d) => translateDrawing(d, dx, dy));
}
}
if (drawings.length) importDrawings(drawings);
if (decision.importMeshes && decision.parsed.meshes.length) {
addContextObjects(decision.parsed.meshes);
}
// Auf die Ziel-Ebene wechseln, damit die importierte Geometrie sichtbar ist.
setActiveLevelId(levelId);
setImportState(null);
};
return {
projectFileInputRef,
openedProjectPathRef,
projectLockOwnedRef,
lockConflict,
setLockConflict,
openImportForFile,
onSaveProject,
releaseCurrentProjectLock,
applyOpenedProjectFile,
openProjectViaNativeDialog,
downloadTextFile,
projectFileName,
runExport,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
onOpenProject,
onProjectFileChosen,
runImport,
};
}