Compare commits
15 Commits
v0.1.0
..
f2b5740d89
| Author | SHA1 | Date | |
|---|---|---|---|
| f2b5740d89 | |||
| 63dba0592c | |||
| be7a47c785 | |||
| 327b7d3148 | |||
| b56095da80 | |||
| 89c444451c | |||
| 3878203c2d | |||
| 7324625b03 | |||
| ce933b1926 | |||
| 3d64970bda | |||
| 6a306182f1 | |||
| bb8d577608 | |||
| 1acf27548f | |||
| 34aab597a1 | |||
| f00e82af2e |
@@ -76,6 +76,8 @@
|
||||
|
||||
## ⏭️ 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):**
|
||||
- [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).
|
||||
|
||||
@@ -98,10 +98,12 @@ screenWidth_mm = canvasWidthCssPx * 25.4 / dpi
|
||||
N (1:N) = frustumWidth_mm / screenWidth_mm
|
||||
```
|
||||
- **Nur bei Orthografie** sinnvoll; in Perspektive zeigt die UI „—" (wie DOSSIER).
|
||||
- **DPI:** Browser kennt das nativ — `dpi = 96 * window.devicePixelRatio` (CSS
|
||||
definiert 1 px = 1/96 inch). Das ersetzt DOSSIERs CoreGraphics-JXA-Detection
|
||||
komplett und ist exakter. Optional manuell kalibrierbar (Eingabe in den
|
||||
Settings), persistiert pro Projekt.
|
||||
- **DPI:** fest `dpi = 96` (CSS definiert 1 CSS-px = 1/96 inch, UNABHÄNGIG von
|
||||
`window.devicePixelRatio` — der Browser hält CSS-px physisch konstant, egal
|
||||
wie viele Geräte-px auf Retina/HiDPI dahinterstecken; `devicePixelRatio` mit
|
||||
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 /
|
||||
1000`; bei `THREE.OrthographicCamera` `camera.zoom = canvasWidthCssPx /
|
||||
(frustumWidth_world / metersPerPixelAtZoom1)` bzw. direkt `left/right` setzen.
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
{
|
||||
"name": "cad",
|
||||
"name": "dossier",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cad",
|
||||
"name": "dossier",
|
||||
"version": "0.0.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@mlightcad/libredwg-web": "^0.7.7",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.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",
|
||||
"dxf-parser": "^1.1.2",
|
||||
"jspdf": "^4.2.1",
|
||||
@@ -1862,6 +1867,42 @@
|
||||
"@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": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.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",
|
||||
"dxf-parser": "^1.1.2",
|
||||
"jspdf": "^4.2.1",
|
||||
|
||||
@@ -39,6 +39,10 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "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_json = "1"
|
||||
geometry = { path = "geometry" }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"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"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
@@ -16,7 +16,16 @@
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:menu:default",
|
||||
"core:event:default",
|
||||
"dialog:allow-open",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 879 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 788 B |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -1711,9 +1711,23 @@ impl Renderer {
|
||||
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
|
||||
// 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 == RenderStyle::Textured {
|
||||
// Texturierter Pfad: eigene Pipeline + zweiter Vertex-Puffer
|
||||
@@ -1730,7 +1744,9 @@ impl Renderer {
|
||||
pass.draw_indexed(0..t.index_count, 0, 0..1);
|
||||
}
|
||||
} 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
|
||||
} else {
|
||||
&self.pipeline
|
||||
@@ -1798,11 +1814,11 @@ impl Renderer {
|
||||
pass.draw(0..cl.vertex_count, 0..1);
|
||||
}
|
||||
|
||||
// 2) Modell-Kanten (wireframe/hidden) — LineList-Pipeline (wie Grid).
|
||||
// Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten)
|
||||
// Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne
|
||||
// Flaechen -> alle Kanten sichtbar.
|
||||
if self.style.draws_edges() {
|
||||
// 2) Modell-Kanten (wireframe/hidden, ODER erzwungen bei aktivem
|
||||
// Schnitt, s.o.) — LineList-Pipeline (wie Grid). Tiefengetestet
|
||||
// gegen die (gebiasten) Flaechen -> verdeckte Kanten fallen weg.
|
||||
// Im Wireframe ohne Flaechen -> alle Kanten sichtbar.
|
||||
if self.style.draws_edges() || force_edges_for_section {
|
||||
if let Some(e) = &self.edges {
|
||||
pass.set_pipeline(&self.grid_pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
|
||||
@@ -72,6 +72,18 @@ pub fn run() {
|
||||
// im Tauri-Fenster einen echten Save-Dialog zeigen statt still zu laden.
|
||||
.plugin(tauri_plugin_dialog::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).
|
||||
.manage(lock::LockState::default())
|
||||
.setup(|_app| {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Dossier",
|
||||
"productName": "dossier",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ch.dossier.cad",
|
||||
"build": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Dossier",
|
||||
"title": "dossier",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"dragDropEnabled": false,
|
||||
@@ -27,6 +27,21 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,17 @@ export class CommandEngine {
|
||||
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 =
|
||||
* phase + Feld-IDs + lastPoint. Der lastPoint-Teil sorgt dafür, dass ein
|
||||
|
||||
@@ -140,7 +140,7 @@ export const de = {
|
||||
"menu.native.view": "Ansicht",
|
||||
"menu.native.window": "Fenster",
|
||||
"menu.native.panels": "Panels",
|
||||
"menu.native.about": "Über Dossier",
|
||||
"menu.native.about": "Über dossier",
|
||||
"menu.native.quit": "Beenden",
|
||||
|
||||
// ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
|
||||
@@ -1318,12 +1318,22 @@ export const de = {
|
||||
"exportSave.fmt.stl": "STL (3D-Mesh)",
|
||||
"about.title": "Über dossier",
|
||||
"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.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.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 ──────────────────────────────────────────────────
|
||||
"topbar.settings": "Einstellungen",
|
||||
@@ -1344,6 +1354,19 @@ export const de = {
|
||||
"settings.referenceElevation": "Referenzhöhe EG (m ü. M.)",
|
||||
"settings.referenceElevation.hint":
|
||||
"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) ──────────────────────────────────
|
||||
"drop.hint": "DXF/DWG-Datei hier ablegen",
|
||||
|
||||
@@ -138,7 +138,7 @@ export const en: Record<TranslationKey, string> = {
|
||||
"menu.native.view": "View",
|
||||
"menu.native.window": "Window",
|
||||
"menu.native.panels": "Panels",
|
||||
"menu.native.about": "About Dossier",
|
||||
"menu.native.about": "About dossier",
|
||||
"menu.native.quit": "Quit",
|
||||
|
||||
// Layout menu.
|
||||
@@ -1305,12 +1305,22 @@ export const en: Record<TranslationKey, string> = {
|
||||
"exportSave.fmt.stl": "STL (3D mesh)",
|
||||
"about.title": "About dossier",
|
||||
"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.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.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.
|
||||
"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.hint":
|
||||
"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).
|
||||
"drop.hint": "Drop a DXF/DWG file here",
|
||||
|
||||
@@ -22,6 +22,12 @@ export type MenuState =
|
||||
| { kind: "viewport"; wallId: string | null; x: number; y: number }
|
||||
| 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). */
|
||||
export function menuTitle(menu: NonNullable<MenuState>, project: Project): string {
|
||||
switch (menu.kind) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export function ResourceWindowApp() {
|
||||
return (...args: unknown[]) => {
|
||||
void import("@tauri-apps/api/event").then(({ emitTo }) =>
|
||||
emitTo("main", "resource-command", { name: prop as HandlerName, args }).catch(
|
||||
() => {},
|
||||
(err) => console.error("[resource-window] emit resource-command failed", prop, err),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -319,14 +319,27 @@ export function useNativeAppMenu(
|
||||
|
||||
// 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.
|
||||
// „Ü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) —
|
||||
// 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({
|
||||
text: "Dossier",
|
||||
text: "dossier",
|
||||
items: [
|
||||
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"),
|
||||
}),
|
||||
await PredefinedMenuItem.new({ item: "Separator" }),
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function openContextImportWindowNative(): Promise<void> {
|
||||
}
|
||||
new WebviewWindow(CONTEXT_IMPORT_WINDOW_LABEL, {
|
||||
url: "index.html?window=context-import",
|
||||
title: "Standort importieren — Dossier",
|
||||
title: "Standort importieren — dossier",
|
||||
width: 480,
|
||||
height: 680,
|
||||
minWidth: 400,
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function openDrawingLevelsWindowNative(): Promise<void> {
|
||||
}
|
||||
new WebviewWindow(DRAWING_LEVELS_WINDOW_LABEL, {
|
||||
url: "index.html?window=drawing-levels",
|
||||
title: "Zeichnungsebenen — Dossier",
|
||||
title: "Zeichnungsebenen — dossier",
|
||||
width: 340,
|
||||
height: 560,
|
||||
minWidth: 260,
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function openLayerSettingsWindowNative(code: string): Promise<void>
|
||||
}
|
||||
new WebviewWindow(LAYER_SETTINGS_WINDOW_LABEL, {
|
||||
url: `index.html?window=layer-settings&code=${encodeURIComponent(code)}`,
|
||||
title: "Ebeneneinstellungen — Dossier",
|
||||
title: "Ebeneneinstellungen — dossier",
|
||||
width: 360,
|
||||
height: 420,
|
||||
minWidth: 300,
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function openResourceWindowNative(initialTab?: TabId): Promise<void
|
||||
"index.html?window=resources" + (initialTab ? `&tab=${encodeURIComponent(initialTab)}` : "");
|
||||
new WebviewWindow(RESOURCE_WINDOW_LABEL, {
|
||||
url,
|
||||
title: "Ressourcen — Dossier",
|
||||
title: "Ressourcen — dossier",
|
||||
width: 780,
|
||||
height: 660,
|
||||
minWidth: 480,
|
||||
@@ -85,7 +85,11 @@ export function useResourceWindowHost(
|
||||
unlisten.push(
|
||||
await listen<{ name: string; args: unknown[] }>("resource-command", (e) => {
|
||||
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(
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function openSettingsWindowNative(): Promise<void> {
|
||||
}
|
||||
new WebviewWindow(SETTINGS_WINDOW_LABEL, {
|
||||
url: "index.html?window=settings",
|
||||
title: "Einstellungen — Dossier",
|
||||
title: "Einstellungen — dossier",
|
||||
width: 520,
|
||||
height: 640,
|
||||
minWidth: 420,
|
||||
|
||||
@@ -157,15 +157,16 @@ export function DrawingLevelsPanel() {
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
const host = usePanelHost();
|
||||
return (
|
||||
<button
|
||||
className="nav-settings"
|
||||
aria-label={t("topbar.settings")}
|
||||
title={t("topbar.settings")}
|
||||
onClick={host.onOpenSettings}
|
||||
aria-label={t("nav.drawingLevelsWindow")}
|
||||
title={t("nav.drawingLevelsWindow")}
|
||||
onClick={host.onOpenDrawingLevelsWindow}
|
||||
>
|
||||
<span className="material-symbols-outlined" aria-hidden="true">
|
||||
settings
|
||||
|
||||
@@ -175,7 +175,9 @@ function CategoryRow({
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function LayersHeaderActions() {
|
||||
const host = usePanelHost();
|
||||
@@ -183,9 +185,11 @@ export function LayersHeaderActions() {
|
||||
<>
|
||||
<button
|
||||
className="nav-settings"
|
||||
onClick={host.onOpenSettings}
|
||||
aria-label={t("topbar.settings")}
|
||||
title={t("topbar.settings")}
|
||||
onClick={(e) =>
|
||||
host.onOpenLayerSettings(host.activeCategoryCode, e.clientX, e.clientY)
|
||||
}
|
||||
aria-label={t("ctx.layerSettings")}
|
||||
title={t("ctx.layerSettings")}
|
||||
>
|
||||
<span className="material-symbols-outlined" aria-hidden="true">
|
||||
settings
|
||||
|
||||
@@ -117,13 +117,19 @@ export interface PanelHostValue {
|
||||
* Löschen) und hält das offene Menü.
|
||||
*/
|
||||
onLayerContextMenu: (code: string, clientX: number, clientY: number) => void;
|
||||
/** Öffnet die Ebeneneinstellungen direkt (Zahnrad-Symbol je Zeile) — unter
|
||||
* Tauri als natives Fenster, sonst derselbe InlineEditor-Popover wie über
|
||||
* das Kontextmenü (siehe App.tsx `openLayerSettings`). */
|
||||
/** Öffnet die Ebeneneinstellungen direkt (Zahnrad-Symbol je Zeile bzw.
|
||||
* Panel-Kopfzeile, dort für die aktive Ebene) — unter Tauri als natives
|
||||
* Fenster, sonst derselbe InlineEditor-Popover wie über das Kontextmenü
|
||||
* (siehe App.tsx `openLayerSettings`). */
|
||||
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) —
|
||||
* 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;
|
||||
|
||||
// Ressourcen-Handler (Bauteile / Schraffuren / Linienstile). Spiegelt
|
||||
|
||||
@@ -78,13 +78,19 @@ function visibleEdgeRuns(scr: Vec2[], noStroke: number[]): Vec2[][] {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* in mm Papier definiert und werden über diese dpi in konstante Bildschirm-Pixel
|
||||
* umgerechnet (zusammen mit vector-effect:non-scaling-stroke bleiben sie beim
|
||||
* Zoomen papierkonstant). Live wird zusätzlich gelesen, falls sich der Monitor
|
||||
* (devicePixelRatio) ändert.
|
||||
* dpi = 96 (CSS definiert 1 CSS-px = 1/96 inch, UNABHÄNGIG von devicePixelRatio —
|
||||
* das gilt genauso auf Retina/HiDPI-Displays und bei OS-Skalierung, da der
|
||||
* Browser CSS-px genau dafür physisch konstant hält, egal wie viele Geräte-px
|
||||
* dahinterstecken). Strichstärken sind in mm Papier definiert und werden über
|
||||
* 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). */
|
||||
const mmToPx = (mm: number): number => (mm / 25.4) * dpi();
|
||||
|
||||
|
||||
@@ -108,8 +108,8 @@ const MONO_INK = "#111111";
|
||||
|
||||
// ── 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
|
||||
// dpi = 96·devicePixelRatio in konstante Bildschirm-Pixel um (non-scaling-stroke),
|
||||
// damit Linien beim Zoomen ihre Papiergröße behalten und der Massstab stimmt.
|
||||
// dpi = 96 in konstante Bildschirm-Pixel um (non-scaling-stroke), damit Linien
|
||||
// beim Zoomen ihre Papiergröße behalten und der Massstab stimmt.
|
||||
|
||||
/** Default-Schichtfuge zwischen den Materialschichten in mm — Haarlinie (0.02),
|
||||
* gleiche Stärke wie die Schraffur-Tinte. Pro Fuge im Wandstil überschreibbar
|
||||
|
||||
@@ -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).toBeGreaterThan(2.8); // nicht mehr als die Dachdicke gekappt
|
||||
}
|
||||
// Am First (y nahe 2): unverändert volle Höhe (Dachunterkante liegt weit darüber).
|
||||
const nearRidge = walls.filter((s) => s.start[1] > 1.8 && s.start[1] < 2.2);
|
||||
// Am First (y=2): unverändert volle Höhe. Die ungeклippten Achsenstücke gleicher
|
||||
// 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);
|
||||
for (const seg of nearRidge) {
|
||||
expect(seg.height).toBeCloseTo(3.0, 6);
|
||||
@@ -1886,13 +1888,14 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
|
||||
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 { meshes } = projectToModel3d(project);
|
||||
const roofMeshes = meshes.filter(isRoofMesh);
|
||||
// Einschichtiges Dach (thickness-Fallback): 1 Schicht-Slab-Mesh (Flächen) +
|
||||
// 1 Giebel-Mesh (Endfüllung) = 2 Roof-Meshes (beide ROOF_RGB).
|
||||
expect(roofMeshes.length).toBe(2);
|
||||
// Einschichtiges Dach (thickness-Fallback): NUR das Schicht-Slab-Mesh der
|
||||
// geneigten Flächen. Die Giebel-Endfüllung wird bewusst nicht mehr emittiert
|
||||
// (der Giebel entsteht aus der Wand darunter) -> 1 Roof-Mesh.
|
||||
expect(roofMeshes.length).toBe(1);
|
||||
for (const m of roofMeshes) {
|
||||
expect(m.positions.every((v) => Number.isFinite(v))).toBe(true);
|
||||
expect(m.indices.length % 3).toBe(0);
|
||||
@@ -2024,9 +2027,9 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
|
||||
const noField: Project = { ...sampleProject };
|
||||
delete (noField as { roofs?: unknown }).roofs;
|
||||
expect(projectToModel3d(noField).meshes.filter(isRoofMesh).length).toBe(0);
|
||||
// sampleProject enthält ein Demo-Satteldach (RF1, einschichtig) -> 2 Roof-
|
||||
// Meshes: Schicht-Slab (Flächen) + Giebel-Endfüllung.
|
||||
expect(projectToModel3d(sampleProject).meshes.filter(isRoofMesh).length).toBe(2);
|
||||
// sampleProject enthält ein Demo-Satteldach (RF1, einschichtig) -> 1 Roof-
|
||||
// Mesh: nur der Schicht-Slab der Flächen (keine Giebel-Endfüllung mehr).
|
||||
expect(projectToModel3d(sampleProject).meshes.filter(isRoofMesh).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1099,18 +1099,32 @@ function clipPieceToRoofs(
|
||||
const span = bb - a;
|
||||
const steps = Math.max(1, Math.ceil(span / ROOF_CLIP_STEP));
|
||||
const stepLen = span / steps;
|
||||
// Je Stufe die geklemmte Oberkante bestimmen …
|
||||
const tops: number[] = [];
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const sa = a + i * stepLen;
|
||||
const sb = a + (i + 1) * stepLen;
|
||||
const mid = (sa + sb) / 2;
|
||||
const mid = a + (i + 0.5) * stepLen;
|
||||
const p = add(add(wall.start, scale(u, mid)), scale(n, bandOffset));
|
||||
let clip = zt;
|
||||
for (const { geo, total } of roofData) {
|
||||
const under = roofUndersideAt(geo, total, p.x, p.y);
|
||||
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);
|
||||
runStart = i;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1932,19 +1946,13 @@ function emitRoofs(project: Project): RMesh[] {
|
||||
if (indices.length > 0) out.push({ positions, indices, kind: "extrusion", color, materialIndex });
|
||||
return offsetAbove + t;
|
||||
}, 0);
|
||||
// Giebel (Endfüllung) als dünne Fläche, äusserste Schichtfarbe/Override.
|
||||
if (g.gables.length > 0) {
|
||||
const gp: number[] = [];
|
||||
const gi: number[] = [];
|
||||
for (const gable of g.gables) pushFanTriangles(gp, gi, gable);
|
||||
if (gi.length > 0) {
|
||||
const first = layers[0];
|
||||
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 });
|
||||
}
|
||||
}
|
||||
// Giebel-ENDFÜLLUNG bewusst NICHT mehr emittieren: die dreieckige Giebelfläche
|
||||
// wurde früher als papierdünne Einzelfläche gebaut ("komischer Abschluss" an
|
||||
// den Sattel-/Mansard-Enden) und lag zudem deckungsgleich vor der Giebelwand,
|
||||
// die ohnehin bis an die Dach-Unterkante hochgeklemmt wird (s.
|
||||
// {@link clipPieceToRoofs}) — also doppelt und optisch als dünne 2D-Schicht
|
||||
// sichtbar. Es wird nur noch das Dach SELBST (die geneigten Slab-Flächen)
|
||||
// konstruiert; der Giebel entsteht aus der Wand darunter.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -630,9 +630,9 @@ body {
|
||||
border: 1px solid var(--border, #3a3f47);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||
color: var(--ink, #e6e8ea);
|
||||
/* Der Dialog ist ein DOM-Kind der `.topbar` (white-space: nowrap) — ohne
|
||||
dieses Reset liefe der mehrzeilige Beschreibungstext einzeilig aus dem
|
||||
Dialog heraus. Hier wieder normalen Zeilenumbruch erzwingen. */
|
||||
/* Defensiv gesetzt (unabhängig davon, ob ein Vorfahre white-space: nowrap
|
||||
setzt) — ohne dieses Reset liefe der mehrzeilige Beschreibungstext
|
||||
einzeilig aus dem Dialog heraus. */
|
||||
white-space: normal;
|
||||
}
|
||||
.about-brand {
|
||||
@@ -676,9 +676,15 @@ body {
|
||||
line-height: 1.6;
|
||||
color: var(--label, #c4c8cd);
|
||||
}
|
||||
.about-close {
|
||||
appearance: none;
|
||||
.about-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.about-close,
|
||||
.about-check-update {
|
||||
appearance: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #3a3f47);
|
||||
@@ -687,9 +693,14 @@ body {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.about-close:hover {
|
||||
.about-close:hover,
|
||||
.about-check-update:hover:not(:disabled) {
|
||||
background: var(--accent-dim, #33383f);
|
||||
}
|
||||
.about-check-update:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-weight: 500;
|
||||
@@ -5392,6 +5403,35 @@ body {
|
||||
outline: none;
|
||||
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 ──────────────────────────────────────────────
|
||||
Dezenter Rahmen + Hinweis, solange eine Datei über die App gezogen wird. */
|
||||
|
||||
@@ -66,6 +66,13 @@ export interface CommandLineHandle {
|
||||
* Zeichnen, ohne dass das Feld fokussiert war): fokussiert + setzt den Text.
|
||||
*/
|
||||
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>(
|
||||
@@ -87,6 +94,7 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
|
||||
setText(seed);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
blur: () => inputRef.current?.blur(),
|
||||
}));
|
||||
|
||||
// Autocomplete nur im Ruhezustand (Befehlsname tippen), nicht in einem Schritt.
|
||||
|
||||
@@ -977,7 +977,7 @@ function ComponentsTab({
|
||||
<div className="res-md-empty">{t("resources.components.empty")}</div>
|
||||
)}
|
||||
<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")}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1246,7 +1246,7 @@ function HatchesTab({
|
||||
<div className="res-md-empty">{t("resources.hatches.empty")}</div>
|
||||
)}
|
||||
<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")}
|
||||
</button>
|
||||
<FileImportButton
|
||||
@@ -1549,7 +1549,7 @@ function LinesTab({
|
||||
<div className="res-md-empty">{t("resources.lines.empty")}</div>
|
||||
)}
|
||||
<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")}
|
||||
</button>
|
||||
<FileImportButton
|
||||
@@ -1809,7 +1809,7 @@ function LayeredStylesTab({
|
||||
<div className="res-md-empty">{t(emptyKey)}</div>
|
||||
)}
|
||||
<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")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,25 @@ import { t } from "../i18n";
|
||||
import { ACCENT_SWATCHES } from "../theme/accents";
|
||||
import { ColorHexField } from "./ColorHexField";
|
||||
import { LayoutMenu } from "./LayoutMenu";
|
||||
import { APP_VERSION } from "./TopBar";
|
||||
import type { RendererMode } from "./StatusBar";
|
||||
import type { LayoutState } from "../panels/types";
|
||||
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 {
|
||||
/** Hell/Dunkel — Standard ist Hell (siehe theme/themeMode.ts). */
|
||||
@@ -71,6 +87,22 @@ export function SettingsDialog({
|
||||
onClose,
|
||||
windowed = false,
|
||||
}: 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(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -208,6 +240,97 @@ export function SettingsDialog({
|
||||
/>
|
||||
<div className="imp-note">{t("settings.referenceElevation.hint")}</div>
|
||||
</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>
|
||||
|
||||
{!windowed && (
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
import type { Project } from "../model/types";
|
||||
import { t } from "../i18n";
|
||||
import { checkForUpdate } from "../update/checkForUpdate";
|
||||
import { getRunningVersion } from "../update/version";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
@@ -1020,6 +1022,11 @@ export const APP_VERSION = "0.1.0";
|
||||
* Wortmarke. Klick ausserhalb / Esc schliesst.
|
||||
*/
|
||||
function AboutDialog({ onClose }: { onClose: () => void }) {
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
const [runningVersion, setRunningVersion] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
void getRunningVersion().then(setRunningVersion);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -1028,7 +1035,14 @@ function AboutDialog({ onClose }: { onClose: () => void }) {
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [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-dialog"
|
||||
@@ -1041,7 +1055,7 @@ function AboutDialog({ onClose }: { onClose: () => void }) {
|
||||
dossier<span className="brand-dot">.</span>
|
||||
</div>
|
||||
<div className="about-version">
|
||||
{t("about.version")} {APP_VERSION}
|
||||
{t("about.version")} {runningVersion ?? APP_VERSION}
|
||||
</div>
|
||||
<p className="about-desc">{t("about.desc")}</p>
|
||||
<div className="about-meta">
|
||||
@@ -1059,11 +1073,29 @@ function AboutDialog({ onClose }: { onClose: () => void }) {
|
||||
<li>LibreDWG — GPL-3.0</li>
|
||||
<li>acadrust — MPL-2.0</li>
|
||||
</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}>
|
||||
{t("about.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||