15 Commits

Author SHA1 Message Date
karim f2b5740d89 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.
2026-07-31 17:03:41 +02:00
karim 63dba0592c Selbst-Update-Funktion ergänzt (Tauri Updater-Plugin) + App-Branding
"dossier" statt "Dossier".

Prüft beim Start still gegen ein Gitea-Release-Manifest (latest.json),
bietet im Settings-/About-Dialog einen manuellen Check sowie einen
Versionsverlauf mit Rollback-Download (versions.json, eigener schlanker
Index neben dem Updater-Manifest). Dafür: tauri-plugin-updater/process/
http/shell + zugehörige Capabilities, neue App-Icons (auch für die
plattformspezifischen Bundle-Formate), sowie ein kleingeschriebener
Produktname samt erweitertem nativen Info-Fenster (macOS-About).
2026-07-31 17:03:23 +02:00
karim be7a47c785 TopBar: In-App-Menüleiste unter Tauri ausgeblendet — die native macOS-
Systemmenüleiste (useNativeAppMenu) deckt dieselben Aktionen bereits ab,
die eigene Leiste wäre dort nur Dopplung. Im Browser/Electron ohne
natives App-Menü bleibt sie weiterhin die einzige Quelle.
2026-07-31 17:01:18 +02:00
karim 327b7d3148 Zeichenwerkzeug-Zifferntasten: 1 (Text) und 3 (Kreis) fehlten in der
Shortcut-Map, obwohl beide Befehle längst fertig implementiert waren.

