diff --git a/src/App.tsx b/src/App.tsx index 83df1f6..02600a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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(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(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; - 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>; - 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 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; - 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. diff --git a/src/state/useProjectFileActions.ts b/src/state/useProjectFileActions.ts new file mode 100644 index 0000000..a6445c2 --- /dev/null +++ b/src/state/useProjectFileActions.ts @@ -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, +) { + // 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(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(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; + 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>; + 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 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; + 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, + }; +}