Zusätzlich musste man nach jedem gesetzten Punkt Esc drücken, bevor eine
Zifferntaste das nächste Werkzeug wählte — der Fokus blieb im Befehlsfeld
hängen (dort wird nach Abschluss nicht mehr geblurred) und die globale
Kürzel-Prüfung ignorierte jede Eingabe im Feld pauschal. Jetzt gibt die
Engine den Fokus frei, sobald ein Befehl fertig ist (Commit/Abbruch), und
eine Zifferntaste wählt direkt das nächste Werkzeug (Vectorworks-Stil) —
aber nur, wenn das Feld leer ist UND der aktuelle Schritt weder Tab-Felder
noch Freitext erwartet (Textlabel-Eingabe wie „Raum 101" bleibt geschützt).
2026-07-31 17:00:57 +02:00
karim b56095da80 Dach/Wand: Giebel-Endfüllung (dünne Einzelfläche) nicht mehr emittiert — lag
deckungsgleich vor der ohnehin bis zur Dach-Unterkante hochgeklemmten
Giebelwand und wirkte als sichtbare Doppel-Schicht. Zusätzlich werden
aufeinanderfolgende Wandstücke gleicher geklemmter Oberkante (z. B. am First)
wieder zu einem Body verschmolzen statt in viele 0.15-m-Fragmente zu zerfallen.
2026-07-31 17:00:09 +02:00
karim 89c444451c 3D-Schnitt: Kanten dünner Bauteile (Zwischenwände, Giebel-Enden, einzelne
Materialschichten) werden jetzt zusätzlich zum aktiven Stil erzwungen
gezeichnet, wenn ein Schnitt aktiv ist — sonst wirkten sie aus vielen
Blickwinkeln wie ein abgelöstes, schwebendes Element.
2026-07-31 17:00:03 +02:00
karim 3878203c2d Ressourcen-Manager: "+" im nativen Fenster fügte keine neuen Einträge hinzu
"+"-Buttons für Bauteile/Schraffuren/Linienstile/Wand-/Decken-/Dachstile
waren direkt als onClick={onAddX} verdrahtet — React reicht dabei das
Klick-Event als Argument durch. Im In-App-Overlay harmlos (Store-
Funktionen ignorieren Extra-Argumente), aber im nativen Ressourcen-
Fenster läuft jeder Aufruf über die Tauri-Event-Brücke (emitTo), die den
Aufruf als JSON serialisiert — ein React-Event mit zirkulären DOM-
Referenzen lässt sich nicht serialisieren, der Aufruf scheiterte lautlos.
Fix: alle betroffenen Buttons rufen den Handler jetzt explizit ohne
Argument auf. Zusätzlich die bisher stillschweigend verschluckten Fehler
in der Fenster-Sync-Brücke sichtbar gemacht (console.error statt leerem
catch), fürs nächste Mal.
2026-07-22 20:07:31 +02:00
karim 7324625b03 Projekt-Mutationen/Ressourcen-Typ-CRUD aus App.tsx in useProjectActions() extrahiert
Bündelt Projekt-Mutationen (Store-Actions), Wand-/Decken-/Dach-/Tür-/
Fenster-/Treppentyp-CRUD sowie diverse Element-Mutationsaktionen (Teil
der laufenden App.tsx-Verkleinerung).
2026-07-22 20:06:32 +02:00
karim ce933b1926 Pendenzen: fehlendes Kopieren/Einfügen für 3D-Elemente nachtragen
Nutzer-Report: Strg/Cmd+C/V ist nur für 2D-Zeichenelemente verdrahtet,
nicht für Wand/Decke/Dach/Öffnung/Treppe/Raum/Extrusion/Stütze.
2026-07-22 00:42:50 +02:00
karim 3d64970bda Auswahl-Zustand aus App.tsx in useSelectionState() extrahiert
Bündelt die Auswahl-Mengen aller Bauteil-/Zeichnungs-Arten (Wand, 2D-
Zeichnung, Decke, Dach, Öffnung, Treppe, Raum, Extrusion, Stütze,
Kontext-Objekt) plus abgeleitete Einzel-Ids, die 2D-Zwischenablage und
den 3D-Live-Schnitt-Treiber (Teil der laufenden App.tsx-Verkleinerung).
2026-07-22 00:42:07 +02:00
karim 6a306182f1 Zeichenwerkzeug-Zustand aus App.tsx in useDrawingToolState() extrahiert
Bündelt aktives Werkzeug, Wandtyp/Linienstil, Snap-Einstellungen,
Werkzeug-Zustand-Ref, Draft-Vorschau, aktive Transformation
(Bewegen/Spiegeln/Drehen) und die Kopienanzahl für Array/Distribute
(Teil der laufenden App.tsx-Verkleinerung).
2026-07-22 00:35:12 +02:00
karim bb8d577608 2D-Massstab: dpi-Berechnung fälschlich mit devicePixelRatio multipliziert
CSS-Pixel sind laut Spezifikation physisch konstant 1/96 Zoll, unabhängig
von devicePixelRatio (der beschreibt nur die Geräte-Pixel-Dichte hinter
einem CSS-Pixel, relevant für Rasterungs-Schärfe, nicht für die physische
Grösse). Die Papier-Massstab-Umrechnung in PlanView.tsx nahm bisher
96 * devicePixelRatio an und verfälschte dadurch den angezeigten wie
gemessenen Massstab auf jedem Retina-/HiDPI-Bildschirm um genau den
Faktor devicePixelRatio (per Lineal am Bildschirm nachgemessen). Betrifft
nur die Bildschirmanzeige, nicht den PDF/DXF-Export (eigene Berechnung).
2026-07-22 00:30:57 +02:00
karim 1acf27548f Oberleisten-/Status-Zustand aus App.tsx in useTopBarState() extrahiert
Bündelt Detailstufe, 3D-Rendermodus, Sichtfeld, Boden-Referenzraster,
Plan-Farbmodus, Achslinien, Linienmodus, Fadenkreuz-/Snap-Farbe,
Papier-Massstab, Live-Zoom, Cursor und Ausschnitt-Auswahl (Teil der
laufenden App.tsx-Verkleinerung).
2026-07-22 00:28:34 +02:00
karim 34aab597a1 Zahnrad-Icons in Zeichnungsebenen/Ebenen öffnen jetzt die passenden Fenster
Bisher öffneten beide Panel-Kopfzeilen die allgemeinen Einstellungen statt
ihrer eigenen: Zeichnungsebenen jetzt den Zeichnungsebenen-Fenster-Manager,
Ebenen die Ebeneneinstellungen der aktiven Kategorie.
2026-07-21 19:51:03 +02:00
karim f00e82af2e Kontextmenü-/Editor-State aus App.tsx in useContextMenuState() extrahiert
Bündelt layerClipboard, menu, editor, nativeLayerSettingsCode,
stampEditorRoomId und editTextId in einem eigenen Hook (Teil der
laufenden App.tsx-Verkleinerung).
2026-07-21 19:31:19 +02:00
59 changed files with 2921 additions and 960 deletions
+2
View File
@@ -76,6 +76,8 @@
## ⏭️ Als Nächstes ## ⏭️ Als Nächstes
- [ ] **Kopieren/Einfügen (Strg/Cmd+C/V) fehlt für 3D-Elemente** (Nutzer-Report 2026-07-22). Der Mechanismus in `App.tsx` (Zeile ~1945–1978) ist NUR für 2D-Zeichenelemente (`drawingClipboard`/`selectedDrawingIds`) verdrahtet. Wand/Decke/Dach/Öffnung/Treppe/Raum/Extrusion/Stütze haben keine eigene Zwischenablage — bei Kopieren mit einer 3D-Auswahl passiert nichts (kein `preventDefault`, `ids.size===0`→return), Einfügen benutzt trotzdem den ALTEN Inhalt von `drawingClipboard` aus einer früheren 2D-Kopie → wirkt wie „kopiert nix, fügt das letzte 2D-Element ein". Kein Regressions-Bug, bestand schon vor der App.tsx-Verkleinerungs-Serie dieser Session. **Scope beim Umsetzen:** analog zum 2D-Mechanismus (tiefe Kopie der gewählten Elemente in eine Zwischenablage, Einfügen mit neuen IDs auf aktivem Geschoss) für jede der acht Bauteil-Arten je einzeln nötig — unterschiedliche Datenformen (z. B. Wand braucht neue Endpunkte/Verbindungslogik, nicht nur eine ID-Kopie wie ein Drawing2D).
- [ ] **BIM-Elemente „wirklich 1:1" (Tür/Fenster/Dach/Decke)** — volle Studie + Priorisierung: **[docs/design/bim-elements-depth-study.md](docs/design/bim-elements-depth-study.md)** (623 Zeilen, Referenzmatrix VW/ArchiCAD/Revit/Allplan gegen IST). ✅ **Bereits erledigt 2026-07-10:** Fenster-Schachtelung Blendrahmen→Flügelrahmen→Glas + fest/öffenbar + Einbaulage 2D (`9a38636`); Mansard-Untertypen Giebel/Walm/Zelt + Knick (`bfb80b3`); Dach-Dicke 3D (`e99bb24`) + **Dach-Schichtlogik RoofType/Layer[]** (`3a986ec`); zweiflügelige Türen leafCount 2D+3D (`4319e12`); getrennter Traufe/Ortgang-Überstand (`c9baff5`); Glas/glazingPanes 2D+3D + Rollladenkasten (`2e13ec3`/`d131683`). **Offen (nach Doc-Priorität):** - [ ] **BIM-Elemente „wirklich 1:1" (Tür/Fenster/Dach/Decke)** — volle Studie + Priorisierung: **[docs/design/bim-elements-depth-study.md](docs/design/bim-elements-depth-study.md)** (623 Zeilen, Referenzmatrix VW/ArchiCAD/Revit/Allplan gegen IST). ✅ **Bereits erledigt 2026-07-10:** Fenster-Schachtelung Blendrahmen→Flügelrahmen→Glas + fest/öffenbar + Einbaulage 2D (`9a38636`); Mansard-Untertypen Giebel/Walm/Zelt + Knick (`bfb80b3`); Dach-Dicke 3D (`e99bb24`) + **Dach-Schichtlogik RoofType/Layer[]** (`3a986ec`); zweiflügelige Türen leafCount 2D+3D (`4319e12`); getrennter Traufe/Ortgang-Überstand (`c9baff5`); Glas/glazingPanes 2D+3D + Rollladenkasten (`2e13ec3`/`d131683`). **Offen (nach Doc-Priorität):**
- [x] ~~**Decken-Aussparungen**~~ — **erledigt 2026-07-10 (`37f4fe8`+`cdbef99`+`aee8846`+`83dcdd0`):** `Ceiling.openings?: Vec2[][]` + **Brücken-Trick** (`mergeHoles` in `src/geometry/polygonHoles.ts`: Löcher über schmale Brücken in den Aussenring → EIN einfacher Ring, paritätskorrekt für SVG/Hatch/Ear-Clipping/Pick — KEIN Loch-Support in den Primitiven nötig, kein Rust-Change). 2D-Poché-Loch (Brückenkanten via noStrokeEdges) + 3D-Slab-Loch; Befehl „Deckenloch" (Aliase deckenloch/aussparung, BIM-Ribbon); Panel listet Aussparungen mit Entfernen. +11 Tests. - [x] ~~**Decken-Aussparungen**~~ — **erledigt 2026-07-10 (`37f4fe8`+`cdbef99`+`aee8846`+`83dcdd0`):** `Ceiling.openings?: Vec2[][]` + **Brücken-Trick** (`mergeHoles` in `src/geometry/polygonHoles.ts`: Löcher über schmale Brücken in den Aussenring → EIN einfacher Ring, paritätskorrekt für SVG/Hatch/Ear-Clipping/Pick — KEIN Loch-Support in den Primitiven nötig, kein Rust-Change). 2D-Poché-Loch (Brückenkanten via noStrokeEdges) + 3D-Slab-Loch; Befehl „Deckenloch" (Aliase deckenloch/aussparung, BIM-Ribbon); Panel listet Aussparungen mit Entfernen. +11 Tests.
- [x] ~~**Dach in 2D-Aufsicht + Vertikalschnitt**~~ — **erledigt 2026-07-10 (`9a00c8e`+`f248e2a`):** Grundriss-Aufsicht trägt die Ansichts-Schraffur (viewHatchId) der Eindeckungs-Schicht; Vertikalschnitt via `appendRoofSections` (analytischer TS-Schnitt: Cyrus–Beck gegen die Aufsichts-Polygone, lineare Oberkante, Dicke/cos(Neigung), Schicht-Bänder aussen→innen mit Bauteil-Schraffur). **Offen:** Ansichts-/Silhouettenkanten des Dachs in Elevationen (nur Schnitt-Polygone bisher). - [x] ~~**Dach in 2D-Aufsicht + Vertikalschnitt**~~ — **erledigt 2026-07-10 (`9a00c8e`+`f248e2a`):** Grundriss-Aufsicht trägt die Ansichts-Schraffur (viewHatchId) der Eindeckungs-Schicht; Vertikalschnitt via `appendRoofSections` (analytischer TS-Schnitt: Cyrus–Beck gegen die Aufsichts-Polygone, lineare Oberkante, Dicke/cos(Neigung), Schicht-Bänder aussen→innen mit Bauteil-Schraffur). **Offen:** Ansichts-/Silhouettenkanten des Dachs in Elevationen (nur Schnitt-Polygone bisher).
+6 -4
View File
@@ -98,10 +98,12 @@ screenWidth_mm = canvasWidthCssPx * 25.4 / dpi
N (1:N) = frustumWidth_mm / screenWidth_mm N (1:N) = frustumWidth_mm / screenWidth_mm
``` ```
- **Nur bei Orthografie** sinnvoll; in Perspektive zeigt die UI „—" (wie DOSSIER). - **Nur bei Orthografie** sinnvoll; in Perspektive zeigt die UI „—" (wie DOSSIER).
- **DPI:** Browser kennt das nativ — `dpi = 96 * window.devicePixelRatio` (CSS - **DPI:** fest `dpi = 96` (CSS definiert 1 CSS-px = 1/96 inch, UNABHÄNGIG von
definiert 1 px = 1/96 inch). Das ersetzt DOSSIERs CoreGraphics-JXA-Detection `window.devicePixelRatio` — der Browser hält CSS-px physisch konstant, egal
komplett und ist exakter. Optional manuell kalibrierbar (Eingabe in den wie viele Geräte-px auf Retina/HiDPI dahinterstecken; `devicePixelRatio` mit
Settings), persistiert pro Projekt. hineinzumultiplizieren verdoppelt/halbiert den Massstab auf jedem Retina-
Bildschirm, per Lineal nachgemessen). Optional manuell kalibrierbar (Eingabe
in den Settings), persistiert pro Projekt — bisher nicht gebaut.
- **Massstab setzen** (1:N → Zoom): `frustumWidth_world = screenWidth_mm · N / - **Massstab setzen** (1:N → Zoom): `frustumWidth_world = screenWidth_mm · N /
1000`; bei `THREE.OrthographicCamera` `camera.zoom = canvasWidthCssPx / 1000`; bei `THREE.OrthographicCamera` `camera.zoom = canvasWidthCssPx /
(frustumWidth_world / metersPerPixelAtZoom1)` bzw. direkt `left/right` setzen. (frustumWidth_world / metersPerPixelAtZoom1)` bzw. direkt `left/right` setzen.
+43 -2
View File
@@ -1,17 +1,22 @@
{ {
"name": "cad", "name": "dossier",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cad", "name": "dossier",
"version": "0.0.0", "version": "0.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@mlightcad/libredwg-web": "^0.7.7", "@mlightcad/libredwg-web": "^0.7.7",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-updater": "^2.10.1",
"delaunator": "^5.1.0", "delaunator": "^5.1.0",
"dxf-parser": "^1.1.2", "dxf-parser": "^1.1.2",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
@@ -1862,6 +1867,42 @@
"@tauri-apps/api": "^2.11.0" "@tauri-apps/api": "^2.11.0"
} }
}, },
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
"integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-process": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
"integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tweenjs/tween.js": { "node_modules/@tweenjs/tween.js": {
"version": "23.1.3", "version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+4
View File
@@ -29,6 +29,10 @@
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-updater": "^2.10.1",
"delaunator": "^5.1.0", "delaunator": "^5.1.0",
"dxf-parser": "^1.1.2", "dxf-parser": "^1.1.2",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
+778 -2
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -39,6 +39,10 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-http = "2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
geometry = { path = "geometry" } geometry = { path = "geometry" }
+11 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region). Menu: fuer die native macOS-Systemmenueleiste (src/native/appMenu.ts). Webview/Event: fuer die nativen Ressourcen-/Einstellungs-/Zeichnungsebenen-/Ebeneneinstellungs-Fenster + ihre Sync-Bruecken (src/native/*Window.ts) - gilt fuer alle Fensterlabels, da jedes Events senden/empfangen muss.", "description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region). Menu: fuer die native macOS-Systemmenueleiste (src/native/appMenu.ts). Webview/Event: fuer die nativen Ressourcen-/Einstellungs-/Zeichnungsebenen-/Ebeneneinstellungs-Fenster + ihre Sync-Bruecken (src/native/*Window.ts) - gilt fuer alle Fensterlabels, da jedes Events senden/empfangen muss. Dialog/Fs: nativer Oeffnen- UND Speichern-Dialog fuer Projektdateien (.obp/.json) plus Lesen/Schreiben der gewaehlten Datei (io/projectFile.ts, io/saveFile.ts) - open+read fuer Projekt laden, save+write fuer Projekt/Export speichern. Updater/Process: Selbst-Update-Check beim Start (src/update/checkForUpdate.ts) + Neustart nach Installation. Http: Versionsverlauf (versions.json vom Gitea-Release) ohne Browser-CORS, Scope auf den eigenen Gitea-Host begrenzt. Shell:allow-open: oeffnet den DMG-Download einer aelteren Version im System-Browser (Rollback).",
"windows": ["main", "resources", "settings", "drawing-levels", "layer-settings", "context-import"], "windows": ["main", "resources", "settings", "drawing-levels", "layer-settings", "context-import"],
"permissions": [ "permissions": [
"core:default", "core:default",
@@ -16,7 +16,16 @@
"core:webview:allow-create-webview-window", "core:webview:allow-create-webview-window",
"core:menu:default", "core:menu:default",
"core:event:default", "core:event:default",
"dialog:allow-open",
"dialog:allow-save", "dialog:allow-save",
"fs:allow-write-text-file" "fs:allow-read-text-file",
"fs:allow-write-text-file",
"updater:default",
"process:allow-restart",
{
"identifier": "http:default",
"allow": [{ "url": "https://git.openbureau.ch/*" }]
},
"shell:allow-open"
] ]
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+23 -7
View File
@@ -1711,9 +1711,23 @@ impl Renderer {
multiview_mask: None, multiview_mask: None,
}); });
// Live-Schnitt aktiv? Dann erzwingen wir die Modell-Kanten ZUSAETZLICH
// zum aktuellen Stil (unabhaengig von `draws_edges()`) — Nutzer-Report:
// duenne, schraeg angeschnittene Bauteile (Zwischenwaende, Giebel-Enden,
// einzelne Material-Schichten) haben aus vielen Blickwinkeln eine fast
// unsichtbare (kantige) Stirnflaeche, waehrend ihre (flache) Schnitt-
// kappe voll sichtbar bleibt — das wirkt wie ein abgeloestes,
// "schwebendes" Element. Die dunklen Kanten (bereits vorhanden fuer
// wireframe/hidden/shaded-edges, `edges::build_mesh_edges`, tiefen-
// getestet + von der Schnittebene gekappt wie das Mesh selbst) zeichnen
// jede Bauteilkante nach, auch wenn ihre Flaeche praktisch auf Null
// Bildschirmbreite zusammenfaellt — das verbindet Kappe und Bauteil
// sichtbar, ohne die Kamera zu bewegen oder Geometrie zu aendern.
let force_edges_for_section = self.globals.mode[1] > 0.5;
// 1) Gefuellte Flaechen — in allen Stilen ausser "wireframe". Im // 1) Gefuellte Flaechen — in allen Stilen ausser "wireframe". Im
// Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit // Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit
// die folgenden Kanten sauber obenauf liegen. // die folgenden Kanten sauber obenauf liegen. Bei aktivem Schnitt
// ebenso (die Kanten werden dann erzwungen, s.o.).
if self.style.draws_faces() { if self.style.draws_faces() {
if self.style == RenderStyle::Textured { if self.style == RenderStyle::Textured {
// Texturierter Pfad: eigene Pipeline + zweiter Vertex-Puffer // Texturierter Pfad: eigene Pipeline + zweiter Vertex-Puffer
@@ -1730,7 +1744,9 @@ impl Renderer {
pass.draw_indexed(0..t.index_count, 0, 0..1); pass.draw_indexed(0..t.index_count, 0, 0..1);
} }
} else if let Some(m) = &self.mesh { } else if let Some(m) = &self.mesh {
let face_pipeline = if self.style.needs_biased_face_pipeline() { let face_pipeline = if self.style.needs_biased_face_pipeline()
|| force_edges_for_section
{
&self.hidden_face_pipeline &self.hidden_face_pipeline
} else { } else {
&self.pipeline &self.pipeline
@@ -1798,11 +1814,11 @@ impl Renderer {
pass.draw(0..cl.vertex_count, 0..1); pass.draw(0..cl.vertex_count, 0..1);
} }
// 2) Modell-Kanten (wireframe/hidden) — LineList-Pipeline (wie Grid). // 2) Modell-Kanten (wireframe/hidden, ODER erzwungen bei aktivem
// Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten) // Schnitt, s.o.) — LineList-Pipeline (wie Grid). Tiefengetestet
// Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne // gegen die (gebiasten) Flaechen -> verdeckte Kanten fallen weg.
// Flaechen -> alle Kanten sichtbar. // Im Wireframe ohne Flaechen -> alle Kanten sichtbar.
if self.style.draws_edges() { if self.style.draws_edges() || force_edges_for_section {
if let Some(e) = &self.edges { if let Some(e) = &self.edges {
pass.set_pipeline(&self.grid_pipeline); pass.set_pipeline(&self.grid_pipeline);
pass.set_bind_group(0, &self.bind_group, &[]); pass.set_bind_group(0, &self.bind_group, &[]);
+12
View File
@@ -72,6 +72,18 @@ pub fn run() {
// im Tauri-Fenster einen echten Save-Dialog zeigen statt still zu laden. // im Tauri-Fenster einen echten Save-Dialog zeigen statt still zu laden.
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
// Selbst-Update: prueft beim Start gegen `endpoints` aus tauri.conf.json
// (Gitea-Release-Asset `latest.json`), Installation via JS-Seite
// (@tauri-apps/plugin-updater). `tauri_plugin_process` liefert `relaunch()`
// fuer den Neustart nach der Installation.
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
// HTTP-Requests aus der Webview (Versionsverlauf, versions.json vom
// selben Gitea-Release) ohne Browser-CORS-Einschraenkung; Shell: oeffnet
// den DMG-Download einer aelteren Version im System-Browser (Rollback,
// s. src/update/checkForUpdate.ts).
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_shell::init())
// Haelt die pro Instanz offen gelockten Projektdatei-Handles (lock.rs). // Haelt die pro Instanz offen gelockten Projektdatei-Handles (lock.rs).
.manage(lock::LockState::default()) .manage(lock::LockState::default())
.setup(|_app| { .setup(|_app| {
+18 -3
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Dossier", "productName": "dossier",
"version": "0.1.0", "version": "0.1.0",
"identifier": "ch.dossier.cad", "identifier": "ch.dossier.cad",
"build": { "build": {
@@ -11,7 +11,7 @@
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
"title": "Dossier", "title": "dossier",
"width": 1400, "width": 1400,
"height": 900, "height": 900,
"dragDropEnabled": false, "dragDropEnabled": false,
@@ -27,6 +27,21 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"icon": ["icons/icon.png"] "createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYyRDk3NkZGODQwRUJBNTQKUldSVXVnNkUvM2JaOHV2QWp0L05qZnpEUmt2TkN3ZWJBeFQwb1I0cExWNEpuZWU0UjZiazdQNU0K",
"endpoints": [
"https://git.openbureau.ch/karim/DOSSIER-STANDALONE/releases/download/latest/latest.json"
]
}
} }
} }
+294 -845
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -168,6 +168,17 @@ export class CommandEngine {
return this.currentFields().length > 0; return this.currentFields().length > 0;
} }
/**
* Erwartet der aktuelle Schritt freien Text (z. B. das Textwerkzeug-Label)?
* Dort ist JEDE Taste — auch Ziffern — literaler Inhalt, im Unterschied zu
* einem reinen Punkt-Pick-Schritt (nur „point"/„number", keine Felder), wo
* Ziffern ansonsten ungenutzt sind.
*/
acceptsFreeText(): boolean {
if (!this.command || !this.state) return false;
return this.command.accepts(this.state).includes("text");
}
/** /**
* Setzt den Feld-Zustand zurück, wenn der Schritt wechselt — Signatur = * Setzt den Feld-Zustand zurück, wenn der Schritt wechselt — Signatur =
* phase + Feld-IDs + lastPoint. Der lastPoint-Teil sorgt dafür, dass ein * phase + Feld-IDs + lastPoint. Der lastPoint-Teil sorgt dafür, dass ein
+26 -3
View File
@@ -140,7 +140,7 @@ export const de = {
"menu.native.view": "Ansicht", "menu.native.view": "Ansicht",
"menu.native.window": "Fenster", "menu.native.window": "Fenster",
"menu.native.panels": "Panels", "menu.native.panels": "Panels",
"menu.native.about": "Über Dossier", "menu.native.about": "Über dossier",
"menu.native.quit": "Beenden", "menu.native.quit": "Beenden",
// ── Layout-Menü (Topbar) ───────────────────────────────────────────────── // ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
@@ -1318,12 +1318,22 @@ export const de = {
"exportSave.fmt.stl": "STL (3D-Mesh)", "exportSave.fmt.stl": "STL (3D-Mesh)",
"about.title": "Über dossier", "about.title": "Über dossier",
"about.version": "Version", "about.version": "Version",
"about.desc": "Open-Source-CAAD (Computer Aided Architecture Design) — schöne, normgerechte Pläne aus einem semantischen Gebäudemodell. Als Desktop-App (vollständige Fassung) und im Browser zugänglich.", "about.desc": "Open Source Computer Aided Architecture Design — 2D-Vektordesign sowie normgerechte Pläne aus einem semantischen Gebäudemodell.",
"about.copyright": "© 2026 Karim Gabriele Varano", "about.copyright": "© 2026 Karim Gabriele Varano",
"about.license": "Lizenz: AGPL-3.0-or-later", "about.license": "Lizenz: AGPL-3.0-or-later",
"about.suite": "Teil der openbureau-Suite", "about.suite": "Teil von openbureau",
"about.licenses": "Open-Source-Lizenzen (Drittkomponenten)", "about.licenses": "Open-Source-Lizenzen (Drittkomponenten)",
"about.close": "Schließen", "about.close": "Schließen",
"about.checkUpdate": "Nach Updates suchen",
"about.checkingUpdate": "Suche nach Updates …",
"update.title": "Update",
"update.upToDate": "Du hast bereits die aktuellste Version.",
"update.availableBody": "Version {version} ist verfügbar. Jetzt herunterladen und installieren? Die App startet danach neu.",
"update.installNow": "Installieren",
"update.installLater": "Später",
"update.checkFailed": "Update-Prüfung fehlgeschlagen: {error}",
"update.installFailed": "Installation fehlgeschlagen: {error}",
// ── Einstellungs-Fenster ────────────────────────────────────────────────── // ── Einstellungs-Fenster ──────────────────────────────────────────────────
"topbar.settings": "Einstellungen", "topbar.settings": "Einstellungen",
@@ -1344,6 +1354,19 @@ export const de = {
"settings.referenceElevation": "Referenzhöhe EG (m ü. M.)", "settings.referenceElevation": "Referenzhöhe EG (m ü. M.)",
"settings.referenceElevation.hint": "settings.referenceElevation.hint":
"Nur gespeichert — wird noch nicht verwendet. Folgt für Terrain-Draping/reale Höhen relativ zum Erdgeschoss.", "Nur gespeichert — wird noch nicht verwendet. Folgt für Terrain-Draping/reale Höhen relativ zum Erdgeschoss.",
"settings.section.update": "Update",
"settings.update.autoCheck": "Automatisch nach Updates suchen",
"settings.update.snooze.active": "Aktiv",
"settings.update.snooze.1day": "1 Tag pausieren",
"settings.update.snooze.1week": "1 Woche pausieren",
"settings.update.snooze.1month": "1 Monat pausieren",
"settings.update.snoozedUntil": "Pausiert bis {date}. Manuelles Suchen funktioniert weiterhin.",
"settings.update.versionHistory": "Frühere Versionen",
"settings.update.versionHistory.hint":
"Öffnet den Download im Browser — Installation bleibt manuell (ersetzt die aktuelle Version, wie beim ersten Installieren).",
"settings.update.loadingHistory": "Lade Versionsliste …",
"settings.update.noHistory": "Keine früheren Versionen verfügbar.",
"settings.update.download": "Herunterladen",
// ── Drag & Drop (App-weiter Datei-Drop) ────────────────────────────────── // ── Drag & Drop (App-weiter Datei-Drop) ──────────────────────────────────
"drop.hint": "DXF/DWG-Datei hier ablegen", "drop.hint": "DXF/DWG-Datei hier ablegen",
+26 -3
View File
@@ -138,7 +138,7 @@ export const en: Record<TranslationKey, string> = {
"menu.native.view": "View", "menu.native.view": "View",
"menu.native.window": "Window", "menu.native.window": "Window",
"menu.native.panels": "Panels", "menu.native.panels": "Panels",
"menu.native.about": "About Dossier", "menu.native.about": "About dossier",
"menu.native.quit": "Quit", "menu.native.quit": "Quit",
// Layout menu. // Layout menu.
@@ -1305,12 +1305,22 @@ export const en: Record<TranslationKey, string> = {
"exportSave.fmt.stl": "STL (3D mesh)", "exportSave.fmt.stl": "STL (3D mesh)",
"about.title": "About dossier", "about.title": "About dossier",
"about.version": "Version", "about.version": "Version",
"about.desc": "Open-source CAAD (Computer Aided Architecture Design) — beautiful, standard-compliant drawings from a semantic building model. Available as a desktop app (full version) and in the browser.", "about.desc": "Open Source Computer Aided Architecture Design — 2D vector design plus standards-compliant drawings from a semantic building model.",
"about.copyright": "© 2026 Karim Gabriele Varano", "about.copyright": "© 2026 Karim Gabriele Varano",
"about.license": "License: AGPL-3.0-or-later", "about.license": "License: AGPL-3.0-or-later",
"about.suite": "Part of the openbureau suite", "about.suite": "Part of openbureau",
"about.licenses": "Open-source licenses (third-party)", "about.licenses": "Open-source licenses (third-party)",
"about.close": "Close", "about.close": "Close",
"about.checkUpdate": "Check for updates",
"about.checkingUpdate": "Checking for updates …",
"update.title": "Update",
"update.upToDate": "You already have the latest version.",
"update.availableBody": "Version {version} is available. Download and install now? The app will restart afterwards.",
"update.installNow": "Install",
"update.installLater": "Later",
"update.checkFailed": "Update check failed: {error}",
"update.installFailed": "Installation failed: {error}",
// Settings dialog. // Settings dialog.
"topbar.settings": "Settings", "topbar.settings": "Settings",
@@ -1331,6 +1341,19 @@ export const en: Record<TranslationKey, string> = {
"settings.referenceElevation": "Ground floor reference elevation (m a.s.l.)", "settings.referenceElevation": "Ground floor reference elevation (m a.s.l.)",
"settings.referenceElevation.hint": "settings.referenceElevation.hint":
"Stored only — not used yet. Terrain draping / real-world elevations relative to the ground floor will follow.", "Stored only — not used yet. Terrain draping / real-world elevations relative to the ground floor will follow.",
"settings.section.update": "Update",
"settings.update.autoCheck": "Check for updates automatically",
"settings.update.snooze.active": "Active",
"settings.update.snooze.1day": "Pause for 1 day",
"settings.update.snooze.1week": "Pause for 1 week",
"settings.update.snooze.1month": "Pause for 1 month",
"settings.update.snoozedUntil": "Paused until {date}. Manual checks still work.",
"settings.update.versionHistory": "Earlier versions",
"settings.update.versionHistory.hint":
"Opens the download in your browser — installation stays manual (replaces the current version, same as the first install).",
"settings.update.loadingHistory": "Loading version list …",
"settings.update.noHistory": "No earlier versions available.",
"settings.update.download": "Download",
// Drag & drop (app-wide file drop). // Drag & drop (app-wide file drop).
"drop.hint": "Drop a DXF/DWG file here", "drop.hint": "Drop a DXF/DWG file here",
+6
View File
@@ -22,6 +22,12 @@ export type MenuState =
| { kind: "viewport"; wallId: string | null; x: number; y: number } | { kind: "viewport"; wallId: string | null; x: number; y: number }
| null; | null;
/** Offener Inline-Editor (aus einem Menüpunkt). `x/y` = Anker in Viewport-Px. */
export type EditorState =
| { kind: "layer"; code: string; x: number; y: number }
| { kind: "level"; id: string; x: number; y: number }
| null;
/** Titel des Kontextmenüs je Quelle (Ebenenname / Geschossname / Wand/Ansicht). */ /** Titel des Kontextmenüs je Quelle (Ebenenname / Geschossname / Wand/Ansicht). */
export function menuTitle(menu: NonNullable<MenuState>, project: Project): string { export function menuTitle(menu: NonNullable<MenuState>, project: Project): string {
switch (menu.kind) { switch (menu.kind) {
+45
View File
@@ -0,0 +1,45 @@
// Transienter UI-State der Kontextmenüs/Inline-Editoren — bleibt lokal (kein
// Store-Slice: keine Undo/Redo-Relevanz). Bündelt nur die useState-
// Deklarationen; die Reset-Effekte (Geschoss-/Ansichtswechsel) bleiben in
// App(), da sie zusammen mit globalem Auswahl-State aus dem Store laufen.
//
// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useState } from "react";
import type { EditorState, MenuState } from "./contextMenu";
export function useContextMenuState() {
// Eigenschaften-Zwischenablage des Ebenen-Kontextmenüs (Farbe + Linienstärke).
const [layerClipboard, setLayerClipboard] = useState<{
color: string;
lw: number;
} | null>(null);
// Offenes Kontextmenü (genau eines) — Quelle bestimmt die Einträge.
const [menu, setMenu] = useState<MenuState>(null);
// Offener Inline-Editor (Ebene bzw. Geschoss) — aus einem Menüpunkt geöffnet.
const [editor, setEditor] = useState<EditorState>(null);
// Welcher Ebenen-Code aktuell im nativen Ebeneneinstellungs-Fenster offen
// ist (Tauri) — Pendant zu `editor?.kind === "layer"` im Browser-Fallback.
const [nativeLayerSettingsCode, setNativeLayerSettingsCode] = useState<string | null>(
null,
);
// Offener Raum-Stempel-Editor (Rich-Text) — per Doppelklick/Button geöffnet.
const [stampEditorRoomId, setStampEditorRoomId] = useState<string | null>(null);
// Text-Inhalt-Editor: Id des per Doppelklick bearbeiteten Text-2D-Elements.
const [editTextId, setEditTextId] = useState<string | null>(null);
return {
layerClipboard,
setLayerClipboard,
menu,
setMenu,
editor,
setEditor,
nativeLayerSettingsCode,
setNativeLayerSettingsCode,
stampEditorRoomId,
setStampEditorRoomId,
editTextId,
setEditTextId,
};
}
+1 -1
View File
@@ -66,7 +66,7 @@ export function ResourceWindowApp() {
return (...args: unknown[]) => { return (...args: unknown[]) => {
void import("@tauri-apps/api/event").then(({ emitTo }) => void import("@tauri-apps/api/event").then(({ emitTo }) =>
emitTo("main", "resource-command", { name: prop as HandlerName, args }).catch( emitTo("main", "resource-command", { name: prop as HandlerName, args }).catch(
() => {}, (err) => console.error("[resource-window] emit resource-command failed", prop, err),
), ),
); );
}; };
+16 -3
View File
@@ -319,14 +319,27 @@ export function useNativeAppMenu(
// macOS: der allererste Eintrag ist das App-Menü (Über/Beenden). Ohne ihn // macOS: der allererste Eintrag ist das App-Menü (Über/Beenden). Ohne ihn
// rutscht „Datei" an die Stelle, an der macOS sonst den App-Namen zeigt. // rutscht „Datei" an die Stelle, an der macOS sonst den App-Namen zeigt.
// „Über Dossier" nutzt das NATIVE macOS-Info-Fenster (PredefinedMenuItem // „Über dossier" nutzt das NATIVE macOS-Info-Fenster (PredefinedMenuItem
// „About") statt des eigenen In-App-Dialogs (AboutDialog in TopBar.tsx) — // „About") statt des eigenen In-App-Dialogs (AboutDialog in TopBar.tsx) —
// an dieser Stelle erwartet macOS exakt diesen Eintrag, kein Custom-Dialog. // an dieser Stelle erwartet macOS exakt diesen Eintrag, kein Custom-Dialog.
// Metadaten spiegeln den In-App-Dialog: kleingeschriebener Name „dossier",
// Beschreibung + Lizenz/Suite als `credits` (macOS zeigt sie als Textblock
// unter der Version), Copyright unten. Das Icon zieht macOS aus dem Bundle
// (tauri.conf.json bundle.icon → icon.icns). Auf macOS NICHT unterstützt
// und daher weggelassen: authors/comments/license/website.
const appMenu = await Submenu.new({ const appMenu = await Submenu.new({
text: "Dossier", text: "dossier",
items: [ items: [
await PredefinedMenuItem.new({ await PredefinedMenuItem.new({
item: { About: { name: "Dossier", version: APP_VERSION } }, item: {
About: {
name: "dossier",
version: APP_VERSION,
shortVersion: APP_VERSION,
copyright: t("about.copyright"),
credits: `${t("about.desc")}\n\n${t("about.license")} · ${t("about.suite")}`,
},
},
text: t("menu.native.about"), text: t("menu.native.about"),
}), }),
await PredefinedMenuItem.new({ item: "Separator" }), await PredefinedMenuItem.new({ item: "Separator" }),
+1 -1
View File
@@ -41,7 +41,7 @@ export async function openContextImportWindowNative(): Promise<void> {
} }
new WebviewWindow(CONTEXT_IMPORT_WINDOW_LABEL, { new WebviewWindow(CONTEXT_IMPORT_WINDOW_LABEL, {
url: "index.html?window=context-import", url: "index.html?window=context-import",
title: "Standort importieren — Dossier", title: "Standort importieren — dossier",
width: 480, width: 480,
height: 680, height: 680,
minWidth: 400, minWidth: 400,
+1 -1
View File
@@ -51,7 +51,7 @@ export async function openDrawingLevelsWindowNative(): Promise<void> {
} }
new WebviewWindow(DRAWING_LEVELS_WINDOW_LABEL, { new WebviewWindow(DRAWING_LEVELS_WINDOW_LABEL, {
url: "index.html?window=drawing-levels", url: "index.html?window=drawing-levels",
title: "Zeichnungsebenen — Dossier", title: "Zeichnungsebenen — dossier",
width: 340, width: 340,
height: 560, height: 560,
minWidth: 260, minWidth: 260,
+1 -1
View File
@@ -54,7 +54,7 @@ export async function openLayerSettingsWindowNative(code: string): Promise<void>
} }
new WebviewWindow(LAYER_SETTINGS_WINDOW_LABEL, { new WebviewWindow(LAYER_SETTINGS_WINDOW_LABEL, {
url: `index.html?window=layer-settings&code=${encodeURIComponent(code)}`, url: `index.html?window=layer-settings&code=${encodeURIComponent(code)}`,
title: "Ebeneneinstellungen — Dossier", title: "Ebeneneinstellungen — dossier",
width: 360, width: 360,
height: 420, height: 420,
minWidth: 300, minWidth: 300,
+6 -2
View File
@@ -47,7 +47,7 @@ export async function openResourceWindowNative(initialTab?: TabId): Promise<void
"index.html?window=resources" + (initialTab ? `&tab=${encodeURIComponent(initialTab)}` : ""); "index.html?window=resources" + (initialTab ? `&tab=${encodeURIComponent(initialTab)}` : "");
new WebviewWindow(RESOURCE_WINDOW_LABEL, { new WebviewWindow(RESOURCE_WINDOW_LABEL, {
url, url,
title: "Ressourcen — Dossier", title: "Ressourcen — dossier",
width: 780, width: 780,
height: 660, height: 660,
minWidth: 480, minWidth: 480,
@@ -85,7 +85,11 @@ export function useResourceWindowHost(
unlisten.push( unlisten.push(
await listen<{ name: string; args: unknown[] }>("resource-command", (e) => { await listen<{ name: string; args: unknown[] }>("resource-command", (e) => {
const fn = (handlersRef.current as unknown as Record<string, unknown>)[e.payload.name]; const fn = (handlersRef.current as unknown as Record<string, unknown>)[e.payload.name];
if (typeof fn === "function") (fn as (...a: unknown[]) => void)(...e.payload.args); if (typeof fn === "function") {
(fn as (...a: unknown[]) => void)(...e.payload.args);
} else {
console.error("[resource-window-host] unknown resource-command", e.payload.name);
}
}), }),
); );
unlisten.push( unlisten.push(
+1 -1
View File
@@ -52,7 +52,7 @@ export async function openSettingsWindowNative(): Promise<void> {
} }
new WebviewWindow(SETTINGS_WINDOW_LABEL, { new WebviewWindow(SETTINGS_WINDOW_LABEL, {
url: "index.html?window=settings", url: "index.html?window=settings",
title: "Einstellungen — Dossier", title: "Einstellungen — dossier",
width: 520, width: 520,
height: 640, height: 640,
minWidth: 420, minWidth: 420,
+5 -4
View File
@@ -157,15 +157,16 @@ export function DrawingLevelsPanel() {
} }
/** Aktionen für die gemeinsame PanelFrame-Kopfzeile (statt eigener Titelzeile): /** Aktionen für die gemeinsame PanelFrame-Kopfzeile (statt eigener Titelzeile):
* Zahnrad → Einstellungen (Darstellungsfarben/Projekt-Felder). */ * Zahnrad → Zeichnungsebenen-Manager als eigenes natives Fenster öffnen
* (Duplikat desselben Inhalts, additiv zum Dock-Panel). */
export function DrawingLevelsHeaderActions() { export function DrawingLevelsHeaderActions() {
const host = usePanelHost(); const host = usePanelHost();
return ( return (
<button <button
className="nav-settings" className="nav-settings"
aria-label={t("topbar.settings")} aria-label={t("nav.drawingLevelsWindow")}
title={t("topbar.settings")} title={t("nav.drawingLevelsWindow")}
onClick={host.onOpenSettings} onClick={host.onOpenDrawingLevelsWindow}
> >
<span className="material-symbols-outlined" aria-hidden="true"> <span className="material-symbols-outlined" aria-hidden="true">
settings settings
+8 -4
View File
@@ -175,7 +175,9 @@ function CategoryRow({
} }
/** Aktionen für die gemeinsame PanelFrame-Kopfzeile (statt eigener Titelzeile): /** Aktionen für die gemeinsame PanelFrame-Kopfzeile (statt eigener Titelzeile):
* „+ Kategorie" + Zahnrad → Einstellungen (wie im Zeichnungsebenen-Kopf). * „+ Kategorie" + Zahnrad → Ebeneneinstellungen der aktiven Ebene (wie das
* Zahnrad je Zeile, hier für activeCategoryCode; unter Tauri natives Fenster,
* sonst InlineEditor-Popover — siehe onOpenLayerSettings).
* Läuft im PanelHostContext, nutzt daher usePanelHost. */ * Läuft im PanelHostContext, nutzt daher usePanelHost. */
export function LayersHeaderActions() { export function LayersHeaderActions() {
const host = usePanelHost(); const host = usePanelHost();
@@ -183,9 +185,11 @@ export function LayersHeaderActions() {
<> <>
<button <button
className="nav-settings" className="nav-settings"
onClick={host.onOpenSettings} onClick={(e) =>
aria-label={t("topbar.settings")} host.onOpenLayerSettings(host.activeCategoryCode, e.clientX, e.clientY)
title={t("topbar.settings")} }
aria-label={t("ctx.layerSettings")}
title={t("ctx.layerSettings")}
> >
<span className="material-symbols-outlined" aria-hidden="true"> <span className="material-symbols-outlined" aria-hidden="true">
settings settings
+10 -4
View File
@@ -117,13 +117,19 @@ export interface PanelHostValue {
* Löschen) und hält das offene Menü. * Löschen) und hält das offene Menü.
*/ */
onLayerContextMenu: (code: string, clientX: number, clientY: number) => void; onLayerContextMenu: (code: string, clientX: number, clientY: number) => void;
/** Öffnet die Ebeneneinstellungen direkt (Zahnrad-Symbol je Zeile) — unter /** Öffnet die Ebeneneinstellungen direkt (Zahnrad-Symbol je Zeile bzw.
* Tauri als natives Fenster, sonst derselbe InlineEditor-Popover wie über * Panel-Kopfzeile, dort für die aktive Ebene) — unter Tauri als natives
* das Kontextmenü (siehe App.tsx `openLayerSettings`). */ * Fenster, sonst derselbe InlineEditor-Popover wie über das Kontextmenü
* (siehe App.tsx `openLayerSettings`). */
onOpenLayerSettings: (code: string, clientX: number, clientY: number) => void; onOpenLayerSettings: (code: string, clientX: number, clientY: number) => void;
/** Öffnet den Zeichnungsebenen-Manager als eigenes natives Fenster (Zahnrad
* in der Panel-Kopfzeile „Zeichnungsebenen") — Duplikat desselben Inhalts
* wie das Dock-Panel, additiv. No-op außerhalb von Tauri (kein eigenes
* Fenster-Konzept im Browser; das Dock-Panel zeigt dort bereits alles). */
onOpenDrawingLevelsWindow: () => void;
/** Öffnet das Einstellungs-Fenster (Darstellungsfarben, Projekt-Felder) — /** Öffnet das Einstellungs-Fenster (Darstellungsfarben, Projekt-Felder) —
* unter Tauri als natives Fenster, sonst als In-App-Dialog (App.openSettings). * unter Tauri als natives Fenster, sonst als In-App-Dialog (App.openSettings).
* Als Zahnrad in den Panel-Kopfzeilen „Zeichnungsebenen"/„Ebenen". */ * Erreichbar über „Fenster"-Menü (AppMenuBar/native Menüleiste). */
onOpenSettings: () => void; onOpenSettings: () => void;
// Ressourcen-Handler (Bauteile / Schraffuren / Linienstile). Spiegelt // Ressourcen-Handler (Bauteile / Schraffuren / Linienstile). Spiegelt
+12 -6
View File
@@ -78,13 +78,19 @@ function visibleEdgeRuns(scr: Vec2[], noStroke: number[]): Vec2[][] {
/** /**
* Papier-Massstab (docs/design/plans-output.md §3): Der Plan IST Papier-Space. * Papier-Massstab (docs/design/plans-output.md §3): Der Plan IST Papier-Space.
* dpi = 96·devicePixelRatio (CSS definiert 1px = 1/96 inch). Strichstärken sind * dpi = 96 (CSS definiert 1 CSS-px = 1/96 inch, UNABHÄNGIG von devicePixelRatio —
* in mm Papier definiert und werden über diese dpi in konstante Bildschirm-Pixel * das gilt genauso auf Retina/HiDPI-Displays und bei OS-Skalierung, da der
* umgerechnet (zusammen mit vector-effect:non-scaling-stroke bleiben sie beim * Browser CSS-px genau dafür physisch konstant hält, egal wie viele Geräte-px
* Zoomen papierkonstant). Live wird zusätzlich gelesen, falls sich der Monitor * dahinterstecken). Strichstärken sind in mm Papier definiert und werden über
* (devicePixelRatio) ändert. * diese dpi in konstante Bildschirm-Pixel umgerechnet (zusammen mit
* vector-effect:non-scaling-stroke bleiben sie beim Zoomen papierkonstant).
* BUGFIX: früher fälschlich `96 · devicePixelRatio` — das verwechselte die
* Geräte-px-Dichte (für Tessellierung/Rasterung relevant, siehe
* `pxPerMeterForView` weiter unten) mit der physischen CSS-px-Grösse und
* verdoppelte/halbierte den gemessenen Massstab auf jedem Retina-Bildschirm
* (per Lineal am Bildschirm nachgemessen falsch).
*/ */
const dpi = (): number => 96 * (window.devicePixelRatio || 1); const dpi = (): number => 96;
/** mm Papier → Bildschirm-Pixel (für non-scaling-stroke / dasharray). */ /** mm Papier → Bildschirm-Pixel (für non-scaling-stroke / dasharray). */
const mmToPx = (mm: number): number => (mm / 25.4) * dpi(); const mmToPx = (mm: number): number => (mm / 25.4) * dpi();
+2 -2
View File
@@ -108,8 +108,8 @@ const MONO_INK = "#111111";
// ── Linienstärken in PAPIER-Millimeter (vgl. docs/design/plans-output.md §3.2) ── // ── Linienstärken in PAPIER-Millimeter (vgl. docs/design/plans-output.md §3.2) ──
// Alle Stricharten sind in mm Papier definiert; die PlanView rechnet sie über // Alle Stricharten sind in mm Papier definiert; die PlanView rechnet sie über
// dpi = 96·devicePixelRatio in konstante Bildschirm-Pixel um (non-scaling-stroke), // dpi = 96 in konstante Bildschirm-Pixel um (non-scaling-stroke), damit Linien
// damit Linien beim Zoomen ihre Papiergröße behalten und der Massstab stimmt. // beim Zoomen ihre Papiergröße behalten und der Massstab stimmt.
/** Default-Schichtfuge zwischen den Materialschichten in mm — Haarlinie (0.02), /** Default-Schichtfuge zwischen den Materialschichten in mm — Haarlinie (0.02),
* gleiche Stärke wie die Schraffur-Tinte. Pro Fuge im Wandstil überschreibbar * gleiche Stärke wie die Schraffur-Tinte. Pro Fuge im Wandstil überschreibbar
+12 -9
View File
@@ -1550,8 +1550,10 @@ describe("Dach-Wand-Verschneidung (clipPieceToRoofs) — Wand-Top folgt der Dach
expect(seg.height).toBeLessThan(3.0 - 0.05); expect(seg.height).toBeLessThan(3.0 - 0.05);
expect(seg.height).toBeGreaterThan(2.8); // nicht mehr als die Dachdicke gekappt expect(seg.height).toBeGreaterThan(2.8); // nicht mehr als die Dachdicke gekappt
} }
// Am First (y nahe 2): unverändert volle Höhe (Dachunterkante liegt weit darüber). // Am First (y=2): unverändert volle Höhe. Die ungeклippten Achsenstücke gleicher
const nearRidge = walls.filter((s) => s.start[1] > 1.8 && s.start[1] < 2.2); // Oberkante werden zu EINEM Body verschmolzen, daher hier das Segment suchen, das
// y=2 ÜBERSPANNT (nicht mehr ein an y≈2 beginnendes 0.15-m-Stück).
const nearRidge = walls.filter((s) => Math.min(s.start[1], s.end[1]) <= 2 && Math.max(s.start[1], s.end[1]) >= 2);
expect(nearRidge.length).toBeGreaterThan(0); expect(nearRidge.length).toBeGreaterThan(0);
for (const seg of nearRidge) { for (const seg of nearRidge) {
expect(seg.height).toBeCloseTo(3.0, 6); expect(seg.height).toBeCloseTo(3.0, 6);
@@ -1886,13 +1888,14 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
thickness: 0.3, thickness: 0.3,
}; };
it("Satteldach: Flächen + Giebel als EIN Roof-Mesh, First-Höhe passt zur Traufhöhe + ridgeHeight", () => { it("Satteldach: nur die Dachflächen als Roof-Mesh (keine Giebel-Endfüllung), First-Höhe passt zur Traufhöhe + ridgeHeight", () => {
const project: Project = { ...sampleProject, roofs: [baseRoof] }; const project: Project = { ...sampleProject, roofs: [baseRoof] };
const { meshes } = projectToModel3d(project); const { meshes } = projectToModel3d(project);
const roofMeshes = meshes.filter(isRoofMesh); const roofMeshes = meshes.filter(isRoofMesh);
// Einschichtiges Dach (thickness-Fallback): 1 Schicht-Slab-Mesh (Flächen) + // Einschichtiges Dach (thickness-Fallback): NUR das Schicht-Slab-Mesh der
// 1 Giebel-Mesh (Endfüllung) = 2 Roof-Meshes (beide ROOF_RGB). // geneigten Flächen. Die Giebel-Endfüllung wird bewusst nicht mehr emittiert
expect(roofMeshes.length).toBe(2); // (der Giebel entsteht aus der Wand darunter) -> 1 Roof-Mesh.
expect(roofMeshes.length).toBe(1);
for (const m of roofMeshes) { for (const m of roofMeshes) {
expect(m.positions.every((v) => Number.isFinite(v))).toBe(true); expect(m.positions.every((v) => Number.isFinite(v))).toBe(true);
expect(m.indices.length % 3).toBe(0); expect(m.indices.length % 3).toBe(0);
@@ -2024,9 +2027,9 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
const noField: Project = { ...sampleProject }; const noField: Project = { ...sampleProject };
delete (noField as { roofs?: unknown }).roofs; delete (noField as { roofs?: unknown }).roofs;
expect(projectToModel3d(noField).meshes.filter(isRoofMesh).length).toBe(0); expect(projectToModel3d(noField).meshes.filter(isRoofMesh).length).toBe(0);
// sampleProject enthält ein Demo-Satteldach (RF1, einschichtig) -> 2 Roof- // sampleProject enthält ein Demo-Satteldach (RF1, einschichtig) -> 1 Roof-
// Meshes: Schicht-Slab (Flächen) + Giebel-Endfüllung. // Mesh: nur der Schicht-Slab der Flächen (keine Giebel-Endfüllung mehr).
expect(projectToModel3d(sampleProject).meshes.filter(isRoofMesh).length).toBe(2); expect(projectToModel3d(sampleProject).meshes.filter(isRoofMesh).length).toBe(1);
}); });
}); });
+25 -17
View File
@@ -1099,18 +1099,32 @@ function clipPieceToRoofs(
const span = bb - a; const span = bb - a;
const steps = Math.max(1, Math.ceil(span / ROOF_CLIP_STEP)); const steps = Math.max(1, Math.ceil(span / ROOF_CLIP_STEP));
const stepLen = span / steps; const stepLen = span / steps;
// Je Stufe die geklemmte Oberkante bestimmen …
const tops: number[] = [];
for (let i = 0; i < steps; i++) { for (let i = 0; i < steps; i++) {
const sa = a + i * stepLen; const mid = a + (i + 0.5) * stepLen;
const sb = a + (i + 1) * stepLen;
const mid = (sa + sb) / 2;
const p = add(add(wall.start, scale(u, mid)), scale(n, bandOffset)); const p = add(add(wall.start, scale(u, mid)), scale(n, bandOffset));
let clip = zt; let clip = zt;
for (const { geo, total } of roofData) { for (const { geo, total } of roofData) {
const under = roofUndersideAt(geo, total, p.x, p.y); const under = roofUndersideAt(geo, total, p.x, p.y);
if (under !== null && under < clip) clip = under; if (under !== null && under < clip) clip = under;
} }
const zte = Math.max(zb, clip); tops.push(Math.max(zb, clip));
}
// … und AUFEINANDERFOLGENDE Stufen gleicher Oberkante zu EINEM Body verschmelzen.
// Wo das Dach das Stück gar nicht klippt (Oberkante == zt über die ganze Länge,
// z. B. nahe dem First oder wenn kein Dach wirklich überdeckt), entsteht so wieder
// EIN durchgehender Body statt vieler identischer 0.15-m-Fragmente — nur der echt
// geneigte Bereich bleibt gestuft. Das entlastet auch den Live-Schnitt, der genau
// diese Wandkörper cuttet (weniger, saubere Schnitt-Kappen).
let runStart = 0;
for (let i = 1; i <= steps; i++) {
if (i < steps && Math.abs(tops[i] - tops[runStart]) <= EPS) continue;
const sa = a + runStart * stepLen;
const sb = a + i * stepLen;
const zte = tops[runStart];
if (zte - zb > EPS) emit(sa, sb, zb, zte); if (zte - zb > EPS) emit(sa, sb, zb, zte);
runStart = i;
} }
} }
@@ -1932,19 +1946,13 @@ function emitRoofs(project: Project): RMesh[] {
if (indices.length > 0) out.push({ positions, indices, kind: "extrusion", color, materialIndex }); if (indices.length > 0) out.push({ positions, indices, kind: "extrusion", color, materialIndex });
return offsetAbove + t; return offsetAbove + t;
}, 0); }, 0);
// Giebel (Endfüllung) als dünne Fläche, äusserste Schichtfarbe/Override. // Giebel-ENDFÜLLUNG bewusst NICHT mehr emittieren: die dreieckige Giebelfläche
if (g.gables.length > 0) { // wurde früher als papierdünne Einzelfläche gebaut ("komischer Abschluss" an
const gp: number[] = []; // den Sattel-/Mansard-Enden) und lag zudem deckungsgleich vor der Giebelwand,
const gi: number[] = []; // die ohnehin bis an die Dach-Unterkante hochgeklemmt wird (s.
for (const gable of g.gables) pushFanTriangles(gp, gi, gable); // {@link clipPieceToRoofs}) — also doppelt und optisch als dünne 2D-Schicht
if (gi.length > 0) { // sichtbar. Es wird nur noch das Dach SELBST (die geneigten Slab-Flächen)
const first = layers[0]; // konstruiert; der Giebel entsteht aus der Wand darunter.
const gc =
overrideColor ??
(first?.componentId ? hexToRgb(getComponent(project, first.componentId).color, ROOF_RGB) : ROOF_RGB);
out.push({ positions: gp, indices: gi, kind: "extrusion", color: gc });
}
}
} }
return out; return out;
} }
+74
View File
@@ -0,0 +1,74 @@
// Bündelt den Zeichenwerkzeug-Zustand (transienter View-State; bleibt lokal):
// aktives Werkzeug, Werkzeug-Parameter (Wandtyp/Kategorie/Linienstil), Snap-
// Einstellungen sowie der laufende Werkzeug-Zustand (Ref, kein Re-Render je
// Mausbewegung), die Live-Vorschau (Draft → Overlay in PlanView) und die
// aktive Transformation (Bewegen/Spiegeln/Drehen) auf der Auswahl. Nur aus
// App() herausgezogen, um die Funktion zu entlasten — reine Zustands-
// Bündelung, keine Verhaltensänderung.
//
// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useRef, useState } from "react";
import { useStore } from "./appStore";
import { getTool } from "../tools/tools";
import type { CopyMode, TransformOp } from "../tools/transform";
import { DEFAULT_SNAP } from "../tools/types";
import type { SnapSettings, ToolDraft, ToolId, ToolState } from "../tools/types";
import type { Vec2 } from "../model/types";
export function useDrawingToolState(initialWallTypeId: string, initialLineStyleId: string) {
const [activeTool, setActiveTool] = useState<ToolId>("select");
const [activeWallTypeId, setActiveWallTypeId] = useState<string>(initialWallTypeId);
const activeCategoryCode = useStore((s) => s.activeCategoryCode);
const setActiveCategoryCode = useStore((s) => s.setActiveCategoryCode);
const [activeLineStyleId] = useState<string>(initialLineStyleId);
const [snap, setSnap] = useState<SnapSettings>(DEFAULT_SNAP);
const toolStateRef = useRef<ToolState>(getTool("select").init());
const [draft, setDraft] = useState<ToolDraft | null>(null);
// Aktive Transformation (Bewegen/Spiegeln/Drehen) auf der Auswahl — Vectorworks-
// Stil mit U/I/O/P-Modusleiste. null = keine Transformation läuft.
const [activeTransform, setActiveTransform] = useState<{
op: TransformOp;
copyMode: CopyMode;
count: number;
points: Vec2[];
} | null>(null);
// Letzter gesnappter Cursor der laufenden Transformation — für das Live-Feld
// (Winkel/Distanz) der Befehlszeile (analog gripDragInfoRef beim Vertex-Drag).
const transformDragInfoRef = useRef<{ cursor: Vec2 } | null>(null);
// Kopienanzahl für array/distribute wird über einen PromptDialog erfasst
// (window.prompt ist im Tauri-WKWebView deaktiviert) — `pendingCopyMode`
// hält den Modus, während der Dialog offen ist.
const [pendingCopyMode, setPendingCopyMode] = useState<"array" | "distribute" | null>(null);
const requestCopyCount = (mode: "array" | "distribute") => setPendingCopyMode(mode);
const commitCopyCount = (input: string) => {
const n = Math.round(Number(input));
if (Number.isFinite(n) && n >= 1 && pendingCopyMode) {
const mode = pendingCopyMode;
setActiveTransform((t) => (t ? { ...t, copyMode: mode, count: n } : t));
}
setPendingCopyMode(null);
};
return {
activeTool,
setActiveTool,
activeWallTypeId,
setActiveWallTypeId,
activeCategoryCode,
setActiveCategoryCode,
activeLineStyleId,
snap,
setSnap,
toolStateRef,
draft,
setDraft,
activeTransform,
setActiveTransform,
transformDragInfoRef,
pendingCopyMode,
setPendingCopyMode,
requestCopyCount,
commitCopyCount,
};
}
+484
View File
@@ -0,0 +1,484 @@
// Bündelt Projekt-Mutationen als (referenzstabile) Store-Actions plus die
// Ressourcen-Typ-CRUD (Wand-/Decken-/Dach-/Tür-/Fenster-/Treppentyp), die
// direkt über `setProject` laufen (kein eigener Slice). Löschen ist je Typ
// geschützt, solange ein Element ihn per `typeId`/`wallTypeId`/… referenziert.
// 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 type { RefObject } from "react";
import { useStore } from "./appStore";
import { t } from "../i18n";
import type {
CeilingType,
ContextObject,
DoorType,
OverrideRule,
Project,
RoofType,
StairType,
Vec2,
WallType,
WindowType,
} from "../model/types";
import type { PlanViewHandle } from "../plan/PlanView";
import type { NotifyKind } from "./notifySlice";
/**
* Bounding-Box (Modell-Meter) neu importierter Kontext-Objekte (Konturen/
* Meshes/Terrain, z. B. aus dem Standort-Import-Dialog), als zwei Eckpunkte
* für `PlanViewHandle.fitBounds` (Auto-Zoom auf Import). Ignoriert die
* Z-Achse (reine 2D-Projektion, wie die Plan-Darstellung der Kontext-
* Konturen in generatePlan.ts). Leere/degenerierte Eingabe → null.
*/
function contextObjectsBounds(objs: ContextObject[]): Vec2[] | 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 obj of objs) {
if (obj.type === "contourSet") {
for (const c of obj.contours) for (const p of c.pts) acc(p.x, p.y);
} else if (obj.type === "image") {
acc(obj.min.x, obj.min.y);
acc(obj.max.x, obj.max.y);
} else {
// importedMesh / terrainMesh: flaches (x,y,z…)-Array, Stride 3.
const pos = obj.positions;
for (let i = 0; i + 1 < pos.length; i += 3) acc(pos[i], pos[i + 1]);
}
}
return Number.isFinite(minX)
? [{ x: minX, y: minY }, { x: maxX, y: maxY }]
: null;
}
export function useProjectActions(
// `setProject`/`notify` kommen als Parameter herein statt erneut via
// useStore geholt zu werden: beide werden schon früher in App() gebraucht
// (Command-Engine/Grips, vor dieser Sektion) und bleiben dort deklariert —
// sonst gäbe es die Store-Action zweimal (Duplikat-Deklaration in App()).
setProject: (next: Project | ((p: Project) => Project)) => void,
notify: (message: string, kind?: NotifyKind) => void,
planViewRef: RefObject<PlanViewHandle | null>,
) {
// ── Projekt-Mutationen (aus dem Store-Slice) ─────────────────────────────
// Sämtliche immutablen Projekt-Mutationen liegen jetzt in projectSlice. App
// liest sie als (referenzstabile) Store-Actions und reicht sie unverändert an
// Panels/Menüs/Editoren weiter.
const toggleLevelVisible = useStore((s) => s.toggleLevelVisible);
const toggleCategoryVisible = useStore((s) => s.toggleCategoryVisible);
const addFloor = useStore((s) => s.addFloor);
const addDrawing = useStore((s) => s.addDrawing);
const addSection = useStore((s) => s.addSection);
const addElevation = useStore((s) => s.addElevation);
const addDrawingLevel = useStore((s) => s.addDrawingLevel);
const importDrawings = useStore((s) => s.importDrawings);
const addCategory = useStore((s) => s.addCategory);
const patchCategory = useStore((s) => s.patchCategory);
const addSubCategory = useStore((s) => s.addSubCategory);
const duplicateCategory = useStore((s) => s.duplicateCategory);
const deleteCategory = useStore((s) => s.deleteCategory);
const assignWallCategory = useStore((s) => s.assignWallCategory);
const snapshotLayerVisibility = useStore((s) => s.snapshotLayerVisibility);
const applyLayerVisibility = useStore((s) => s.applyLayerVisibility);
const snapshotDrawingVisibility = useStore((s) => s.snapshotDrawingVisibility);
const applyDrawingVisibility = useStore((s) => s.applyDrawingVisibility);
const patchLevel = useStore((s) => s.patchLevel);
const duplicateLevel = useStore((s) => s.duplicateLevel);
const deleteLevel = useStore((s) => s.deleteLevel);
const patchComponent = useStore((s) => s.patchComponent);
const addComponent = useStore((s) => s.addComponent);
const deleteComponent = useStore((s) => s.deleteComponent);
const patchHatch = useStore((s) => s.patchHatch);
const addHatch = useStore((s) => s.addHatch);
const deleteHatch = useStore((s) => s.deleteHatch);
const patchLineStyle = useStore((s) => s.patchLineStyle);
const addLineStyle = useStore((s) => s.addLineStyle);
const deleteLineStyle = useStore((s) => s.deleteLineStyle);
// Wandstile: immutable Patch eines Wandtyps (z. B. Schichtfugen-Stile). Kein
// eigener Store-Slice nötig — läuft direkt über setProject wie die übrigen
// Ressourcen-Mutationen.
const patchWallType = (id: string, patch: Partial<WallType>) =>
setProject((p) => ({
...p,
wallTypes: p.wallTypes.map((wt) => (wt.id === id ? { ...wt, ...patch } : wt)),
}));
// Grafische Override-Regeln: ersetzt die ganze Regelliste (Reihenfolge =
// Priorität, oben gewinnt). Läuft wie die übrigen Ressourcen-Mutationen
// direkt über setProject — persistiert damit automatisch im Projekt-JSON.
const setOverrideRules = (rules: OverrideRule[]) =>
setProject((p) => ({ ...p, overrideRules: rules }));
// Deckenstile: immutable Patch eines Deckentyps — analog `patchWallType`.
const patchCeilingType = (id: string, patch: Partial<CeilingType>) =>
setProject((p) => ({
...p,
ceilingTypes: (p.ceilingTypes ?? []).map((ct) =>
ct.id === id ? { ...ct, ...patch } : ct,
),
}));
// Neuen Wandtyp anlegen: eine Default-Schicht (erstes Bauteil, 200 mm).
const addWallType = () =>
setProject((p) => {
const componentId = p.components[0]?.id ?? "";
const wallType: WallType = {
id: `wt-${Date.now()}`,
name: t("default.wallTypeName"),
layers: [{ componentId, thickness: 0.2 }],
};
return { ...p, wallTypes: [...p.wallTypes, wallType] };
});
// Wandtyp löschen — geschützt, solange ihn eine Wand verwendet.
const deleteWallType = (id: string) =>
setProject((p) => {
const used = p.walls.some((w) => w.wallTypeId === id);
if (used) {
const name = p.wallTypes.find((wt) => wt.id === id)?.name ?? id;
notify(t("alert.wallTypeInUse", { name }), "warning");
return p;
}
return { ...p, wallTypes: p.wallTypes.filter((wt) => wt.id !== id) };
});
// Neuen Deckentyp anlegen — analog `addWallType` (150 mm Default).
const addCeilingType = () =>
setProject((p) => {
const componentId = p.components[0]?.id ?? "";
const ceilingType: CeilingType = {
id: `ct-${Date.now()}`,
name: t("default.ceilingTypeName"),
layers: [{ componentId, thickness: 0.15 }],
};
return { ...p, ceilingTypes: [...(p.ceilingTypes ?? []), ceilingType] };
});
// Deckentyp löschen — geschützt, solange ihn eine Decke verwendet.
const deleteCeilingType = (id: string) =>
setProject((p) => {
const used = (p.ceilings ?? []).some((c) => c.ceilingTypeId === id);
if (used) {
const name = (p.ceilingTypes ?? []).find((ct) => ct.id === id)?.name ?? id;
notify(t("alert.ceilingTypeInUse", { name }), "warning");
return p;
}
return {
...p,
ceilingTypes: (p.ceilingTypes ?? []).filter((ct) => ct.id !== id),
};
});
// ── Dachtypen (Dachaufbauten) — analog Deckentyp ──────────────────────────
const patchRoofType = (id: string, patch: Partial<RoofType>) =>
setProject((p) => ({
...p,
roofTypes: (p.roofTypes ?? []).map((rt) => (rt.id === id ? { ...rt, ...patch } : rt)),
}));
const addRoofType = () =>
setProject((p) => {
const componentId = p.components[0]?.id ?? "";
const roofType: RoofType = {
id: `rt-${Date.now()}`,
name: t("default.roofTypeName"),
layers: [{ componentId, thickness: 0.2 }],
};
return { ...p, roofTypes: [...(p.roofTypes ?? []), roofType] };
});
// Dachtyp löschen — geschützt, solange ihn ein Dach verwendet.
const deleteRoofType = (id: string) =>
setProject((p) => {
const used = (p.roofs ?? []).some((r) => r.roofTypeId === id);
if (used) {
const name = (p.roofTypes ?? []).find((rt) => rt.id === id)?.name ?? id;
notify(t("alert.roofTypeInUse", { name }), "warning");
return p;
}
return { ...p, roofTypes: (p.roofTypes ?? []).filter((rt) => rt.id !== id) };
});
// ── Bauteil-Typen: Tür / Fenster / Treppe (Bibliothek) ────────────────────
// Anlegen/Patchen/Löschen analog Wand-/Deckentyp — direkt über setProject
// (kein eigener Slice). Löschen ist geschützt, solange ein Element den Typ
// per `typeId` referenziert. Defaults orientieren sich an den Element-Inline-
// Werten (Tür 0.9×2.0, Fenster 1.2×1.4 Brüstung 0.9).
const addDoorType = () =>
setProject((p) => {
const dt: DoorType = {
id: `dt-${Date.now()}`,
name: t("default.doorTypeName"),
kind: "dreh",
leafCount: 1,
leafStyle: "glatt",
frameThickness: 0.05,
defaultWidth: 0.9,
defaultHeight: 2.0,
};
return { ...p, doorTypes: [...(p.doorTypes ?? []), dt] };
});
const patchDoorType = (id: string, patch: Partial<DoorType>) =>
setProject((p) => ({
...p,
doorTypes: (p.doorTypes ?? []).map((dt) =>
dt.id === id ? { ...dt, ...patch } : dt,
),
}));
const deleteDoorType = (id: string) =>
setProject((p) => {
const used = (p.openings ?? []).some((o) => o.typeId === id);
if (used) {
const name = (p.doorTypes ?? []).find((dt) => dt.id === id)?.name ?? id;
notify(t("alert.doorTypeInUse", { name }), "warning");
return p;
}
return { ...p, doorTypes: (p.doorTypes ?? []).filter((dt) => dt.id !== id) };
});
const addWindowType = () =>
setProject((p) => {
const wt: WindowType = {
id: `ft-${Date.now()}`,
name: t("default.windowTypeName"),
kind: "drehkipp",
wingCount: 1,
glazing: "zweifach",
frameThickness: 0.06,
defaultSillHeight: 0.9,
defaultWidth: 1.2,
defaultHeight: 1.4,
};
return { ...p, windowTypes: [...(p.windowTypes ?? []), wt] };
});
const patchWindowType = (id: string, patch: Partial<WindowType>) =>
setProject((p) => ({
...p,
windowTypes: (p.windowTypes ?? []).map((wt) =>
wt.id === id ? { ...wt, ...patch } : wt,
),
}));
const deleteWindowType = (id: string) =>
setProject((p) => {
const used = (p.openings ?? []).some((o) => o.typeId === id);
if (used) {
const name = (p.windowTypes ?? []).find((wt) => wt.id === id)?.name ?? id;
notify(t("alert.windowTypeInUse", { name }), "warning");
return p;
}
return {
...p,
windowTypes: (p.windowTypes ?? []).filter((wt) => wt.id !== id),
};
});
// „Als Stil speichern": aktuellen Fenster-/Tür-Typ der Öffnung als NEUEN
// benannten Stil ablegen und der Öffnung zuweisen (VW „Fenster speichern…").
// Ohne Bezugstyp wird aus den Default-Feldern ein frischer Stil erzeugt.
const saveOpeningStyle = (openingId: string, name: string) =>
setProject((p) => {
const o = (p.openings ?? []).find((x) => x.id === openingId);
if (!o) return p;
if (o.kind === "door") {
const base = (p.doorTypes ?? []).find((d) => d.id === o.typeId);
const nt: DoorType = base
? { ...base, id: `dt-${Date.now()}`, name }
: {
id: `dt-${Date.now()}`,
name,
kind: "dreh",
leafCount: 1,
leafStyle: "glatt",
frameThickness: 0.05,
defaultWidth: 0.9,
defaultHeight: 2.0,
};
return {
...p,
doorTypes: [...(p.doorTypes ?? []), nt],
openings: (p.openings ?? []).map((x) =>
x.id === openingId ? { ...x, typeId: nt.id } : x,
),
};
}
const base = (p.windowTypes ?? []).find((w) => w.id === o.typeId);
const nt: WindowType = base
? { ...base, id: `ft-${Date.now()}`, name }
: {
id: `ft-${Date.now()}`,
name,
kind: "drehkipp",
wingCount: 1,
glazing: "zweifach",
frameThickness: 0.06,
defaultSillHeight: 0.9,
defaultWidth: 1.2,
defaultHeight: 1.4,
};
return {
...p,
windowTypes: [...(p.windowTypes ?? []), nt],
openings: (p.openings ?? []).map((x) =>
x.id === openingId ? { ...x, typeId: nt.id } : x,
),
};
});
const addStairType = () =>
setProject((p) => {
const st: StairType = {
id: `st-${Date.now()}`,
name: t("default.stairTypeName"),
structure: "massiv",
closedRisers: true,
treadThickness: 0.04,
nosing: 0.03,
railing: "keine",
defaultWidth: 1.0,
};
return { ...p, stairTypes: [...(p.stairTypes ?? []), st] };
});
const patchStairType = (id: string, patch: Partial<StairType>) =>
setProject((p) => ({
...p,
stairTypes: (p.stairTypes ?? []).map((st) =>
st.id === id ? { ...st, ...patch } : st,
),
}));
const deleteStairType = (id: string) =>
setProject((p) => {
const used = (p.stairs ?? []).some((s) => s.typeId === id);
if (used) {
const name = (p.stairTypes ?? []).find((st) => st.id === id)?.name ?? id;
notify(t("alert.stairTypeInUse", { name }), "warning");
return p;
}
return { ...p, stairTypes: (p.stairTypes ?? []).filter((st) => st.id !== id) };
});
const importLineStyles = useStore((s) => s.importLineStyles);
const importHatches = useStore((s) => s.importHatches);
// Site-/Kontext-Schicht (siteSlice): Import/Entfernen/Gelände.
const addContextObjects = useStore((s) => s.addContextObjects);
// Wie addContextObjects, aber passt die Plan-Ansicht danach auf die Bounding-
// Box der NEU importierten Objekte ein (Auto-Zoom auf Import, ROADMAP §11) —
// sonst muss man nach Standort-/Geo-Import manuell zur Geometrie suchen.
const addContextObjectsAndFit = (objs: ContextObject[]) => {
addContextObjects(objs);
const pts = contextObjectsBounds(objs);
if (pts) planViewRef.current?.fitBounds(pts);
};
const removeContextObject = useStore((s) => s.removeContextObject);
const removeContextObjects = useStore((s) => s.removeContextObjects);
const generateTerrain = useStore((s) => s.generateTerrain);
const moveGripOf = useStore((s) => s.moveGripOf);
const moveElementByOf = useStore((s) => s.moveElementByOf);
const moveEdgeOf = useStore((s) => s.moveEdgeOf);
const commitTransformOn = useStore((s) => s.commitTransformOn);
const setElementColor = useStore((s) => s.setElementColor);
const setElementWeight = useStore((s) => s.setElementWeight);
const setElementFill = useStore((s) => s.setElementFill);
const setElementFillColor = useStore((s) => s.setElementFillColor);
const setElementForeground = useStore((s) => s.setElementForeground);
const setElementBackground = useStore((s) => s.setElementBackground);
const updateDrawing2D = useStore((s) => s.updateDrawing2D);
const reorderDrawings = useStore((s) => s.reorderDrawings);
const resizeElement = useStore((s) => s.resizeElement);
const updateWall = useStore((s) => s.updateWall);
const setWallThickness = useStore((s) => s.setWallThickness);
const updateOpening = useStore((s) => s.updateOpening);
const moveCeilingGrip = useStore((s) => s.moveCeilingGrip);
const moveCeilingBy = useStore((s) => s.moveCeilingBy);
const moveCeilingEdge = useStore((s) => s.moveCeilingEdge);
const updateCeiling = useStore((s) => s.updateCeiling);
const setCeilingThickness = useStore((s) => s.setCeilingThickness);
const updateStair = useStore((s) => s.updateStair);
const moveStairBy = useStore((s) => s.moveStairBy);
const updateRoom = useStore((s) => s.updateRoom);
const moveRoomBy = useStore((s) => s.moveRoomBy);
const moveRoomGrip = useStore((s) => s.moveRoomGrip);
const moveRoomEdge = useStore((s) => s.moveRoomEdge);
const moveRoomStamp = useStore((s) => s.moveRoomStamp);
const setRoomStampDoc = useStore((s) => s.setRoomStampDoc);
const setRoomStamp = useStore((s) => s.setRoomStamp);
return {
toggleLevelVisible,
toggleCategoryVisible,
addFloor,
addDrawing,
addSection,
addElevation,
addDrawingLevel,
importDrawings,
addCategory,
patchCategory,
addSubCategory,
duplicateCategory,
deleteCategory,
assignWallCategory,
snapshotLayerVisibility,
applyLayerVisibility,
snapshotDrawingVisibility,
applyDrawingVisibility,
patchLevel,
duplicateLevel,
deleteLevel,
patchComponent,
addComponent,
deleteComponent,
patchHatch,
addHatch,
deleteHatch,
patchLineStyle,
addLineStyle,
deleteLineStyle,
patchWallType,
setOverrideRules,
patchCeilingType,
addWallType,
deleteWallType,
addCeilingType,
deleteCeilingType,
patchRoofType,
addRoofType,
deleteRoofType,
addDoorType,
patchDoorType,
deleteDoorType,
addWindowType,
patchWindowType,
deleteWindowType,
saveOpeningStyle,
addStairType,
patchStairType,
deleteStairType,
importLineStyles,
importHatches,
addContextObjects,
addContextObjectsAndFit,
removeContextObject,
removeContextObjects,
generateTerrain,
moveGripOf,
moveElementByOf,
moveEdgeOf,
commitTransformOn,
setElementColor,
setElementWeight,
setElementFill,
setElementFillColor,
setElementForeground,
setElementBackground,
updateDrawing2D,
reorderDrawings,
resizeElement,
updateWall,
setWallThickness,
updateOpening,
moveCeilingGrip,
moveCeilingBy,
moveCeilingEdge,
updateCeiling,
setCeilingThickness,
updateStair,
moveStairBy,
updateRoom,
moveRoomBy,
moveRoomGrip,
moveRoomEdge,
moveRoomStamp,
setRoomStampDoc,
setRoomStamp,
};
}
+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,
};
}
+119
View File
@@ -0,0 +1,119 @@
// Bündelt die Auswahl-Zustände aller Bauteil-/Zeichnungs-Arten: je Art eine
// MENGE aus dem Store-Slice (Marquee/Mehrfachauswahl) plus ein daraus
// abgeleitetes Einzel-Element (nur bei genau einem gewählten Element) für die
// bestehende Einzel-Element-Maschinerie (Grips, Edit, Attribute, Object-Info).
// Dazu die 2D-Zeichenelement-Zwischenablage und den 3D-Live-Schnitt-Treiber
// (reiner Ansichts-Zustand, unabhängig von der Auswahl). Nur aus App()
// herausgezogen, um die Funktion zu entlasten — reine Zustands-Bündelung,
// keine Verhaltensänderung.
//
// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useState } from "react";
import { useStore } from "./appStore";
import type { Drawing2D } from "../model/types";
export function useSelectionState() {
const selectedWallIds = useStore((s) => s.selectedWallIds);
const setSelectedWallIds = useStore((s) => s.setSelectedWallIds);
// 2D-Auswahl ist jetzt eine MENGE (Marquee/Mehrfach). Für die bestehende
// Einzel-Element-Maschinerie (Grips, Edit, Attribute, Object-Info) wird lokal
// der „eine" gewählte Zeichnungs-ID abgeleitet (nur bei genau einem Element);
// `setSelectedDrawingId` bleibt als bequeme Einzel-Setter-Hülle erhalten.
const selectedDrawingIds = useStore((s) => s.selectedDrawingIds);
const setSelectedDrawingIds = useStore((s) => s.setSelectedDrawingIds);
const selectedDrawingId =
selectedDrawingIds.length === 1 ? selectedDrawingIds[0] : null;
const setSelectedDrawingId = (id: string | null) =>
setSelectedDrawingIds(id ? [id] : []);
// Zwischenablage für 2D-Zeichenelemente (Ctrl/Cmd+C/V). Kopiert die GEWÄHLTEN
// Drawing2D tief; Einfügen legt Klone mit neuen IDs auf dem AKTIVEN Geschoss
// (Zeichnungsebene) an — so lässt sich z. B. importiertes Mobiliar über
// Geschosse hinweg wiederverwenden. Kategorie/Stil bleiben erhalten.
const [drawingClipboard, setDrawingClipboard] = useState<Drawing2D[]>([]);
// Bequemer Einzel-Selektor (erste gewählte Wand).
const selectedWallId = selectedWallIds[0] ?? null;
// Decken-Auswahl (MENGE); für die Einzel-Element-Maschinerie der „eine"
// gewählte (bei genau einem Element).
const selectedCeilingIds = useStore((s) => s.selectedCeilingIds);
const setSelectedCeilingIds = useStore((s) => s.setSelectedCeilingIds);
const selectedCeilingId =
selectedCeilingIds.length === 1 ? selectedCeilingIds[0] : null;
// Dach-Auswahl (MENGE); Einzel-Element bei genau einem gewählten Dach.
const selectedRoofIds = useStore((s) => s.selectedRoofIds);
const setSelectedRoofIds = useStore((s) => s.setSelectedRoofIds);
const selectedRoofId = selectedRoofIds.length === 1 ? selectedRoofIds[0] : null;
// Schnitt-/Ansichtslinien-Auswahl (Einzel-DrawingLevel-Id oder null).
const selectedSectionLineId = useStore((s) => s.selectedSectionLineId);
const setSelectedSectionLineId = useStore((s) => s.setSelectedSectionLineId);
// Welche Schnittlinie treibt den 3D-Live-Schnitt (reiner Ansichts-Zustand,
// unabhängig von der Auswahl — der Schnitt bleibt beim Klick woanders aktiv).
const [section3dCutId, setSection3dCutId] = useState<string | null>(null);
const updateRoof = useStore((s) => s.updateRoof);
const moveRoofGrip = useStore((s) => s.moveRoofGrip);
const moveRoofBy = useStore((s) => s.moveRoofBy);
const selectedOpeningIds = useStore((s) => s.selectedOpeningIds);
const setSelectedOpeningIds = useStore((s) => s.setSelectedOpeningIds);
const selectedOpeningId =
selectedOpeningIds.length === 1 ? selectedOpeningIds[0] : null;
const selectedStairIds = useStore((s) => s.selectedStairIds);
const setSelectedStairIds = useStore((s) => s.setSelectedStairIds);
const selectedStairId =
selectedStairIds.length === 1 ? selectedStairIds[0] : null;
const selectedRoomIds = useStore((s) => s.selectedRoomIds);
const setSelectedRoomIds = useStore((s) => s.setSelectedRoomIds);
const selectedRoomId =
selectedRoomIds.length === 1 ? selectedRoomIds[0] : null;
const selectedExtrudedSolidIds = useStore((s) => s.selectedExtrudedSolidIds);
const setSelectedExtrudedSolidIds = useStore((s) => s.setSelectedExtrudedSolidIds);
const selectedExtrudedSolidId =
selectedExtrudedSolidIds.length === 1 ? selectedExtrudedSolidIds[0] : null;
const selectedColumnIds = useStore((s) => s.selectedColumnIds);
const setSelectedColumnIds = useStore((s) => s.setSelectedColumnIds);
const selectedColumnId =
selectedColumnIds.length === 1 ? selectedColumnIds[0] : null;
const selectedContextObjectIds = useStore((s) => s.selectedContextObjectIds);
const setSelectedContextObjectIds = useStore((s) => s.setSelectedContextObjectIds);
return {
selectedWallIds,
setSelectedWallIds,
selectedDrawingIds,
setSelectedDrawingIds,
selectedDrawingId,
setSelectedDrawingId,
drawingClipboard,
setDrawingClipboard,
selectedWallId,
selectedCeilingIds,
setSelectedCeilingIds,
selectedCeilingId,
selectedRoofIds,
setSelectedRoofIds,
selectedRoofId,
selectedSectionLineId,
setSelectedSectionLineId,
section3dCutId,
setSection3dCutId,
updateRoof,
moveRoofGrip,
moveRoofBy,
selectedOpeningIds,
setSelectedOpeningIds,
selectedOpeningId,
selectedStairIds,
setSelectedStairIds,
selectedStairId,
selectedRoomIds,
setSelectedRoomIds,
selectedRoomId,
selectedExtrudedSolidIds,
setSelectedExtrudedSolidIds,
selectedExtrudedSolidId,
selectedColumnIds,
setSelectedColumnIds,
selectedColumnId,
selectedContextObjectIds,
setSelectedContextObjectIds,
};
}
+92
View File
@@ -0,0 +1,92 @@
// Bündelt den Oberleisten-/Status-Zustand (View-State, kein Modell): Detail-
// stufe, 3D-Rendermodus, Feldweite, Boden-Referenzraster, Plan-Farbmodus,
// Wand-Achslinien, Linienmodus, Fadenkreuz-/Snap-Farbe und Papier-Massstab
// kommen aus dem Store; liveScale/selectedViewSnapshotId/cursor sind rein
// lokaler UI-State (PlanView meldet den Live-Massstab, kein Undo/Redo-Bezug).
// zoomPercent ist daraus abgeleitet. Nur aus App() herausgezogen, um die
// Funktion zu entlasten — reine Zustands-Bündelung, keine Verhaltensänderung.
//
// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useState } from "react";
import { useStore } from "./appStore";
import type { Vec2 } from "../model/types";
export function useTopBarState() {
const detail = useStore((s) => s.detail);
const setDetail = useStore((s) => s.setDetail);
const renderMode = useStore((s) => s.renderMode);
const setRenderMode = useStore((s) => s.setRenderMode);
const view3d = useStore((s) => s.view3d);
const setView3d = useStore((s) => s.setView3d);
const fov = useStore((s) => s.fov);
const setFov = useStore((s) => s.setFov);
// Boden-Referenzraster im 3D-Viewport (wirkt nur im WASM/Nordstern-Pfad,
// s. Wasm3DViewport.tsx) — Umschalter sitzt als Overlay-Button im Viewport.
const grid3dVisible = useStore((s) => s.grid3dVisible);
const setGrid3dVisible = useStore((s) => s.setGrid3dVisible);
const planColorMode = useStore((s) => s.planColorMode);
const setPlanColorMode = useStore((s) => s.setPlanColorMode);
const referenceLines = useStore((s) => s.referenceLines);
const setReferenceLines = useStore((s) => s.setReferenceLines);
const lineMode = useStore((s) => s.lineMode);
const setLineMode = useStore((s) => s.setLineMode);
const marqueeColor = useStore((s) => s.marqueeColor);
const setMarqueeColor = useStore((s) => s.setMarqueeColor);
const snapColor = useStore((s) => s.snapColor);
const setSnapColor = useStore((s) => s.setSnapColor);
const scaleDenominator = useStore((s) => s.scaleDenominator);
const setScaleDenominator = useStore((s) => s.setScaleDenominator);
// LIVE Papier-Massstab-Nenner aus der View-Transform (PlanView meldet ihn);
// null = keine Plan-Ansicht aktiv. Transienter UI-State, bleibt lokal.
const [liveScale, setLiveScale] = useState<number | null>(null);
// „Angewählter" Ausschnitt (View-Snapshot): bleibt gesetzt, solange der
// Live-Zustand EXAKT dem Ausschnitt entspricht (siehe Effekt weiter unten).
// Steuert die Footer-Bar + die Aktiv-Markierung im Ausschnitte-Panel.
const [selectedViewSnapshotId, setSelectedViewSnapshotId] = useState<
string | null
>(null);
// Cursor in Modell-Metern (aus der Plan-Ansicht) bzw. null (nicht über Plan).
const [cursor, setCursor] = useState<Vec2 | null>(null);
// Live-Zoom in Prozent RELATIV zum angewandten Papier-Massstab: 100 % =
// Live-Ansicht steht exakt im angewandten Massstab (liveScale == scaleDenominator).
// Reinzoomen > 100 %, Rauszoomen < 100 %. Einzige Quelle: angewandter Nenner
// (scaleDenominator) / Live-Nenner (liveScale). Ohne Live-Massstab (keine
// Plan-Ansicht) Fallback 100 %.
const zoomPercent =
liveScale != null && liveScale > 0
? (scaleDenominator / liveScale) * 100
: 100;
return {
detail,
setDetail,
renderMode,
setRenderMode,
view3d,
setView3d,
fov,
setFov,
grid3dVisible,
setGrid3dVisible,
planColorMode,
setPlanColorMode,
referenceLines,
setReferenceLines,
lineMode,
setLineMode,
marqueeColor,
setMarqueeColor,
snapColor,
setSnapColor,
scaleDenominator,
setScaleDenominator,
liveScale,
setLiveScale,
selectedViewSnapshotId,
setSelectedViewSnapshotId,
cursor,
setCursor,
zoomPercent,
};
}
+46 -6
View File
@@ -630,9 +630,9 @@ body {
border: 1px solid var(--border, #3a3f47); border: 1px solid var(--border, #3a3f47);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
color: var(--ink, #e6e8ea); color: var(--ink, #e6e8ea);
/* Der Dialog ist ein DOM-Kind der `.topbar` (white-space: nowrap) — ohne /* Defensiv gesetzt (unabhängig davon, ob ein Vorfahre white-space: nowrap
dieses Reset liefe der mehrzeilige Beschreibungstext einzeilig aus dem setzt) — ohne dieses Reset liefe der mehrzeilige Beschreibungstext
Dialog heraus. Hier wieder normalen Zeilenumbruch erzwingen. */ einzeilig aus dem Dialog heraus. */
white-space: normal; white-space: normal;
} }
.about-brand { .about-brand {
@@ -676,9 +676,15 @@ body {
line-height: 1.6; line-height: 1.6;
color: var(--label, #c4c8cd); color: var(--label, #c4c8cd);
} }
.about-close { .about-actions {
appearance: none; display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px; margin-top: 18px;
}
.about-close,
.about-check-update {
appearance: none;
padding: 6px 14px; padding: 6px 14px;
border-radius: 6px; border-radius: 6px;
border: 1px solid var(--border, #3a3f47); border: 1px solid var(--border, #3a3f47);
@@ -687,9 +693,14 @@ body {
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
} }
.about-close:hover { .about-close:hover,
.about-check-update:hover:not(:disabled) {
background: var(--accent-dim, #33383f); background: var(--accent-dim, #33383f);
} }
.about-check-update:disabled {
opacity: 0.6;
cursor: default;
}
.tag { .tag {
font-weight: 500; font-weight: 500;
@@ -5392,6 +5403,35 @@ body {
outline: none; outline: none;
box-shadow: 0 0 0 2px var(--accent-border); box-shadow: 0 0 0 2px var(--accent-border);
} }
.settings-subsection {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.settings-select {
font-family: inherit;
font-size: 13px;
padding: 6px 9px;
border: none;
border-radius: 7px;
background: var(--field-bg);
color: var(--ink);
}
.settings-select:focus {
outline: none;
box-shadow: 0 0 0 2px var(--accent-border);
}
.settings-history-row {
display: flex;
align-items: center;
gap: 8px;
}
.settings-history-row .settings-select {
flex: 1;
}
/* ── App-weites Drop-Feedback ────────────────────────────────────────────── /* ── App-weites Drop-Feedback ──────────────────────────────────────────────
Dezenter Rahmen + Hinweis, solange eine Datei über die App gezogen wird. */ Dezenter Rahmen + Hinweis, solange eine Datei über die App gezogen wird. */
+8
View File
@@ -66,6 +66,13 @@ export interface CommandLineHandle {
* Zeichnen, ohne dass das Feld fokussiert war): fokussiert + setzt den Text. * Zeichnen, ohne dass das Feld fokussiert war): fokussiert + setzt den Text.
*/ */
beginTyping: (seed: string) => void; beginTyping: (seed: string) => void;
/**
* Gibt den Eingabefokus frei (z. B. wenn ein Befehl fertig ist), damit die
* nächste Zifferntaste sofort wieder das nächste Werkzeug wählt statt im
* Eingabefeld zu landen (Vectorworks-Verhalten: kein Esc nötig zwischen zwei
* Werkzeugen).
*/
blur: () => void;
} }
export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>( export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
@@ -87,6 +94,7 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
setText(seed); setText(seed);
inputRef.current?.focus(); inputRef.current?.focus();
}, },
blur: () => inputRef.current?.blur(),
})); }));
// Autocomplete nur im Ruhezustand (Befehlsname tippen), nicht in einem Schritt. // Autocomplete nur im Ruhezustand (Befehlsname tippen), nicht in einem Schritt.
+4 -4
View File
@@ -977,7 +977,7 @@ function ComponentsTab({
<div className="res-md-empty">{t("resources.components.empty")}</div> <div className="res-md-empty">{t("resources.components.empty")}</div>
)} )}
<div className="res-md-listfoot"> <div className="res-md-listfoot">
<button className="res-add" onClick={onAddComponent}> <button className="res-add" onClick={() => onAddComponent()}>
<span className="res-add-plus">+</span> {t("resources.add")} <span className="res-add-plus">+</span> {t("resources.add")}
</button> </button>
</div> </div>
@@ -1246,7 +1246,7 @@ function HatchesTab({
<div className="res-md-empty">{t("resources.hatches.empty")}</div> <div className="res-md-empty">{t("resources.hatches.empty")}</div>
)} )}
<div className="res-md-listfoot"> <div className="res-md-listfoot">
<button className="res-add" onClick={onAddHatch}> <button className="res-add" onClick={() => onAddHatch()}>
<span className="res-add-plus">+</span> {t("resources.add")} <span className="res-add-plus">+</span> {t("resources.add")}
</button> </button>
<FileImportButton <FileImportButton
@@ -1549,7 +1549,7 @@ function LinesTab({
<div className="res-md-empty">{t("resources.lines.empty")}</div> <div className="res-md-empty">{t("resources.lines.empty")}</div>
)} )}
<div className="res-md-listfoot"> <div className="res-md-listfoot">
<button className="res-add" onClick={onAddLineStyle}> <button className="res-add" onClick={() => onAddLineStyle()}>
<span className="res-add-plus">+</span> {t("resources.add")} <span className="res-add-plus">+</span> {t("resources.add")}
</button> </button>
<FileImportButton <FileImportButton
@@ -1809,7 +1809,7 @@ function LayeredStylesTab({
<div className="res-md-empty">{t(emptyKey)}</div> <div className="res-md-empty">{t(emptyKey)}</div>
)} )}
<div className="res-md-listfoot"> <div className="res-md-listfoot">
<button className="res-add" onClick={onAdd}> <button className="res-add" onClick={() => onAdd()}>
<span className="res-add-plus">+</span> {t("resources.add")} <span className="res-add-plus">+</span> {t("resources.add")}
</button> </button>
</div> </div>
+123
View File
@@ -14,9 +14,25 @@ import { t } from "../i18n";
import { ACCENT_SWATCHES } from "../theme/accents"; import { ACCENT_SWATCHES } from "../theme/accents";
import { ColorHexField } from "./ColorHexField"; import { ColorHexField } from "./ColorHexField";
import { LayoutMenu } from "./LayoutMenu"; import { LayoutMenu } from "./LayoutMenu";
import { APP_VERSION } from "./TopBar";
import type { RendererMode } from "./StatusBar"; import type { RendererMode } from "./StatusBar";
import type { LayoutState } from "../panels/types"; import type { LayoutState } from "../panels/types";
import type { ThemeMode } from "../theme/themeMode"; import type { ThemeMode } from "../theme/themeMode";
import { checkForUpdate, getUpdateSnoozeUntil, setUpdateSnooze } from "../update/checkForUpdate";
import { getRunningVersion } from "../update/version";
import {
fetchVersionHistory,
openVersionDownload,
type VersionEntry,
} from "../update/versionHistory";
/** Pausier-Optionen fuer den stillen Start-Check (s. checkForUpdate.ts). */
const SNOOZE_OPTIONS = [
{ id: "active", ms: null },
{ id: "1day", ms: 24 * 60 * 60 * 1000 },
{ id: "1week", ms: 7 * 24 * 60 * 60 * 1000 },
{ id: "1month", ms: 30 * 24 * 60 * 60 * 1000 },
] as const;
export interface SettingsDialogProps { export interface SettingsDialogProps {
/** Hell/Dunkel — Standard ist Hell (siehe theme/themeMode.ts). */ /** Hell/Dunkel — Standard ist Hell (siehe theme/themeMode.ts). */
@@ -71,6 +87,22 @@ export function SettingsDialog({
onClose, onClose,
windowed = false, windowed = false,
}: SettingsDialogProps) { }: SettingsDialogProps) {
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [runningVersion, setRunningVersion] = useState<string | null>(null);
const [snoozeUntil, setSnoozeUntilState] = useState<number | null>(() => getUpdateSnoozeUntil());
const [versionHistory, setVersionHistory] = useState<VersionEntry[] | null>(null);
const [selectedTag, setSelectedTag] = useState<string>("");
useEffect(() => {
void getRunningVersion().then(setRunningVersion);
}, []);
useEffect(() => {
void fetchVersionHistory().then((entries) => {
setVersionHistory(entries);
if (entries.length > 0) setSelectedTag(entries[0].tag);
});
}, []);
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
@@ -208,6 +240,97 @@ export function SettingsDialog({
/> />
<div className="imp-note">{t("settings.referenceElevation.hint")}</div> <div className="imp-note">{t("settings.referenceElevation.hint")}</div>
</section> </section>
<section className="imp-section">
<div className="imp-section-title">{t("settings.section.update")}</div>
<div className="imp-note">
{t("about.version")} {runningVersion ?? APP_VERSION}
</div>
<button
type="button"
className="imp-btn"
disabled={checkingUpdate}
onClick={async () => {
setCheckingUpdate(true);
try {
await checkForUpdate(false);
} finally {
setCheckingUpdate(false);
}
}}
>
{checkingUpdate ? t("about.checkingUpdate") : t("about.checkUpdate")}
</button>
<div className="settings-subsection">
<label className="settings-field-label" htmlFor="settings-update-snooze">
{t("settings.update.autoCheck")}
</label>
<select
id="settings-update-snooze"
className="settings-select"
value={snoozeUntil == null ? "active" : String(snoozeUntil)}
onChange={(e) => {
const opt = SNOOZE_OPTIONS.find((o) => o.id === e.target.value);
const until = opt?.ms == null ? null : Date.now() + opt.ms;
setUpdateSnooze(until);
setSnoozeUntilState(until);
}}
>
{SNOOZE_OPTIONS.map((o) => (
<option key={o.id} value={o.id}>
{t(`settings.update.snooze.${o.id}` as const)}
</option>
))}
</select>
{snoozeUntil != null && (
<div className="imp-note">
{t("settings.update.snoozedUntil", {
date: new Date(snoozeUntil).toLocaleDateString(),
})}
</div>
)}
</div>
<div className="settings-subsection">
<label className="settings-field-label" htmlFor="settings-update-history">
{t("settings.update.versionHistory")}
</label>
{versionHistory == null ? (
<div className="imp-note">{t("settings.update.loadingHistory")}</div>
) : versionHistory.length === 0 ? (
<div className="imp-note">{t("settings.update.noHistory")}</div>
) : (
<>
<div className="settings-history-row">
<select
id="settings-update-history"
className="settings-select"
value={selectedTag}
onChange={(e) => setSelectedTag(e.target.value)}
>
{versionHistory.map((v) => (
<option key={v.tag} value={v.tag}>
{v.version} — {v.pub_date.slice(0, 10)}
</option>
))}
</select>
<button
type="button"
className="imp-btn"
onClick={() => {
const entry = versionHistory.find((v) => v.tag === selectedTag);
if (entry) void openVersionDownload(entry);
}}
>
{t("settings.update.download")}
</button>
</div>
<div className="imp-note">{t("settings.update.versionHistory.hint")}</div>
</>
)}
</div>
</section>
</div> </div>
{!windowed && ( {!windowed && (
+34 -2
View File
@@ -10,6 +10,8 @@
import type { Project } from "../model/types"; import type { Project } from "../model/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { checkForUpdate } from "../update/checkForUpdate";
import { getRunningVersion } from "../update/version";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Dropdown } from "./Dropdown"; import { Dropdown } from "./Dropdown";
@@ -1020,6 +1022,11 @@ export const APP_VERSION = "0.1.0";
* Wortmarke. Klick ausserhalb / Esc schliesst. * Wortmarke. Klick ausserhalb / Esc schliesst.
*/ */
function AboutDialog({ onClose }: { onClose: () => void }) { function AboutDialog({ onClose }: { onClose: () => void }) {
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [runningVersion, setRunningVersion] = useState<string | null>(null);
useEffect(() => {
void getRunningVersion().then(setRunningVersion);
}, []);
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
@@ -1028,7 +1035,14 @@ function AboutDialog({ onClose }: { onClose: () => void }) {
return () => document.removeEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey);
}, [onClose]); }, [onClose]);
return ( // Per Portal an document.body statt als DOM-Kind der .topbar rendern: die
// .topbar bildet (position:relative + z-index) einen eigenen Stacking-
// Context, der das hohe z-index:1000 von .about-overlay einsperrt — andere
// z-index:2-Elemente ausserhalb (z. B. das Lineal-Overlay im Grundriss)
// konnten dadurch trotzdem durch den abgedunkelten Hintergrund "durch-
// scheinen". Als Portal-Kind von body liegt der Dialog in keinem fremden
// Stacking-Context mehr.
return createPortal(
<div className="about-overlay" onMouseDown={onClose}> <div className="about-overlay" onMouseDown={onClose}>
<div <div
className="about-dialog" className="about-dialog"
@@ -1041,7 +1055,7 @@ function AboutDialog({ onClose }: { onClose: () => void }) {
dossier<span className="brand-dot">.</span> dossier<span className="brand-dot">.</span>
</div> </div>
<div className="about-version"> <div className="about-version">
{t("about.version")} {APP_VERSION} {t("about.version")} {runningVersion ?? APP_VERSION}
</div> </div>
<p className="about-desc">{t("about.desc")}</p> <p className="about-desc">{t("about.desc")}</p>
<div className="about-meta"> <div className="about-meta">
@@ -1059,11 +1073,29 @@ function AboutDialog({ onClose }: { onClose: () => void }) {
<li>LibreDWG — GPL-3.0</li> <li>LibreDWG — GPL-3.0</li>
<li>acadrust — MPL-2.0</li> <li>acadrust — MPL-2.0</li>
</ul> </ul>
<div className="about-actions">
<button
type="button"
className="about-check-update"
disabled={checkingUpdate}
onClick={async () => {
setCheckingUpdate(true);
try {
await checkForUpdate(false);
} finally {
setCheckingUpdate(false);
}
}}
>
{checkingUpdate ? t("about.checkingUpdate") : t("about.checkUpdate")}
</button>
<button type="button" className="about-close" onClick={onClose}> <button type="button" className="about-close" onClick={onClose}>
{t("about.close")} {t("about.close")}
</button> </button>
</div> </div>
</div> </div>
</div>,
document.body,
); );
} }
+81
View File
@@ -0,0 +1,81 @@
// Selbst-Update: fragt `endpoints` aus tauri.conf.json ab (Gitea-Release-Asset
// `latest.json`, per `/releases/latest/download/` immer der aktuellste Release
// dieses Repos — die URL selbst muss bei neuen Versionen nicht angepasst
// werden). Nur unter Tauri aktiv; im reinen Browser/Vite-Dev-Server gibt es
// weder die Plugins noch etwas zu aktualisieren.
import { ask, message } from "@tauri-apps/plugin-dialog";
import { t } from "../i18n";
import { isTauriRuntime } from "../io/projectFile";
/** localStorage-Key fuer die Update-Pause (s. `getUpdateSnoozeUntil`). */
const SNOOZE_KEY = "dossier.updateSnoozeUntil";
/** Ende der aktuellen Update-Pause (ms epoch), `null` wenn keine aktiv/abgelaufen. */
export function getUpdateSnoozeUntil(): number | null {
const raw = localStorage.getItem(SNOOZE_KEY);
if (!raw) return null;
const n = Number(raw);
if (!Number.isFinite(n) || n <= Date.now()) return null;
return n;
}
/** Pausiert den STILLEN Start-Check bis `untilMs` (epoch); `null` hebt die Pause auf.
* Der manuelle Check (Button in Settings/About) ignoriert die Pause immer —
* das ist eine explizite Nutzeraktion. */
export function setUpdateSnooze(untilMs: number | null): void {
if (untilMs == null) localStorage.removeItem(SNOOZE_KEY);
else localStorage.setItem(SNOOZE_KEY, String(untilMs));
}
/**
* Prueft auf eine neue Version. `silent=true` (Start-Check) meldet sich nur
* bei einem GEFUNDENEN Update oder einem echten Fehler — nicht bei „aktuell",
* damit der App-Start nicht bei jedem Start einen Dialog zeigt. Respektiert
* ausserdem eine aktive Update-Pause (s. `setUpdateSnooze`). Der manuelle
* Aufruf (About-/Settings-Dialog, `silent=false`) meldet in jedem Fall ein
* Ergebnis und ignoriert die Pause.
*/
export async function checkForUpdate(silent: boolean): Promise<void> {
if (!isTauriRuntime()) return;
if (silent && getUpdateSnoozeUntil() != null) return;
const { check } = await import("@tauri-apps/plugin-updater");
let update: Awaited<ReturnType<typeof check>>;
try {
update = await check();
} catch (err) {
if (!silent) {
await message(t("update.checkFailed", { error: String(err) }), {
title: t("update.title"),
kind: "error",
});
}
return;
}
if (!update) {
if (!silent) {
await message(t("update.upToDate"), {
title: t("update.title"),
kind: "info",
});
}
return;
}
const install = await ask(t("update.availableBody", { version: update.version }), {
title: t("update.title"),
kind: "info",
okLabel: t("update.installNow"),
cancelLabel: t("update.installLater"),
});
if (!install) return;
try {
await update.downloadAndInstall();
} catch (err) {
await message(t("update.installFailed", { error: String(err) }), {
title: t("update.title"),
kind: "error",
});
return;
}
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
}
+12
View File
@@ -0,0 +1,12 @@
// Tatsaechlich laufende App-Version (aus Tauris eigenen Metadaten, NICHT der
// manuell gepflegte `APP_VERSION`-String in ui/TopBar.tsx) — vermeidet, dass
// die Anzeige von der echten Installation abweicht, falls jemand beim
// Versionsbump eine der beiden Stellen vergisst.
import { isTauriRuntime } from "../io/projectFile";
/** `null` ausserhalb von Tauri (Vite-Dev-Server im Browser, keine Installation). */
export async function getRunningVersion(): Promise<string | null> {
if (!isTauriRuntime()) return null;
const { getVersion } = await import("@tauri-apps/api/app");
return getVersion();
}
+48
View File
@@ -0,0 +1,48 @@
// Versionsverlauf fuer den manuellen Rollback (Settings → Update): Tauris
// Updater-Plugin kennt nur „neuere Version installieren", kein Downgrade-
// Manifest mit Auswahl. Statt das Plugin zu verbiegen, pflegen wir einen
// eigenen, schlanken Index `versions.json` (liegt auf demselben Gitea-
// Release „latest" wie `latest.json`, s. checkForUpdate.ts) und bieten pro
// Eintrag nur den DMG-Download im System-Browser an — Installation bleibt
// der gewohnte manuelle Schritt (DMG oeffnen, App in Applications ersetzen).
import { isTauriRuntime } from "../io/projectFile";
export interface VersionEntry {
version: string;
/** Git-Tag des Releases, z. B. "v0.1.0". */
tag: string;
/** ISO-Datum. */
pub_date: string;
notes: string;
/** Dateiname der DMG innerhalb dieses Release-Tags. */
dmg: string;
}
const VERSIONS_URL =
"https://git.openbureau.ch/karim/DOSSIER-STANDALONE/releases/download/latest/versions.json";
/** Leeres Array ausserhalb von Tauri oder bei Netzwerk-/Parsefehlern (keine
* Wurf-Semantik hier — der Aufrufer zeigt einfach eine leere Liste). */
export async function fetchVersionHistory(): Promise<VersionEntry[]> {
if (!isTauriRuntime()) return [];
try {
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
const res = await tauriFetch(VERSIONS_URL);
if (!res.ok) return [];
const data = (await res.json()) as VersionEntry[];
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
export function downloadUrlFor(entry: VersionEntry): string {
return `https://git.openbureau.ch/karim/DOSSIER-STANDALONE/releases/download/${entry.tag}/${entry.dmg}`;
}
/** Oeffnet den DMG-Download im System-Standardbrowser (kein In-App-Download —
* Installation bleibt manuell). */
export async function openVersionDownload(entry: VersionEntry): Promise<void> {
const { open } = await import("@tauri-apps/plugin-shell");
await open(downloadUrlFor(entry));
}