8 Commits

Author SHA1 Message Date
karim 9e73c684c3 App.tsx: Hell/Dunkel-State nach theme/useThemeMode.ts
Reiner Umzug von useState + Setter-Wrapper (persistiert in localStorage via
themeMode.ts) in einen eigenen Hook. Verhaltensgleich.
2026-07-21 16:24:33 +02:00
karim 6637533162 App.tsx: Dialog-Sichtbarkeits-State nach state/useDialogState.ts
Reiner Umzug von neun useState-Deklarationen (Ressourcen-Fenster, Layout-
Ansicht, Import-/Export-/Einstellungs-Dialoge, Drop-Feedback) in einen
eigenen Hook — bewusst KEIN Store-Slice (dieser State ist laut den
Original-Kommentaren explizit lokal/transient, keine Undo/Redo- oder
Cross-Component-Relevanz). Verhaltensgleich.
2026-07-21 16:15:59 +02:00
karim 3b79e49cc6 App.tsx entschlackt: Kontextmenüs nach src/menus/, Views nach src/views/
Reiner Umzug (verhaltensgleich, byte-identische Funktionskörper): Kontextmenü-
Builder (buildMenuItems + layer/level/plan/viewport-Menüs) nach
src/menus/contextMenu.tsx; InlineEditor sowie Content/LevelPlanView/
SectionPlanView (der View-Router der Hauptansicht) nach src/views/. App.tsx
7130 → 5917 Zeilen. Setzt die in CONVENTIONS.md/state-architecture.md
vorgesehene, bisher nie umgesetzte Struktur endlich um (STATUS.md §4.3).

Dazu ein unabhängiger CSS-Fix: .plan-selected (Auswahl-Hervorhebung im 2D-Plan)
fehlte die fill-opacity, die zwei Regeln weiter unten bei .plan-marquee/
.tool-preview-fill schon gesetzt ist. Im Dunkel-Theme ist --accent-dim
transparent (zufällig unauffällig), im neuen Standard-Hell-Theme aber fast
deckend weiss — dadurch verdeckte die Auswahl die Schraffur der gewählten
Wand/Decke/des Raums vollständig.
2026-07-21 14:47:40 +02:00
karim a6c2c04736 Doku: STATUS.md (Codebase-Analyse) + Kern-Docs an den Ist-Zustand angeglichen
Vollständige Bestandsaufnahme der Codebasis als neue STATUS.md (Kennzahlen,
Feature-Inventar, Mist-Liste: toter Code, verwaiste WASM-Crates,
Doku-Widersprüche). ARCHITECTURE.md/README.md/CONVENTIONS.md waren noch auf
dem Tag-1-Planungsstand (Electron/Three.js/OpenCascade/Zustand/HLR-Worker) und
beschrieben nicht mehr, was tatsächlich gebaut wurde (eigene Rust/WASM-Engines,
eigener Store, analytische Rust-Schnitt-Pipeline, Tauri auf macOS + Electron
auf Linux). ROADMAP.md und HANDOVER.md als historisch markiert (Hinweis-Box),
Inhalt unverändert.
2026-07-21 13:35:58 +02:00
karim 956a85d93f Materialbibliothek: statisches Broad-Set entfernt, Live-ambientCG bleibt
Der aus der Desktop-Session übernommene statische 88er-Katalog listete 75
Materialien ohne mitgelieferte Texturen (kaputte Thumbnails in der
Schnellauswahl). Bibliothek auf die 13 tatsächlich gebündelten Starter
gekürzt; Fetch-Script und der zugehörige .gitignore-Block entfernt. Alles
darüber hinaus läuft über die bereits vorhandene Live-Suche (1K/2K/4K).
2026-07-21 13:35:44 +02:00
karim f95674aa09 Fix: doppelten RoofingTiles013A-Eintrag nach Rebase entfernt 2026-07-20 11:05:08 +02:00
karim 2ac5c27fa7 Materials-Feature: Bibliothek + Fetch-Script + Manifest (aus Desktop-WIP übernommen) 2026-07-20 11:03:30 +02:00
karim 68a0459d0e Schnittebenen 2D↔3D Phase 1+2, native Fenster, Hell/Dunkel-Theme, SWISSIMAGE-Import
- Schnittebenen: 3D-Live-Schnitt folgt der gewählten Grundriss-Schnittlinie
  (section3dCutId/sectionPlaneFromLevel), Schalter "Im 3D schneiden" im
  Objekt-Info, Doppelklick auf Schnittlinie springt in 2D-Schnittansicht,
  unsichtbare Ebenen blenden ihre Führungslinie aus
- Eigene native Tauri-Fenster für Kontext-Import/Zeichnungsebenen/
  Ebenen-Einstellungen/Ressourcen/Einstellungen + klassische Menüleiste
  (AppMenuBar) neben der Wortmarke
- Hell/Dunkel-Umschalter (Einstellungen → Darstellung), persistiert,
  flackerfrei vor erstem Render gesetzt
- SWISSIMAGE-Luftbild-Import (swisstopo WMS) als Kontext-Hintergrundebene
- UI-Politur: Werkzeug-Panel Symbole/Liste umschaltbar, Topbar-Quick-Access-
  Icons entfernt, Zahnrad→Einstellungen in Panel-Köpfen, Footerbar/
  Snap-Marker/Maß-HUD auf helle Pillen-Sprache umgestellt
- Neues Dachziegel-Material (RoofingTiles013A)
2026-07-20 10:51:37 +02:00
103 changed files with 9897 additions and 3560 deletions
+4
View File
@@ -11,3 +11,7 @@ scripts/*.png
# Editor / OS # Editor / OS
.DS_Store .DS_Store
*.log *.log
# Generierte native-Viewport-Szenen (aus sampleProject via scripts/dump-native-scene.mjs)
src-tauri/assets/native2d_scene.json
src-tauri/assets/native3d_walls.json
+297 -359
View File
@@ -1,452 +1,390 @@
# Architektur — Standalone Browser-BIM (cad) # Architektur — Dossier (Desktop-CAAD)
> Stand: 2026-06-29 > Stand: 2026-07-21 (grundlegend überarbeitet — siehe [STATUS.md](STATUS.md) für
> Vision, Phasen und Backlog: [ROADMAP.md](ROADMAP.md). Konventionen: [CONVENTIONS.md](CONVENTIONS.md). > die volle Bestandsaufnahme inkl. Mist-Liste, die diese Überarbeitung begründet).
> Detail-Designs: [docs/design/elements.md](docs/design/elements.md) · > Vision/Phasen (historisch, Tag-1-Stand): [ROADMAP.md](ROADMAP.md). Konventionen:
> [docs/design/plans-output.md](docs/design/plans-output.md) · > [CONVENTIONS.md](CONVENTIONS.md). Detail-Designs (teils ebenfalls veraltet,
> [docs/design/resources-graphics.md](docs/design/resources-graphics.md). > siehe Hinweis in [docs/README.md](docs/README.md)): [docs/design/](docs/design/).
Dieses Dokument beschreibt, **wie** die Standalone-Browser-App gebaut wird und Dieses Dokument beschreibt, **wie** Dossier tatsächlich gebaut ist — nicht wie
wie sie die Konzepte des DOSSIER-Rhino-Plugins in Browser-Äquivalente übersetzt. es am ersten Tag geplant war. Dossier ist die eigenständige Neuimplementierung
DOSSIER ist ein Rhino-8-Plugin (Python + React-WebView); `cad` ist die *eigen­ des Rhino-Plugins [DOSSIER](https://git.openbureau.ch/karim/dossier): dieselbe
ständige* Browser-Variante: kein Rhino-Document, kein `doc.Strings`, kein Denkweise (Geschosse, Kategorie-Ebenen, mehrschichtige Bauteile,
IronPython — stattdessen ein **eigenes typisiertes Datenmodell in TypeScript**, Prioritäts-Verschneidung), aber als **native Desktop-App** mit einem **eigenen
gerendert über **Three.js** (3D) und **SVG** (Plan), gespeichert als **JSON-Datei**. typisierten Datenmodell in TypeScript** und **zwei eigenen Rust/WASM-Rendering-
Engines** („Nordstern") statt Rhino-Dokument/IronPython.
Alle Bezeichner im Code sind **englisch** (Vectorworks-Terminologie); Prosa und **Desktop-Rahmen (plattformabhängig, wegen WebGPU):** auf **macOS Tauri**
UI-Texte sind deutsch. Einheiten intern in **Metern**. (WKWebView unterstützt WebGPU), auf **Linux Electron/Chromium** (Tauris
Linux-Webview WebKitGTK unterstützt WebGPU nicht zuverlässig — die
render2d/render3d-Engines brauchen es). Beide teilen dieselbe React-App und
randlose Titelleiste; Laufzeit-Erkennung über `window.__TAURI__` bzw.
`window.dossierWindow` (Electron-`contextBridge`). Details: STATUS.md §2.7.
Alle Bezeichner im Code sind **englisch**; Prosa und UI-Texte sind deutsch.
Einheiten intern in **Metern**.
--- ---
## 0. Leitprinzip — ein Modell, viele Darstellungen ## 0. Leitprinzip — ein Modell, viele Darstellungen
Das semantische Gebäudemodell ist die **einzige Wahrheit**. Jede Sicht (3D, Das semantische Gebäudemodell (`Project`) ist die **einzige Wahrheit**. Jede
Grundriss, Schnitt, Ansicht) ist eine **reine Ableitung** (`derive`) daraus. Sicht (3D, Grundriss, Schnitt, Ansicht) ist eine **reine Ableitung** daraus.
Darstellung (Detailgrad, Stile, Schraffuren, Overrides) wird **beim Rendern** Darstellung (Detailgrad, Stile, Schraffuren, Overrides) wird **beim Rendern**
angewandt, nie in die Geometrie eingebacken. angewandt, nie in die Geometrie eingebacken. Dieses Prinzip hat sich über drei
Wochen und ~125.000 Zeilen Code bewährt und wird strikt gehalten — es ist der
einzige Teil der ursprünglichen Architektur-Vision, der **unverändert** Bestand
hat. Alle konkreten Technologie-Entscheidungen darunter (Rendering-Engine,
State-Store, Schnitt-Mechanismus) sind anders gelaufen als am Tag 1 geplant;
Details dazu in [STATUS.md](STATUS.md) §5.
``` ```
┌──────────────────────────────────────────┐ ┌──────────────────────────────────────────┐
│ Project (semantisches Modell, JSON) │ ← einzige Wahrheit │ Project (semantisches Modell, JSON) │ ← einzige Wahrheit
│ resources · types · designLevels · │ resources · types · drawingLevels · │
│ layers · elements │ layers · walls/doors/openings/stairs/…
└──────────────┬───────────────────────────┘ └──────────────┬───────────────────────────┘
│ pure derive() │ pure derive()
┌───────────────────────┼────────────────────────────┐ ┌───────────────────────┼────────────────────────────┬───────────────
▼ ▼ ▼ ▼ ▼ ▼
Scene3D (Three.js) PlanModel → SVG SectionModel → SVG 3D-Viewport 2D-Plan (generatePlan) 3D-Live-Schnitt Export
buildScene() generatePlan() generateSection() (HLR) Viewport3D (three.js) PlanView (SVG) · glPlan (GL2) render3d/section IFC/DXF/
│ │ │ ODER Wasm3DViewport ODER render2d (Rust/WGSL) (Rust, analytisch)PDF/STL
└────────── beide lesen dieselben joins/components/styles ──────────┘ (Rust/wgpu, Default)
│ │ │ │
└────────── alle lesen dieselben joins/components/styles ─────────────┘
``` ```
Das steht im Spike bereits: `generatePlan.ts` und `Viewport3D.tsx` extrudieren
**dasselbe** gehrte Band-Polygon (`clippedBand`). Diese Symmetrie ist der Kern
und wird beim Ausbau strikt gehalten.
--- ---
## 1. Repo-Struktur (Ziel) ## 1. Repo-Struktur (IST-Zustand)
Wächst aus dem heutigen `src/` (model/plan/viewport/ui). Module sind **klein und
fachlich geschnitten** — wir vermeiden bewusst den `elemente.py`-Monolithen
(7244 LOC) aus DOSSIER (dort als Schwachstelle #4.1 dokumentiert).
``` ```
src/ src/
model/ model/ Project-Schema (types.ts, ~2500 LOC), joins.ts (Wand-Gehrung/
types.ts // Project, DesignLevel, LayerCategory, Element-Union (existiert) -Prio-Stösse), parametricWalls.ts, roomStamp.ts, terrain.ts,
geometry.ts // 2D/3D-Mathe ohne Kernel (existiert) geoRebase.ts, sampleProject.ts
joins.ts // Wand-Verschneidung (Gehrung; später Prio-T/X) (existiert) geometry/ 2D-Kernel (kernel2d.ts: offset/trim/fillet/split — LIVE, siehe
sampleProject.ts // Demo-Haus (existiert) §3), ceiling/opening/roomArea/roomBoundary/stair/roof/column.ts,
project.ts // Factory, Defaults, Migrationen polygonHoles.ts
selectors.ts // abgeleitete Reads (visibleCodes, elementsOnLevel…) commands/ Rhino-artiges Kommandosystem: types/registry/engine/parseInput.ts
ids.ts // newId(prefix) — UUID-Erzeugung + cmds/ (ein Modul je Kommando: wall, opening, stair, roof,
elements/ // pro Bauteil ein Modul (Daten + Generierung) column, room, line/rect/circle/arc, move/mirror/copy/offset/
wall.ts opening.ts slab.ts stair.ts roof.ts structure.ts space.ts trim/join, extrude, import, terrain, measure, georef, …)
resources/ tools/ Interaktive Zeichenwerkzeuge, snapping.ts, transform.ts (Grips)
componentManager.ts hatchManager.ts lineManager.ts symbolLibrary.ts compute/ Compute-Boundary: leitet Ops (aktuell nur computeJoins) unter
overrides.ts // regelbasierte Engine (Condition → Action) Tauri via `invoke` an Rust weiter, sonst TS-Fallback
store/ plan/ generatePlan.ts (2D-Ableitung), PlanView.tsx (SVG + Pan/Zoom/
store.ts // Zustand-Store: { project, ui }, Aktionen, Undo/Redo Grips), glPlan/ (eigener WebGL2-Renderer), toSection.ts/
persistence.ts // Datei save/load (JSON), Autosave (IndexedDB) toElevation.ts (Schnitt/Ansicht-Ableitung), toWalls3d.ts,
history.ts // Undo/Redo-Ring toRenderScene.ts (Szene für Rust-render2d), wallMeshCut.ts
plan/ viewport/ Viewport3D.tsx (three.js, „Free") UND Wasm3DViewport.tsx
generatePlan.ts // Grundriss aus Footprint + Symbolik (existiert) (Rust/wgpu „Nordstern", Default/editierbar), raycast3d.ts
generateSection.ts // Schnitt/Ansicht aus 3D-Projektion (HLR-Worker) section/ TOTER Code (OCCT-WASM-HLR-Spike, keine Aufrufer mehr) — Schnitt
PlanView.tsx // SVG-Renderer + Pan/Zoom/Grips (existiert) läuft über render3d/section.rs, siehe §4.3
primitives.ts // Primitive-Union, SVG-Serializer, DXF/PDF-Export export/ exportIfc.ts (IFC4), exportDxf.ts/dxfWriter.ts, exportPdf.ts,
viewport/ layoutPdf.ts (Mehrseiten-PDF pro Ordner), exportMesh.ts (STL/OBJ),
Viewport3D.tsx // Three.js-Szene aus dem Modell (existiert) exportSchedule.ts (CSV-Bauteilliste), sceneToPrintSvg.ts
scene.ts // buildScene(project) → THREE.Group (Layer-Spiegel) materials/ ambientcg.ts (Live-Suche ambientCG-API), library.ts (13
clip.ts // Plan-/Schnitt-Clipping über THREE.Plane gebündelte Starter-Materialien), runtime.ts (PBR-Material aus
camera.ts // Kamera-Presets, Norden-Rotation ComponentMaterial via three.js TextureLoader)
sheets/ io/ DXF/DWG-Import, Swisstopo (swissBUILDINGS3D/-ALTI3D/SWISSIMAGE,
layout.ts // Sheet/Viewport-Datenmodell LV95↔WGS84), OSM-Overpass, .lin/.pat-Parser, projectFile.ts
SheetEditor.tsx // Plansatz-Editor (.obp Speichern/Laden, Tauri-Lock)
exportPdf.ts // Vektor-PDF (svg → pdf-lib / jsPDF) overrides/ Regelbasierte Override-Engine (Bedingung → Aktion)
workers/ state/ Eigener Store auf `useSyncExternalStore` (KEIN Zustand/Redux):
geometry.worker.ts // Booleans (Öffnungen) + HLR via Comlink store.ts + Slices (project/history/selection/view/layout/site/
ui/ notify), appStore.ts komponiert sie
App.tsx Navigator panels // React-Oberfläche (heute in App.tsx, wird gesplittet) panels/ Dock-/Floating-Panel-System (Dock/FloatingPanel/TabStrip/
registry/layout) + Panels (Tools, Attributes, ObjectInfo, Layers,
DrawingLevels, Site, RoomBalance, Elements, ViewSnapshots, Layouts)
ui/ App.tsx (Shell, **7.130 LOC — noch nicht auf dünne Shell
reduziert**, siehe STATUS.md §4.3), TopBar, ResourceManager.tsx
(Material-/Hatch-/Line-/Typ-Editoren, natives Fenster),
ContextMenu, CommandLine.tsx, LayoutSheet/-Menu, ribbon/
native/ NUR Tauri: native Fenster (Resources/Settings/DrawingLevels/
LayerSettings/ContextImport), macOS-Menüleiste, Fenster-Chrome —
jede Funktion no-opt via `isTauriRuntime()` im Browser
editors/ booleanOps.ts (2D-Boolean via polygon-clipping), splitJoin.ts
text/ Rich-Text (richText.ts, RichTextEditor.tsx, renderHtml.ts)
theme/ Hell/Dunkel, Akzentfarben
i18n/ de.ts/en.ts Wörterbücher, eigener t()-Mechanismus
engine/ Reine WASM-Lade-Glue: engine3d.ts (pkg3d), truckSolid.ts
(pkgTruck), plan/useWasmPlanRenderer.ts (pkg) — pkgGeometry und
pkgDwgImport sind gebaut, aber unbenutzt (siehe STATUS.md §4.1)
src-tauri/
src/ Tauri-Host (Fenster, native Dialoge, fs4-Exklusiv-Lock)
render2d/ 2D-Plan-GPU-Renderer (wgpu/WGSL, + glyphon-Text), auch → WASM
render3d/ „Nordstern" 3D-Engine (wgpu/WGSL): Wandextrusion + Schicht-
bänder + Gehrung, LIVE 2D=3D-Schnitt (section*.rs, analytisch,
kein HLR), Kanten-Extraktion, Materialtextur-Arrays,
Aerial-Drape, Render-Styles (Shaded/White/Textured/Wireframe/
Hidden/ShadedEdges) — auch → WASM
kernel2d/ Rust-Port von geometry/kernel2d.ts — NUR Paritätstest, nicht
produktiv (WASM war < 100 Wänden langsamer als TS)
geometry/ Wand-Join-Mathe — Rust-Port, UNBENUTZT (kein Aufrufer)
trucksolid/ CSG/Extrusion (`truck`-Crate + `csgrs`-Booleans) → WASM,
genutzt vom Extrude-Kommando; Boolean NICHT an Wände/
Öffnungen angeschlossen
dwgimport/ DXF-Parser-Spike (`acadrust`) → WASM, UNBENUTZT (DWG-Import
läuft über npm `@mlightcad/libredwg-web`)
``` ```
Jedes Crate unter `src-tauri/` ausser dem Host ist ein **eigenständiges
Cargo-Package** (kein gemeinsamer Workspace), das sowohl headless
(`cargo test`) als auch per `wasm-pack --features web` baut und dann via
`src/engine/pkg*/` von der TS-Seite geladen wird (`npm run build:engine{,3d,
Geometry,Kernel2d,DwgImport}`/`build:truck`).
--- ---
## 2. Datenmodell ## 2. Datenmodell
### 2.1 Zwei unabhängige Achsen (DOSSIER-Modell, im Spike umgesetzt) ### 2.1 Zwei unabhängige Achsen (unverändert gegenüber der Vision)
DOSSIER trennt zwei orthogonale Konzepte, persistiert als zwei getrennte 1. **Zeichnungsebenen** (`DrawingLevel`) — Geschoss (`kind:"floor"`,
JSON-Bäume (`dossier_zeichnungsebenen`, `dossier_ebenen`). Wir übernehmen das `floorHeight`/`cutHeight`/`baseElevation`), Schnitt/Ansicht
1:1 in `types.ts` (existiert bereits als `drawingLevels` + `layers`): (`kind:"section"|"elevation"`), oder freie Zeichnung (`kind:"drawing"`).
2. **Ebenen** (`LayerCategory`) — das Grafik-Kategorie-Schema (Baum,
`{code, name, color, lw, visible, locked, hatch?, children}`), in jedem
Geschoss gültig. Codes 1:1 aus DOSSIER (`00 Raster · 01 Vermessung ·
20 Wände (└21 Türen/Fenster, 25 Stützen) · 30 Decken · 31 Dächer ·
40 Treppen · 50 Text · 60 Räume · 80 Plangrafik …`).
1. **Zeichnungsebenen** (`DesignLevel` / `DrawingLevel`) — die obersten ### 2.2 Elemente — typisierte Arrays statt `Element[]`-Union
Dokument-Abschnitte. Zwei produktive Arten:
- **Geschoss** (`kind:"floor"`): `floorHeight`, `cutHeight`,
`baseElevation` (akkumuliert via `recomputeFloorElevations`), `visible`/`locked`.
- **Schnitt/Ansicht** (`kind:"section"|"elevation"`): `linePoints`,
`directionSign`, Höhenbereich, Tiefe.
- **Zeichnung** (`kind:"drawing"`): freie 2D-Ebene ohne Geschossbezug.
2. **Ebenen** (`LayerCategory`) — das **Grafik-Kategorie-Schema**, das in *jedem*
Geschoss gilt. Baum mit `{code, name, color, lw, visible, locked, hatch?, children}`.
Codes 1:1 wie DOSSIER (`DEFAULT_LAYER_SCHEMA` aus `launcher/src/App.jsx`):
`00 Raster · 01 Vermessung · 20 Wände (└21 Türen/Fenster, 22 Möbel, 25 Stützen) ·
30 Decken · 31 Dächer · 35 Träger · 50 Text · 60 Plangrafik …`
Ein **Element** kennt sein **Geschoss** (`floorId`) *und* seine **Ebene** Anders als ursprünglich geplant hält `Project` (`src/model/types.ts:2095`)
(`categoryCode`). Sichtbarkeit ergibt sich aus dem Schnitt (Geschoss `geschoss × code` **pro Bauteiltyp ein eigenes (meist optionales) Array**:
Matrix), genau wie DOSSIERs `apply_visibility(z_mode, e_mode)` in `layer_builder.py`.
> **Begriffsnotiz:** Heute heißt der Typ im Code `DrawingLevel`. ROADMAP §5 nennt
> die Modell-Eingabe (Geschosse) Vectorworks-konform **Design Layer** und die
> abgeleitete Ausgabe **Drawing Layer / Sheet**. Wir behalten `DrawingLevel` als
> Union (kind=floor ≙ Design Layer, kind=section/elevation/drawing ≙ Drawing
> Layer) und führen `Sheet` erst separat ein (§2.5, plans-output.md). Kein Rename
> ohne expliziten Auftrag.
### 2.2 Ressourcen-Bibliotheken (Vectorworks-Stil)
Neuer Block `Project.resources` (heute provisorisch als flache `materials[]`).
Alles verweist **per id** — zentral änderbar. Details: resources-graphics.md.
```ts ```ts
interface Resources { interface Project {
lineStyles: LineStyle[]; // Line Manager: { id, name, weight(mm), color, dash:number[] } walls: Wall[];
hatches: Hatch[]; // Hatch Manager: { id, name, pattern, scale, angle, lineStyleId } ceilings?: Ceiling[];
components: Component[]; // Component Manager (= DOSSIER-Material erweitert): roofs?: Roof[];
// { id, name, hatchId, color3d, texture3d?, joinPriority } doors: Door[]; // ÄLTER — siehe openings; bewusste Doppelspur
openings?: Opening[]; // NEUER, allgemeiner: kind:"window"|"door"
stairs?: Stair[];
rooms?: Room[];
columns?: Column[];
extrudedSolids?: ExtrudedSolid[];
drawings2d: Drawing2D[];
context?: ContextObject[]; // Terrain/Importe — NICHT semantisch
parametricWalls?: ParametricWall[];
overrideRules?: OverrideRule[];
viewSnapshots?: ViewSnapshot[];
viewSnapshotFolders?: ViewSnapshotFolder[];
layouts?: Layout[];
masterLayouts?: MasterLayout[];
layoutFolders?: LayoutFolder[];
// + Bibliotheken: lineStyles, hatches, components, wallTypes, roofTypes?,
// doorTypes?, windowTypes?, stairTypes?, ceilingTypes?
// + drawingLevels, layers, geoAnchor?, referenceElevationMasl?
} }
``` ```
`joinPriority` ist DOSSIERs `_MATERIAL_PRIO` (Beton 800 … Putz 100) als Ein `Element`-Typalias existiert noch (`kind:"door"|"window"` etc.), wird aber
**Daten** statt Hardcode — steuert die Prioritäts-T-Verschneidung (Risiko #1). nirgends im Code referenziert — Selektion/Element-Baum arbeiten direkt auf den
typisierten Arrays. `doors`/`openings` sind eine **bekannte, noch nicht
konsolidierte Doppelspur** (PENDENZEN.md).
### 2.3 Aufbauten (mehrschichtige Typen) ### 2.3 Ressourcen & Aufbauten (unverändert gegenüber der Vision)
```ts ```ts
interface Resources { lineStyles: LineStyle[]; hatches: Hatch[]; components: Component[]; }
interface WallType { id; name; layers: { componentId; thickness }[]; } interface WallType { id; name; layers: { componentId; thickness }[]; }
interface SlabType { id; name; layers: { componentId; thickness }[]; }
``` ```
Schichtdicke liegt am Layer, **Priorität am Component** (so muss man Prio nur `joinPriority` sitzt am `Component` (Daten statt Hardcode) und steuert die
einmal pflegen). 3D und Plan lesen dieselben `layers[]`. Prioritäts-T-/X-Verschneidung — **fertig implementiert**, inklusive Rust-Port
für den 3D-Live-Schnitt (`section_boolean.rs` = Port von
`toSection.ts::subtractDominantBands`).
### 2.4 Elemente ### 2.4 Layouts / Ausschnitte
Diskriminierte Union `Element` (heute `Wall | Door`), erweitert um `ViewSnapshot` (Kamera/Massstab/Detailgrad/Sichtbarkeiten/Override-Preset) in
`Window | Slab | Stair | Roof | Column | Beam | Space | Draw2d`. Jedes Element: `ViewSnapshotFolder`-Bäumen; `Layout` (Papierformat, mehrere
`{ id, type, floorId, categoryCode, ... }`. Gehostete Elemente (Tür/Fenster) `LayoutViewport`s, freie `LayoutAnnotation`s, optionale `MasterLayout`-Vererbung)
tragen `hostWallId` statt `floorId` (Geschoss ergibt sich aus der Wand). Volle in `LayoutFolder`-Bäumen. Beide gehören ins Dokument (nicht LocalStorage).
Felder: elements.md.
### 2.5 Pläne / Output (eigene Achse)
```ts
interface Sheet { // Plansatz-Blatt (≙ DOSSIER Layout/PageView)
id; name; paper: "A0".."A4"|"Letter"; landscape: boolean;
viewports: SheetViewport[]; // platzierte Ableitungen + Titelblock
folder?: string;
}
interface SheetViewport { // ≙ DOSSIER Detail + gebundener Ausschnitt
id; rect: Rect; sourceViewId: string; // referenziert DrawingLevel oder ViewSnapshot
scale: number; // 1:N
}
interface ViewSnapshot { // ≙ DOSSIER Ausschnitt (View-Snapshot)
id; name; folder?;
camera: CameraState; scale: number; detailLevel: DetailLevel;
visibility: VisibilityState; // pro Geschoss/Ebene
overrides?: { presetId?; enabled };
}
```
--- ---
## 3. State, Persistenz, Undo/Redo ## 3. State, Persistenz, Undo/Redo
### 3.1 Store — Zustand ### 3.1 Store — eigener `useSyncExternalStore`, nicht Zustand
Heute: `useState<Project>` in `App.tsx`, immutabel via `setProject`. Das skaliert Entgegen der ursprünglichen Empfehlung (Zustand-Library) wurde ein
nicht für Werkzeuge + Undo. Migration auf **Zustand** (ROADMAP §4): **abhängigkeitsfreier Store** gebaut (`src/state/store.ts`, gleiches Muster wie
`src/i18n`): `createStore()` komponiert Slice-Fabriken über eine gemeinsame
`RootState`. `appStore.ts` fügt zusammen: `projectSlice` (Projekt + Undo/Redo,
`setProject` als einziger Mutations-Einstiegspunkt), `historySlice`,
`selectionSlice`, `viewSlice`, `layoutSlice`, `siteSlice`, `notifySlice`
(Toast/Confirm-Ersatz für `window.alert`, in Tauris WKWebView deaktiviert).
Komponenten lesen über `useStore(selector)`.
```ts **Nicht umgesetzt** (geplant in `docs/design/state-architecture.md`): die
interface AppState { Extraktion von View-Routing nach `src/views/` und Kontextmenü-Aufbau nach
project: Project; // das Dokument `src/menus/` — beide Ordner existieren nicht, diese Logik liegt weiterhin
ui: { // flüchtig, NICHT persistiert/undobar inline in `App.tsx` (7.130 Zeilen). Siehe STATUS.md §4.3.
activeLevelId; activeViewType; selection: string[];
activeTool: ToolId; hover; cameraByView; planTransform;
};
// Aktionen mutieren via Immer-Producer; jede Modell-Mutation pusht History.
apply(mutator: (p: Project) => void): void;
undo(): void; redo(): void;
}
```
**Trennung Modell ↔ UI** ist hart: nur `project` ist undobar und wird gespeichert. ### 3.2 Persistenz
`ui` (Auswahl, Kamera, aktives Werkzeug) lebt separat. Das ersetzt DOSSIERs
`sc.sticky`-Bus — wo DOSSIER cross-modul über Sticky-Keys kommuniziert, lesen bei
uns einfach alle Komponenten denselben Store (reaktiv). **Kein Sticky, kein
Bridge-Polling, keine `is not None`-Lücken** (DOSSIER-Schwachstelle #4.4).
### 3.2 Persistenz — JSON-Datei statt `doc.Strings` - **Datei:** eigenes `.obp`-Format (`src/io/projectFile.ts`), unter Tauri über
native Speichern/Öffnen-Dialoge (`plugin-fs`/`plugin-dialog`), im Browser
DOSSIER speichert *alles* in `doc.Strings` (Key-Value am Rhino-Document) + Blob-Fallback. Ältere `.json`-Projekte bleiben ladbar.
globale Presets als JSON-Dateien im User-Home. Browser-Äquivalent: OS-Level-Exklusiv-Lock (`fs4`-Crate, `src-tauri/src/lock.rs`) verhindert
Doppelöffnen desselben Projekts.
| DOSSIER | Browser (cad) | - **Compute-Boundary:** `src/compute/index.ts` leitet einzelne Operationen
|---|---| (aktuell nur `computeJoins`) unter Tauri via `invoke` an eine native Rust-
| `doc.Strings.SetString(key, json)` | Feld im `Project`-Objekt (ein JSON-Baum) | Implementierung weiter; im Browser bzw. für nicht migrierte Ops (Room-
| `.3dm`-Datei mit eingebetteten Strings | **`.cad.json`-Datei** via File System Access API | Detection, DWG-Parsing) läuft die TS-Implementierung.
| Globale Presets (`~/Library/.../*.json`) | **LocalStorage** (`cad.presets.*`) + Export/Import |
| Launcher schreibt Settings-Datei | **IndexedDB** Autosave (Phase 5) |
```ts
// persistence.ts
const FORMAT_VERSION = 1;
function serialize(p: Project): string // JSON.stringify({ version, project })
function deserialize(text: string): Project // + migrate(version) bis aktuell
async function saveToFile(p: Project): Promise<void> // showSaveFilePicker → .cad.json
async function loadFromFile(): Promise<Project> // showOpenFilePicker; Fallback <input type=file>
async function autosave(p: Project) // IndexedDB, debounced 2 s
```
- **Save/Load:** [File System Access API](https://developer.mozilla.org/docs/Web/API/File_System_Access_API)
(`showSaveFilePicker`/`showOpenFilePicker`); Fallback Download-Blob + `<input type=file>`
für Firefox/Safari.
- **Autosave/Recovery:** IndexedDB (via `idb`), entkoppelt vom expliziten Speichern.
- **Cross-Projekt-Presets:** LocalStorage mit Namespacing (`cad.presets.overrides`,
`cad.presets.wallTypes`, …) + JSON-Export/Import — ersetzt DOSSIERs
`~/Library/.../override_presets.json` (overrides.py).
- **Migrationen:** `migrate(data, fromVersion)`-Kette, additiv. DOSSIER macht das
per Sticky-Prefix (`traite_``pause_``dossier_`); wir machen es versioniert im
Datei-Schema.
### 3.3 Undo/Redo ### 3.3 Undo/Redo
DOSSIER überlässt Undo Rhino (und hat dadurch Cache-Stale-Bugs, Schwachstelle Eigener History-Ring im `projectSlice` (kein DOSSIER-Sticky-Bus, kein
#4.3). Wir besitzen das Dokument selbst → **eigener History-Ring**: Cache-Stale-Problem, da alle Sichten pure Ableitungen sind).
```ts
// history.ts — Snapshots des project-Teilbaums (strukturelles Sharing via Immer-Patches)
interface History { undo: Patch[][]; redo: Patch[][]; }
```
Jede `apply()`-Mutation erzeugt Immer-Patches → Push auf `undo`. Da abgeleitete
Sichten **pure** sind, ist nach `undo()` kein Cache zu invalidieren — der
ganze Cache-Stale-Komplex aus DOSSIER (`_JOINTS_CACHE_KEY`, Material-Cache)
entfällt strukturell.
--- ---
## 4. Rendering ## 4. Rendering
### 4.1 Three.js-Szene spiegelt den Ebenen-Baum ### 4.1 Zwei 3D-Viewports
DOSSIER baut in Rhino eine **Layer-Hierarchie** `Zeichnungsebene → CODE_Name-Sublayer` - **`Viewport3D.tsx`** — three.js, die „Free"-Stufe.
(`layer_builder.build_layers`) und hängt jedes Objekt an den passenden Sublayer. - **`Wasm3DViewport.tsx`** — Rust/wgpu, „Nordstern", editierbar, **Default**.
Browser-Spiegel: ein **`THREE.Group`-Baum** mit identischer Struktur, gebaut aus Ein Settings-Schalter wählt die Engine; WebGL2/three.js nur noch als
dem Modell (nicht persistiert — reine Ableitung): explizite Wahl oder Fallback.
`render3d` (10.246 LOC) baut Wandextrusion inkl. Schichtbändern und
Prioritäts-Gehrung (`compute_wall_miters`), Materialtextur-Arrays (pro
Wandschicht) + separate Aerial-Drape-Textur, Render-Styles (Shaded/White/
Textured/Wireframe/Hidden/ShadedEdges via Kanten-Extraktion mit
Crease-Erkennung). **Dächer/Treppen/Stützen haben KEINE eigene Rust-Geometrie**
— sie werden vollständig in TypeScript erzeugt (`geometry/roof.ts`,
`emitRoofs`/`emitColumns`) und Rust nur als vorberechnetes Dreiecks-Mesh
(`MeshInput`/`append_context_mesh`, derselbe generische Importpfad wie für
swissBUILDINGS3D/DXF) übergeben.
### 4.2 Grundriss = drei koexistierende Renderer
1. **`PlanView.tsx`** (SVG) — bleibt immer im DOM (Hit-Testing/Grips),
unabhängig vom aktiven Zeichenpfad.
2. **`plan/glPlan/`** — eigener TypeScript-WebGL2-Renderer.
3. **`useWasmPlanRenderer.ts`** → Rust-`render2d` (WGSL, wgpu nativ,
WebGPU/WebGL2-Fallback im Web, echtes Text-Rendering via `glyphon`).
Beide GPU-Pfade fallen bei Init-Fehler still auf SVG zurück.
### 4.3 Schnitt/Ansicht — analytische Rust-Pipeline, KEIN HLR
Der ursprünglich geplante OCCT-WASM-HLR-Pfad (`src/section/hlr.ts`/`occt.ts`)
ist **toter Code** (keine Aufrufer mehr). Der tatsächlich funktionierende
Live-Schnitt nutzt aus, dass jedes Bauteil ein Prisma mit konstantem
Querschnitt ist — eine Schnittebene liefert dadurch **immer** ein
achsparalleles Rechteck, nie ein Trapez, wodurch HLR unnötig wird:
``` ```
scene App.tsx (section3dCutId/section3dPlane)
└─ levelGroup[floorId] (y-Offset = baseElevation) ← Geschoss → Wasm3DViewport.tsx (section3d-Prop → setSectionPlane)
└─ categoryGroup[categoryCode] (userData.code) ← Ebene → src-tauri/render3d/src/{section.rs, section_boolean.rs, section_fill.rs}
└─ element meshes (userData.elementId)
``` ```
```ts `section_boolean.rs` ist ein 1:1-Port von `toSection.ts::subtractDominantBands`
// scene.ts — 2D-Plan-Schnitt und 3D-Live-Schnitt nutzen dieselbe Prioritäts-Logik und
function buildScene(project: Project, vis: VisibilityState): THREE.Group stimmen dadurch exakt überein. Kein Worker, kein Comlink (beides war geplant,
function syncScene(root: THREE.Group, project, vis) // diff statt full rebuild (Perf) existiert nirgends im Projekt) — läuft synchron GPU-seitig.
```
- **Sichtbarkeit:** `categoryGroup.visible = vis.codes.has(code)` und ### 4.4 Öffnungen als Löcher (kein Mesh-Boolean)
`levelGroup.visible = vis.floors.has(floorId)` — entspricht DOSSIERs
`apply_visibility`, aber als simples `.visible`-Toggle statt Layer-Table-Modify.
- **Farbe/Material:** Component → `MeshStandardMaterial` (PBR später), per
`componentId` gecacht (wie heute `layerMaterial`-Cache, aber projektweit).
- **„Grau/gesperrt"-Modi** (DOSSIER `grey`/`grey_locked`): graues Override-Material
auf der Gruppe statt Layer-Color-Tausch.
### 4.2 Grundriss = Clipping-Ebene + symbolische Generierung Fenster/Türen schneiden echte achsparallele Rechteck-Löcher aus dem Wandkörper
(`plan/toWalls3d.ts` `subtractSpans`, gespiegelt in `render3d::mesh.rs`).
Ein generisches Mesh-CSG-Boolean existiert bereits (`trucksolid::boolean_mesh`,
`csgrs`, für das Extrude-Kommando genutzt), ist aber nicht an die
Wand/Öffnungs-Pipeline angeschlossen.
DOSSIER zeigt den Grundriss eines Geschosses, indem es eine **Clipping-Plane** auf ### 4.5 Materialien
`okff + schnitthöhe` legt, Normale Z (`layer_builder.update_clipping_plane`).
Zwei Browser-Pfade — **beide nötig** (ROADMAP §3):
1. **3D-Viewport im Plan-Modus:** `THREE.Plane(normal=(0,0,-1), constant=cutZ)` über `materials/library.ts` (13 gebündelte PBR-Starter, ambientCG CC0) +
`renderer.clippingPlanes` + `material.clippingPlanes`. `clip.ts` setzt die Ebene `materials/ambientcg.ts` (Live-Suche der kompletten ambientCG-Bibliothek,
pro aktivem Geschoss (`cutZ = baseElevation + cutHeight`), Kamera orthografisch 1K/2K/4K-Auflösungswahl, On-Demand-Download via `jszip`, Proxy wegen CORS) →
von oben. So sieht man den **echten geschnittenen Volumenkörper**. `materials/runtime.ts` baut daraus gecachte `THREE.MeshStandardMaterial`s mit
2. **Symbolischer SVG-Grundriss** (der Hauptweg, schnell + sauber): `generatePlan.ts` physisch korrekter Kachelgrösse (UV in Weltmetern).
erzeugt Vektor-Primitive **direkt aus Parametern** — Wand-Schnittbänder,
Öffnungs-Aussparungen, Tür-Schwenkbögen — *ohne* Mesh zu schneiden. Steht im
Spike. Ausbau: Schraffuren, Lauflinien, Bemaßung, LoD (plans-output.md).
### 4.3 Schnitt/Ansicht = Kamera + Schnittebenen + HLR
DOSSIER setzt 12 Clipping-Planes (Cut + Back), Parallel-Projektion senkrecht zur
Linie, zoomt auf die BBox (`schnitte.activate_schnitt`). Browser:
- **3D-Vorschau:** zwei `THREE.Plane` (Cut auf der Linie, Back in `directionSign`-
Richtung um `depthBack` versetzt) + orthografische Kamera senkrecht zur Linie.
- **Vektor-Ergebnis (Risiko #4):** Hidden-Line-Removal durch das zusammengebaute
Gebäude → saubere Linien als SVG. Via **OpenCascade.js** (`HLRBRep`) im
**Web Worker** (Comlink), Ergebnis gecacht pro (Schnitt, sichtbare Ebenen,
Modell-Hash). Geschnittene Bauteile bekommen Component-Schraffur (Section-Style,
resources-graphics.md). Details: plans-output.md.
### 4.4 SVG-Renderer + Geometrie-Booleans
- **Plan-Output:** SVG (`PlanView.tsx`). `Primitive`-Union (polygon/line/arc/text/
hatch) → SVG-Elemente; derselbe Serializer speist DXF- und PDF-Export.
- **Öffnungs-Booleans (Risiko #2):** Tür/Fenster schneidet Loch in Wand. Für 3D
reicht heute das Aussparen per Segment-Extrusion (im Spike). Für exakte B-Rep-
Verschneidung (und IFC) **OpenCascade.js** *oder* **Manifold** im Worker —
Entscheidung in Phase 0 (ROADMAP §8): OCC = exakt/B-Rep, Manifold = schnell/Mesh.
--- ---
## 5. React-Panel-Struktur ## 5. React-Panel-Struktur
DOSSIER ist eine Sammlung **getrennter WebView-Panels** (EBENEN, ELEMENTE, Dock-/Floating-Panel-System (`src/panels/`: Dock, FloatingPanel, TabStrip,
GESTALTUNG, OBERLEISTE, MASSSTAB, AUSSCHNITTE, DIMENSIONEN, LAYOUTS, OVERRIDES), Registry) mit eingebauten Panels: Tools, Attributes, ObjectInfo,
die über die Python-Bridge + `sc.sticky` kommunizieren. Im Browser ist alles DrawingLevels, Layers, Site (Kontext/Terrain-Import), RoomBalance
**eine SPA** mit einem geteilten Store — die Panels werden zu Docking-Bereichen. (SIA-416-CSV), Elements (Bauteilbaum), ViewSnapshots, Layouts.
``` Der **Resource Manager läuft bewusst NICHT als Dock-Panel**, sondern als
┌─────────────────────────────────────────────────────────────────────┐ eigenständiges (unter Tauri natives) Fenster — ebenso Settings,
│ TopBar Werkzeuge · Ansichtstyp · Massstab · Snaps · Save/Load │ ≙ OBERLEISTE+MASSSTAB DrawingLevels-Detaileditor, LayerSettings und ContextImport
├──────────────┬──────────────────────────────────────┬───────────────┤ (`src/native/*Window.ts` + `*WindowApp.tsx`), jeweils mit
│ Navigator │ Viewport (Three.js oder SVG-Plan) │ Inspector │ `isTauriRuntime()`-Gate und ohne separate Browser-Variante (im Browser bleibt
│ ┌──────────┐ │ ┌────────────────────────────────┐ │ ┌───────────┐ │ die entsprechende In-App-Overlay-Variante aktiv, wo vorhanden).
│ │Zeichnungs│ │ │ 3D / Grundriss / Schnitt / │ │ │ aktives │ │ ≙ ELEMENTE/
│ │-ebenen │ │ │ Ansicht — abgeleitet aus dem │ │ │ Element + │ │ GESTALTUNG-
│ ├──────────┤ │ │ Modell │ │ │ Stil │ │ Properties
│ │ Ebenen │ │ └────────────────────────────────┘ │ └───────────┘ │
│ │ (Baum) │ │ │ Manager-Tabs: │
│ │ │ │ │ Components · │ ≙ Resource-
│ └──────────┘ │ │ Hatch · Line │ Manager
├──────────────┴──────────────────────────────────────┴───────────────┤
│ BottomBar Koordinaten · Snaps · Statusmeldungen │
└─────────────────────────────────────────────────────────────────────┘
Modal/Drawer: Ausschnitte · Sheets/Layouts · Overrides · Kamera-Presets · SIA-Bilanz
```
**Komponenten-Map** (heute alles in `App.tsx`; wird gesplittet): **Werkzeug-System:** `Tool`-Interface (`src/tools/types.ts`) für Zeichenwerkzeuge
mit Snap-Engine; daneben das umfangreichere **Kommandosystem** (`src/commands/`)
| Bereich | DOSSIER-Panel | cad-Komponente | für Rhino-artige getippte Eingabe (`5,3`/`r5,3`/`5<45`, Tab-Feld-Zyklus in
|---|---|---| `CommandLine.tsx`) — beide koexistieren, decken unterschiedliche
| Zeichnungsebenen-Liste | EBENEN (oben) | `Navigator/DrawingLevels.tsx` | Interaktionsstile ab.
| Ebenen-Baum | EBENEN (Baum) | `Navigator/LayerTree.tsx` (Spike: `CategoryRow`) |
| Top-Bar (View/Snaps/Massstab) | OBERLEISTE, MASSSTAB | `TopBar.tsx` |
| Element-Eigenschaften | ELEMENTE, ELEMENT-PROPERTIES | `Inspector/ElementProps.tsx` |
| Stil/Attribute der Auswahl | GESTALTUNG | `Inspector/StylePanel.tsx` |
| Component/Hatch/Line | (Project-Settings, mass_style) | `managers/*Manager.tsx` |
| View-Snapshots | AUSSCHNITTE | `panels/Snapshots.tsx` |
| Plansätze | LAYOUTS | `sheets/SheetEditor.tsx` |
| Regelbasierte Overrides | OVERRIDES | `panels/Overrides.tsx` |
| Bemaßung | DIMENSIONEN | `panels/Dimensions.tsx` |
| Element-Übersicht (BIM-Tree) | ELEMENTE-ÜBERSICHT | `panels/ElementTree.tsx` |
| Kamera-Presets | KAMERA | `panels/CameraPresets.tsx` |
**Werkzeug-System** (ersetzt DOSSIERs Rhino-Command-Aliases `cmd/wand.py` etc.):
ein `Tool`-Interface mit `onPointerDown/Move/Up`, das Vorschau-Primitive
zurückgibt und beim Bestätigen `store.apply()` ruft. Pointer-Events auf Canvas/SVG
mit Snap-Engine (Endpunkt/Mitte/Schnitt/Ortho/Raster). Tabelle der Tools:
elements.md §8.
--- ---
## 6. Rhino → Browser — Mapping-Tabelle ## 6. Rhino → Dossier — Mapping-Tabelle (Kernkonzepte)
| DOSSIER (Rhino-Plugin) | cad (Standalone-Browser) | Anmerkung | | DOSSIER (Rhino-Plugin) | Dossier (Tauri) | Anmerkung |
|---|---|---| |---|---|---|
| **Rhino `RhinoDoc`** | `Project` (TS-Objekt im Store) | einzige Wahrheit | | Rhino `RhinoDoc` | `Project` (TS-Objekt im eigenen Store) | einzige Wahrheit |
| **`doc.Strings[key]=json`** | Feld im `Project`-JSON | persistiert in `.cad.json` | | `doc.Strings[key]=json` | Feld im `Project`-JSON | persistiert in `.obp` |
| **`.3dm`-Datei** | **`.cad.json`** via File System Access API | + IndexedDB-Autosave | | `.3dm`-Datei | **`.obp`**-Datei (Tauri `plugin-fs`) | + Blob-Fallback im Browser |
| **Globale Presets (~/Library/*.json)** | **LocalStorage** + Export/Import | cross-Projekt | | `sc.sticky` (cross-modul Bus) | eigener `useSyncExternalStore`-Store | kein Polling |
| **`sc.sticky` (cross-modul Bus)** | **Zustand-Store** (reaktiv) | kein Polling, kein None-Bug | | Rhino Layer-Tabelle | `LayerCategory[]`-Baum, Sichtbarkeit als `.visible`-Flag | pro Renderer umgesetzt |
| **`panel_base.BaseBridge` / WebView-IPC** | direkte React-Props/Store | keine `document.title`-Hacks | | Clipping-Plane (`AddClippingPlane`) | `render3d::section.rs` (analytisch, Rechteck-Subtraktion) | KEIN HLR |
| **`document.title="RHINOMSG::"` Polling** | entfällt | SPA, kein WebView | | `HLRBRep` | — (ungenutzt; OCCT-Spike ist toter Code) | ersetzt durch obiges |
| **Eto.Forms Satelliten-Fenster** | React-Modal/Drawer | z.B. Ausschnitt-Settings | | `Rhino.Geometry.Brep`-Booleans | `trucksolid::boolean_mesh` (csgrs) | existiert, nicht an Wände angeschlossen |
| **Rhino Layer-Tabelle (Sublayer-Baum)** | `LayerCategory[]`-Baum + `THREE.Group`-Spiegel | `scene.ts` | | Rhino-Grips + DisplayConduit | Pointer-Events + Grip-Overlay (2D vollständig, 3D nur Basis, kein Snap) | siehe STATUS.md §4.7 |
| **`layer_builder.build_layers`** | `buildScene` (Ableitung) | Gruppen statt Layer | | Swisstopo via .NET HttpClient | `fetch()` (CORS-offen) | radiusgenau zugeschnitten (nicht ganze STAC-Kachel) |
| **`layer.PlotWeight`** | `LineStyle.weight` (mm) → SVG `stroke-width` | massstabsabh. (§plans) | | IronPython-Laufzeit-Risiken | entfällt | TS/Rust |
| **`SectionStyle` (Layer)** | Component-Schraffur beim Schnitt-Rendern | resources-graphics.md |
| **Clipping-Plane (`AddClippingPlane`)** | `THREE.Plane` + `renderer.clippingPlanes` | `clip.ts` |
| **`vp.ChangeToParallelProjection`** | `THREE.OrthographicCamera` | `camera.ts` |
| **`vp.GetFrustum()` → Massstab** | Frustum-Breite (ortho) → 1:N | gleiche Mathe (§plans) |
| **CoreGraphics DPI-Auto-Detect** | `window.devicePixelRatio` + CSS-px | Browser kennt DPI nativ |
| **`Rhino.Geometry.Brep`-Booleans** | OpenCascade.js / Manifold (Worker) | Öffnungen, HLR |
| **`HLRBRep` (über Rhino-Display)** | OpenCascade.js `HLRBRep` (Worker) | Schnitt/Ansicht-Linien |
| **Rhino-Grips + DisplayConduit** | Pointer-Events auf SVG/Canvas + Overlay | grip-editing (elements.md) |
| **`Rhino.Input.Custom.GetPoint`** | `Tool`-Pointer-Handler + Snap | Werkzeug-System |
| **`MouseCallback` (Doppelklick Schnitt)** | `onDoubleClick` auf SVG-Symbol | plans-output.md |
| **Rhino Undo/Redo** | eigener History-Ring (Immer-Patches) | kein Cache-Stale |
| **`HatchPattern`-Tabelle** | `Hatch`-Ressourcen + SVG `<pattern>` | resources-graphics.md |
| **`Linetype`-Tabelle** | `LineStyle.dash[]` → SVG `stroke-dasharray` | resources-graphics.md |
| **`TextEntity` / Rich-Text** | SVG `<text>` / HTML-Annotation | plans-output.md |
| **`RhinoPageView` (Layout)** | `Sheet` + `SheetEditor` | plans-output.md |
| **`FilePdf` Multi-Page-Export** | svg → `pdf-lib`/`jsPDF` (Vektor) | plans-output.md |
| **Swisstopo/OSM via .NET HttpClient** | `fetch()` (CORS-fähige STAC/Overpass-APIs) | Phase 4 |
| **LV95↔WGS84 (Python-Formeln)** | dieselben Formeln in TS portiert | Phase 4 |
| **IronPython 2.7/3 Runtime-Risiken** | entfällt (TS/Browser) | — |
--- ---
## 7. Was wir von DOSSIER übernehmen — und was nicht ## 7. Was übernommen wurde — und was bewusst anders lief
**Übernehmen (bewährte Konzepte):** **Übernommen (Prinzipien, bewährt):**
- Zwei-Achsen-Dokumentmodell (Zeichnungsebenen × Ebenen). - Zwei-Achsen-Dokumentmodell, Layer-Codes 1:1, Component/Hatch/Line-Manager
- Layer-Codes/Farben/lw 1:1 (`DEFAULT_LAYER_SCHEMA`). mit `joinPriority`, LoD (grob/mittel/fein), regelbasierte Overrides,
- Component/Hatch/Line-Manager mit id-Verweisen + `joinPriority`. Ausschnitte, SIA-416-Räume, Norden-Rotation.
- Ansichtstypen = Kamera + optionaler Schnitt (vereinheitlicht). - Pure-Ableitungs-Architektur (kein Cache-Stale, kein Sticky-Bus).
- LoD (`darstellung`: einfach/standard/detail) mit Dokument-Override.
- Regelbasierte Overrides (Condition → Action, additive Priorität, reversibel).
- Ausschnitte (View-Snapshots), Layer-Kombinationen, Massstab-pro-Viewport.
- SIA-416-Räume, Norden-Rotation, Stil-Kataloge.
**Bewusst anders (Browser-nativ, Schwachstellen vermeiden):** **Anders gelaufen als geplant (siehe STATUS.md §5 für die volle Tabelle):**
- **Kein Sticky-Bus** → ein reaktiver Store (vermeidet DOSSIER #4.4/#4.5/#4.6). - Eigene Rust/WASM-Rendering-Engines statt Three.js/OpenCascade.js/web-ifc.
- **Kein Monolith** → Bauteil-Module statt 7244-LOC-`elemente.py` (#4.1). - Typisierte Arrays pro Bauteiltyp statt `Element[]`-Union.
- **Pure Ableitungen** statt mutierter Doc-Objekte → kein Cache-Stale (#4.3), - Eigener Store statt Zustand-Library.
kein Undo/Redo-Loch. - Analytische Rust-Schnitt-Pipeline statt HLR/Worker/Comlink.
- **Versioniertes Datei-Schema** statt UserString-Sticky-Migration. - App.tsx wurde **nicht** wie geplant auf eine dünne Shell reduziert
- **Persistenz im Project-JSON** statt verteilt über `doc.Strings`. (`src/views/`/`src/menus/` wurden nie angelegt) — bekannter, unbereinigter
Punkt, siehe STATUS.md §4.3.
**Anti-Over-Engineering (aus DOSSIER-CONVENTIONS.md übernommen):** keine Abstraktion **Anti-Over-Engineering (weiterhin gültig):** keine Abstraktion ohne konkretes
ohne konkretes Problem; kein `try/catch: {}` als Bug-Versteck; erst grep/lesen, Problem; erst lesen, dann editieren; Geometrie nie „nebenbei" refactoren.
dann editieren; Wand-Geometrie nie „nebenbei" refactoren.
--- ---
## 8. Reihenfolge bei Code-Arbeit ## 8. Reihenfolge bei Code-Arbeit
1. **Dieses Dokument + das relevante Detail-Design** (elements/plans-output/ 1. **STATUS.md** (aktueller Ist-Zustand) + dieses Dokument lesen.
resources-graphics) lesen. 2. **Das betroffene Modul** lesen (nicht raten) — bei Zweifel: welcher der
2. **Dann das betroffene Modul** lesen (nicht raten). mehreren Renderer/Pfade ist gerade aktiv (§4.1/§4.2)?
3. **Erst danach editieren**; `Project` immer immutabel via `store.apply()` ändern. 3. **Erst danach editieren**; `Project` immer immutabel via den Store ändern.
4. **Verifizieren:** `npx tsc -b`, `npm run build`, Screenshot via 4. **Verifizieren:** `npx tsc -b`, `npm test` (Vitest), `cargo test` in den
`node scripts/probe.mjs` — Geometrie visuell prüfen. betroffenen Crates, bei Rust-Änderungen `npm run build:engine{,3d}` (WASM
5. **Dieses Dokument aktuell halten**, wenn sich Patterns/Mapping ändern. neu bauen, nur `.rs` committen — `src/engine/pkg*/` ist gitignored). Bei
render3d/Wasm3DViewport-Änderungen testet der Nutzer selbst in der
Tauri-Dev-App (nicht per Puppeteer/Browser verifizierbar).
5. **Dieses Dokument aktuell halten**, wenn sich Patterns ändern — insbesondere
nicht wieder in „Ziel-Struktur"-Beschreibungen abdriften, die Monate lang
niemand nachführt.
+25 -11
View File
@@ -1,6 +1,8 @@
# Projekt-Konventionen — Browser-BIM (cad) # Projekt-Konventionen — Dossier
Siehe [ROADMAP.md](ROADMAP.md) für Vision, Architektur und Phasen. Siehe [ARCHITECTURE.md](ARCHITECTURE.md) für die aktuelle Architektur,
[STATUS.md](STATUS.md) für den vollständigen Ist-Zustand und
[ROADMAP.md](ROADMAP.md) für die ursprüngliche (historische) Produktvision.
## Code-Konventionen (verbindlich) ## Code-Konventionen (verbindlich)
@@ -15,16 +17,26 @@ Siehe [ROADMAP.md](ROADMAP.md) für Vision, Architektur und Phasen.
CCW-Wicklung zeigt `+n` nach innen. Schichten werden außen (T/2) → innen (+T/2) CCW-Wicklung zeigt `+n` nach innen. Schichten werden außen (T/2) → innen (+T/2)
gestapelt. gestapelt.
## Code-Struktur (kein God-Component) ## Code-Struktur (kein God-Component — bisher nur teilweise erreicht)
- **`App.tsx` bleibt ein dünner Shell** (Store-Provider, Oberleiste, Docks+View-Router, - **Ziel:** `App.tsx` bleibt ein dünner Shell (Store-Provider, Oberleiste,
Statusleiste, Floating-Panels, Ressourcen-Overlay) — keine Geschäftslogik darin. Docks+View-Router, Statusleiste, Floating-Panels, Ressourcen-Overlay) — keine
- **Globaler Zustand in einem Store** (`src/state/`, Slices: project/selection/view/layout). Geschäftslogik darin. **Realität (Stand 2026-07-21):** `App.tsx` ist mit
Komponenten lesen Zustand über Store-Hooks statt Prop-Drilling. ~7.100 Zeilen die grösste Datei des Projekts und enthält weiterhin
- **Features als eigene Module:** `src/views/` (View-Router-Teile), `src/editors/` View-Umschaltung und Kontextmenü-Aufbau inline — die geplante Auslagerung
(Inline-Editoren), `src/menus/` (Kontextmenü-Builder), `src/panels/`, `src/ui/`. nach `src/views/`/`src/menus/` (unten) ist **nie passiert**, siehe
[STATUS.md](STATUS.md) §4.3. Neue, grössere Features sollten trotzdem nicht
weiter in `App.tsx` wachsen; wo möglich in `src/panels/`, `src/editors/`
oder ein neues Modul auslagern statt die Datei weiter zu vergrössern.
- **Globaler Zustand in einem Store** (`src/state/`, eigener Store auf
`useSyncExternalStore`**kein** Zustand/Redux/Immer — mit Slices:
project/history/selection/view/layout/site/notify). Komponenten lesen
Zustand über `useStore(selector)` statt Prop-Drilling.
- **Features als eigene Module:** `src/editors/` (Inline-Editoren),
`src/panels/`, `src/ui/`. `src/views/` und `src/menus/` waren geplant, wurden
aber nie angelegt — bei Bedarf gilt das als offener Aufräum-Punkt, nicht als
bestehende Struktur.
- Ziel: modular + parallel bearbeitbar (verschiedene Features ≠ dieselbe Datei). - Ziel: modular + parallel bearbeitbar (verschiedene Features ≠ dieselbe Datei).
Siehe `docs/design/state-architecture.md`.
## UI-Konventionen ## UI-Konventionen
@@ -61,4 +73,6 @@ Rendern angewandt, nie in die Geometrie eingebacken.
- Änderungen verifizieren: `npx tsc -b`, `npm run build`, und Screenshot via - Änderungen verifizieren: `npx tsc -b`, `npm run build`, und Screenshot via
`node scripts/probe.mjs` (schreibt `scripts/probe.png`) bzw. `probe-ff*.mjs` für `node scripts/probe.mjs` (schreibt `scripts/probe.png`) bzw. `probe-ff*.mjs` für
Firefox-Fälle. Screenshot ansehen und Geometrie visuell prüfen. Firefox-Fälle. Screenshot ansehen und Geometrie visuell prüfen.
- Dev-Server läuft via `npm run dev` (Vite, Port 5173). - Dev-Server läuft via `npm run dev` (Vite, Port 5187). Nativer Rahmen
plattformabhängig: `npm run tauri:dev` auf macOS, `npm run electron` auf Linux
(WebKitGTK kann kein zuverlässiges WebGPU → dort Chromium/Electron).
+6
View File
@@ -1,3 +1,9 @@
> **Hinweis (2026-07-21):** Dieser Block (Stand 07-09) ist nicht mehr der
> neueste Stand — `git log`/[PENDENZEN.md](PENDENZEN.md) reichen bis 07-20/17.
> Für den aktuellen Ist-Zustand siehe [STATUS.md](STATUS.md). HANDOVER.md bleibt
> ein Append-Log (ältere Sessions unten); es wird nicht rückwirkend aktualisiert
> — bei neuen Übergaben oben einen neuen Block ergänzen, nicht diesen editieren.
# HANDOVER — Stand 2026-07-09 (autonome Session, ALLES committet) # HANDOVER — Stand 2026-07-09 (autonome Session, ALLES committet)
Lange autonome Session (Nutzer-Auftrag: „arbeite die Pendenzen/Funktionen durch, Lange autonome Session (Nutzer-Auftrag: „arbeite die Pendenzen/Funktionen durch,
+7 -1
View File
@@ -103,7 +103,7 @@
- [x] ~~**Kreis-Toolbar** (trivial)~~**erledigt `e454eab` (2026-07-05):** `ToolId`+`"circle"`, Platzhalter `circleTool` (nicht floorOnly), `TOOL_COMMAND`+`TOOL_ORDER`, Kreis-Icon in `ToolsPanel`, i18n `tool.circle`/`tool.circle.hint`. tsc + Suite 331 grün. (Kreise rendern seit `4ac99d3` glatt als `<circle>`.) - [x] ~~**Kreis-Toolbar** (trivial)~~**erledigt `e454eab` (2026-07-05):** `ToolId`+`"circle"`, Platzhalter `circleTool` (nicht floorOnly), `TOOL_COMMAND`+`TOOL_ORDER`, Kreis-Icon in `ToolsPanel`, i18n `tool.circle`/`tool.circle.hint`. tsc + Suite 331 grün. (Kreise rendern seit `4ac99d3` glatt als `<circle>`.)
- [x] ~~**Bogen-Werkzeug** (mittel)~~**erledigt (2026-07-05):** `arcCommand` in `src/commands/cmds/arc.ts` (3-Klick: Mittelpunkt → Start/Radius → Endwinkel, CCW; Vorschau via `arcPts`/`circlePts`), registriert in `registry.ts` (Alias `a`/`bogen`), `"arc"` als ToolId + Toolbar-Eintrag (Bogen-Icon) + i18n. tsc + Suite 331 grün. ✅ **Center-/Quadrant-Snaps ergänzt (2026-07-05):** `collectCircles` + Snap-Block in `snapping.ts` — Mittelpunkt + Quadranten (Kreis: alle 4; Bogen: nur im Spannbereich) unter der `center`-Einstellung, Bogen-Endpunkte unter `endpoint`; +3 Tests (Suite 334). - [x] ~~**Bogen-Werkzeug** (mittel)~~**erledigt (2026-07-05):** `arcCommand` in `src/commands/cmds/arc.ts` (3-Klick: Mittelpunkt → Start/Radius → Endwinkel, CCW; Vorschau via `arcPts`/`circlePts`), registriert in `registry.ts` (Alias `a`/`bogen`), `"arc"` als ToolId + Toolbar-Eintrag (Bogen-Icon) + i18n. tsc + Suite 331 grün. ✅ **Center-/Quadrant-Snaps ergänzt (2026-07-05):** `collectCircles` + Snap-Block in `snapping.ts` — Mittelpunkt + Quadranten (Kreis: alle 4; Bogen: nur im Spannbereich) unter der `center`-Einstellung, Bogen-Endpunkte unter `endpoint`; +3 Tests (Suite 334).
- [ ] **BAUTEILE aufs Rhino-Niveau heben (Treppe/Fenster/Tür).** Vergleich Rhino-Plugin ↔ TS + priorisierte Ansätze: **[RESEARCH_BAUTEILE_RHINO.md](RESEARCH_BAUTEILE_RHINO.md)**. ✅ **Gruppe A (2D, Items 16) komplett — verifiziert 2026-07-05:** (1) Treppe-Outline (`stairOutline`, gerade/L/Wendel, `generatePlan.ts:2186`), (2) Fenster-Brüstungslinie (`window-sill`, gepunktet, `sillHeight>0`, `:1810`), (3) Tür-Sturzlinien (`door-lintel` + `lintelLines` keine/innen/aussen/beide, gestrichelt, `:1711`), (4) Treppe-Referenz links/mitte/rechts, (5) Fenster-Flügel-Mittelpfosten, (6) Tür `wandoeffnung`. **Offen:** Gruppe B (2D mittel: Tür-Schwung am Rahmen, Treppen-Pfeil-Style `filled`, Fenster/Tür-Presets, `swing_invert`) — Feinpolish. ✅ **Fenster-Anschlag-Striche erledigt** (`8d688b9`, Laibungsstriche quer zur Wand bei „fein", analog Tür; +2 Tests). Gruppe C (3D: Rahmen/Blatt/Glas/Sims als Mesh — wartet auf Mesh-/B-Rep-Pipeline). **Der große Rest ist „Schnitt- vs. Ansichts-Darstellung" (eigenes Item unten).** - [ ] **BAUTEILE aufs Rhino-Niveau heben (Treppe/Fenster/Tür).** Vergleich Rhino-Plugin ↔ TS + priorisierte Ansätze: **[RESEARCH_BAUTEILE_RHINO.md](RESEARCH_BAUTEILE_RHINO.md)**. ✅ **Gruppe A (2D, Items 16) komplett — verifiziert 2026-07-05:** (1) Treppe-Outline (`stairOutline`, gerade/L/Wendel, `generatePlan.ts:2186`), (2) Fenster-Brüstungslinie (`window-sill`, gepunktet, `sillHeight>0`, `:1810`), (3) Tür-Sturzlinien (`door-lintel` + `lintelLines` keine/innen/aussen/beide, gestrichelt, `:1711`), (4) Treppe-Referenz links/mitte/rechts, (5) Fenster-Flügel-Mittelpfosten, (6) Tür `wandoeffnung`. **Offen:** Gruppe B (2D mittel: Tür-Schwung am Rahmen, Fenster/Tür-Presets) — Feinpolish. ✅ **`swing_invert` gilt als abgedeckt** (2026-07-17): die Aufschlagseite ist über `Opening.swing` (links/rechts) UND der Scharnier-Pfosten über `Opening.hinge` (start/ende) bereits voll im Objekt-Info wählbar (4 Kombinationen = jede Schwenklage), ein separater Invert-Flag wäre redundant. ✅ **Treppen-Pfeil-Style `filled` erledigt 2026-07-17** (uncommittet): `Stair.arrowStyle?:"line"|"filled"` (additiv), gefülltes Dreieck-Polygon statt zwei offener Linien in `addStairSymbol` (`generatePlan.ts`), Segment-Umschalter im Objekt-Info (`onSetStairArrowStyle`), `StairInfo.arrowStyle`, +2 Tests (`generatePlan.stairArrow.test.ts`). tsc/vitest 869 grün. ✅ **Fenster-Anschlag-Striche erledigt** (`8d688b9`, Laibungsstriche quer zur Wand bei „fein", analog Tür; +2 Tests). Gruppe C (3D: Rahmen/Blatt/Glas/Sims als Mesh — wartet auf Mesh-/B-Rep-Pipeline). **Der große Rest ist „Schnitt- vs. Ansichts-Darstellung" (eigenes Item unten).**
- [ ] **DWG/DXF-Import via `acadrust` (weiterbauen).** ✅ Spike `763a558`: `acadrust` 0.4 (MPL-2.0, pure Rust) **baut zu wasm32** (Crate `src-tauri/dwgimport`, 839 KB), parst DXF aus Byte-Buffer (`DxfReader::from_reader`+`Cursor`), headless getestet. **Offen:** (1) Entity→DOSSIER-Modell-Mapping (LINE/ARC/… → Wand/Öffnung — die eigentliche Domainarbeit, Wochen), (2) Datei-Upload-Glue im Browser (`<input type=file>`→Uint8Array→`parse_dxf_summary_json`, trivial), (3) DWG-binär (`DwgReader::from_reader` analog, aber R13R2018-Korrektheit unverifiziert), (4) WASM-Größe (nalgebra Haupttreiber). **Klarstellung:** der TS-DXF/DWG-Import (`parseDxf`/`parseDwg`/`dxfToDrawings`) + Upload-UI (`App.tsx`, `ImportDialog.tsx`) existieren längst und funktionieren — der acadrust-Weg wäre eine Rust-Neuimplementierung des Lesens (nur DWG-**Schreiben** ist eine echte Lücke). ✅ **2887794 (2026-07-05): Kurven-Abdeckungslücke geschlossen**`parseDxf` deckt jetzt ARC/CIRCLE/ELLIPSE (tesselliert zu Konturen, Winkel Radiant, voller Umlauf geschlossen) zusätzlich zu LINE/LWPOLYLINE/POLYLINE/MESH ab; 5 Tests, volle Suite 307 grün. ✅ **c481373 (2026-07-05): SPLINE + INSERT ergänzt**`parseDxf` wertet SPLINE als echte B-Spline (De Boor, Grad/Knoten; Fallback fitPoints/Kontrollpolygon) aus und expandiert INSERT-Block-Referenzen (2D-Transform Scale/Rotation/Basispunkt + MINSERT-Array + verschachtelte Blöcke, Tiefe ≤8) zu transformierten Konturen; Kontur-Dispatch in gemeinsamen `collectContours` refaktoriert; +7 Tests, volle Suite 314 grün. **Bekannte Grenzen:** rationale SPLINE-Gewichte ignoriert (dxf-parser liefert sie nicht); Block-interne MESH/3DFACE-Entities werden im 2D-Import nicht expandiert. ✅ **c29f27e (2026-07-05): HATCH ergänzt** — dxf-parser hat KEINEN HATCH-Handler (verwarf HATCH stumm); Lösung via `registerEntityHandler` + eigenem `HatchHandler` (sammelt rohe Gruppencodes) + testbarer `hatchContours`-Auswertung: Randpfade (Polyline-Pfade + Linien-/Bogen-Kanten, Bögen über vorhandene Tessellierung) → geschlossene Konturen mit `Contour.filled`; `contoursToDrawings` macht daraus gefüllte `polyline`-Drawing2D (fillColor-Default, restylebar). +6 Tests, Suite 320 grün. ✅ **05bc5aa (2026-07-05): HATCH-Ellipse/Spline-Kanten** ergänzt (Kantentyp 3/4 tesselliert; B-Spline-Sampling in `sampleBSpline` extrahiert). ✅ **4b93ac9 (2026-07-05): TEXT/MTEXT**`parseDxf` liefert `DxfImportResult.texts` (`ImportedText`: Position/Höhe-in-Metern/Winkel-Radiant; MTEXT-Formatcodes grob gesäubert); `textsToDrawings``{shape:"text"}`-Drawing2D; **Darstellung neu**: `addDrawing2D` emittiert ein schlankes `kind:"drawingText"`-Primitiv, PlanView rendert es rein per SVG (modellverankert, Rotation; GPU-Guard so, dass es in ALLEN Renderer-Modi im SVG bleibt); `toRenderScene` überspringt es; ImportDialog zählt/importiert Texte. +5 Tests, Suite 327 grün. **Bekannte Grenzen HATCH:** Bulges an Polyline-Rändern als Sehne; Insel-Loops = eigene Ringe (keine echten Löcher). **Bekannte Grenzen TEXT:** importierte Texte (pointerEvents:none) noch nicht per Canvas-Klick selektierbar; MTEXT-Feinformatierung flachgeklopft; Block-interne TEXT/MTEXT nicht expandiert. **Text im Tauri visuell abgenommen (Nutzer 2026-07-05)** — auch gedreht korrekt. ✅ **4ac99d3: CIRCLE/ARC als echte glatte Formen**`Contour.curve` trägt die wahre Kreis-/Bogen-Geometrie (pts bleiben für Kontext/3D); `contoursToDrawings` baut `{shape:"circle"|"arc"}`; neue Primitive `drawingCircle` (SVG `<circle>`) + `drawingArc` (SVG-Bogenpfad), `toRenderScene` tesselliert sie für den nativen Pfad; +4 Tests, Suite 331. Ellipse bleibt tesselliert (kein Ellipsen-Primitiv). **Weiter offen:** Entity→Wand-Semantik (die dicke Domainarbeit); DWG-Schreiben (einzige echte Export-Lücke). - [ ] **DWG/DXF-Import via `acadrust` (weiterbauen).** ✅ Spike `763a558`: `acadrust` 0.4 (MPL-2.0, pure Rust) **baut zu wasm32** (Crate `src-tauri/dwgimport`, 839 KB), parst DXF aus Byte-Buffer (`DxfReader::from_reader`+`Cursor`), headless getestet. **Offen:** (1) Entity→DOSSIER-Modell-Mapping (LINE/ARC/… → Wand/Öffnung — die eigentliche Domainarbeit, Wochen), (2) Datei-Upload-Glue im Browser (`<input type=file>`→Uint8Array→`parse_dxf_summary_json`, trivial), (3) DWG-binär (`DwgReader::from_reader` analog, aber R13R2018-Korrektheit unverifiziert), (4) WASM-Größe (nalgebra Haupttreiber). **Klarstellung:** der TS-DXF/DWG-Import (`parseDxf`/`parseDwg`/`dxfToDrawings`) + Upload-UI (`App.tsx`, `ImportDialog.tsx`) existieren längst und funktionieren — der acadrust-Weg wäre eine Rust-Neuimplementierung des Lesens (nur DWG-**Schreiben** ist eine echte Lücke). ✅ **2887794 (2026-07-05): Kurven-Abdeckungslücke geschlossen**`parseDxf` deckt jetzt ARC/CIRCLE/ELLIPSE (tesselliert zu Konturen, Winkel Radiant, voller Umlauf geschlossen) zusätzlich zu LINE/LWPOLYLINE/POLYLINE/MESH ab; 5 Tests, volle Suite 307 grün. ✅ **c481373 (2026-07-05): SPLINE + INSERT ergänzt**`parseDxf` wertet SPLINE als echte B-Spline (De Boor, Grad/Knoten; Fallback fitPoints/Kontrollpolygon) aus und expandiert INSERT-Block-Referenzen (2D-Transform Scale/Rotation/Basispunkt + MINSERT-Array + verschachtelte Blöcke, Tiefe ≤8) zu transformierten Konturen; Kontur-Dispatch in gemeinsamen `collectContours` refaktoriert; +7 Tests, volle Suite 314 grün. **Bekannte Grenzen:** rationale SPLINE-Gewichte ignoriert (dxf-parser liefert sie nicht); Block-interne MESH/3DFACE-Entities werden im 2D-Import nicht expandiert. ✅ **c29f27e (2026-07-05): HATCH ergänzt** — dxf-parser hat KEINEN HATCH-Handler (verwarf HATCH stumm); Lösung via `registerEntityHandler` + eigenem `HatchHandler` (sammelt rohe Gruppencodes) + testbarer `hatchContours`-Auswertung: Randpfade (Polyline-Pfade + Linien-/Bogen-Kanten, Bögen über vorhandene Tessellierung) → geschlossene Konturen mit `Contour.filled`; `contoursToDrawings` macht daraus gefüllte `polyline`-Drawing2D (fillColor-Default, restylebar). +6 Tests, Suite 320 grün. ✅ **05bc5aa (2026-07-05): HATCH-Ellipse/Spline-Kanten** ergänzt (Kantentyp 3/4 tesselliert; B-Spline-Sampling in `sampleBSpline` extrahiert). ✅ **4b93ac9 (2026-07-05): TEXT/MTEXT**`parseDxf` liefert `DxfImportResult.texts` (`ImportedText`: Position/Höhe-in-Metern/Winkel-Radiant; MTEXT-Formatcodes grob gesäubert); `textsToDrawings``{shape:"text"}`-Drawing2D; **Darstellung neu**: `addDrawing2D` emittiert ein schlankes `kind:"drawingText"`-Primitiv, PlanView rendert es rein per SVG (modellverankert, Rotation; GPU-Guard so, dass es in ALLEN Renderer-Modi im SVG bleibt); `toRenderScene` überspringt es; ImportDialog zählt/importiert Texte. +5 Tests, Suite 327 grün. **Bekannte Grenzen HATCH:** Bulges an Polyline-Rändern als Sehne; Insel-Loops = eigene Ringe (keine echten Löcher). **Bekannte Grenzen TEXT:** importierte Texte (pointerEvents:none) noch nicht per Canvas-Klick selektierbar; MTEXT-Feinformatierung flachgeklopft; Block-interne TEXT/MTEXT nicht expandiert. **Text im Tauri visuell abgenommen (Nutzer 2026-07-05)** — auch gedreht korrekt. ✅ **4ac99d3: CIRCLE/ARC als echte glatte Formen**`Contour.curve` trägt die wahre Kreis-/Bogen-Geometrie (pts bleiben für Kontext/3D); `contoursToDrawings` baut `{shape:"circle"|"arc"}`; neue Primitive `drawingCircle` (SVG `<circle>`) + `drawingArc` (SVG-Bogenpfad), `toRenderScene` tesselliert sie für den nativen Pfad; +4 Tests, Suite 331. Ellipse bleibt tesselliert (kein Ellipsen-Primitiv). **Weiter offen:** Entity→Wand-Semantik (die dicke Domainarbeit); DWG-Schreiben (einzige echte Export-Lücke).
- [ ] **STRATEGIE — „von BIM-Tool zu echtem CAD".** Direkt am Quellcode studierte Referenzen (OpenCADStudio/truck/acadrust) + Web-Import-Landkarte → konkrete, priorisierte Ansätze in **[RESEARCH_CAD_APPROACHES.md](RESEARCH_CAD_APPROACHES.md)**. Kern: (1) generisches Entity-Modell + Trait-Dispatch, (2) modeless Command-System (`StepInput`-Funnel + Kommandozeile), (3) DWG/DXF-Round-Trip via `acadrust` (MPL-2.0, pure Rust, WASM-tauglich), (4) volle Object-Snap-Schicht, (5) 3D-B-Rep später selektiv via `truck` (Apache-2.0, Geometrie-Crates WASM-fähig, Booleans/Fillets noch instabil). Der `kernel2d`-Rust/WASM-Kurs ist damit bestätigt. **Mit Nutzer priorisieren, welcher Ansatz zuerst.** - [ ] **STRATEGIE — „von BIM-Tool zu echtem CAD".** Direkt am Quellcode studierte Referenzen (OpenCADStudio/truck/acadrust) + Web-Import-Landkarte → konkrete, priorisierte Ansätze in **[RESEARCH_CAD_APPROACHES.md](RESEARCH_CAD_APPROACHES.md)**. Kern: (1) generisches Entity-Modell + Trait-Dispatch, (2) modeless Command-System (`StepInput`-Funnel + Kommandozeile), (3) DWG/DXF-Round-Trip via `acadrust` (MPL-2.0, pure Rust, WASM-tauglich), (4) volle Object-Snap-Schicht, (5) 3D-B-Rep später selektiv via `truck` (Apache-2.0, Geometrie-Crates WASM-fähig, Booleans/Fillets noch instabil). Der `kernel2d`-Rust/WASM-Kurs ist damit bestätigt. **Mit Nutzer priorisieren, welcher Ansatz zuerst.**
@@ -167,6 +167,11 @@
- **Erweiterte Kamera-Settings als Teil des Ausschnitts (Nutzer 2026-07-07):** mehr einstellbare Kamera-Parameter, die der Ausschnitt MIT speichert — u. a. **Zielhöhe** und **Augenhöhe** (Eye/Target-Z getrennt), FOV, Blickrichtung/Distanz; die Parameter dürfen **pro Projektionsart unterschiedlich** sein (Perspektive vs. Isometrie/Ortho haben je eigene sinnvolle Sets — Iso braucht z. B. orthoHalfHeight statt FOV/Augenhöhe). Heutiger Zustand: nur FOV global (`fov` in App-State, CameraMenu) + Orbit-State intern im `Wasm3DViewport` (`OrbitState` yaw/pitch/dist/target, `orthoHalfHeight`) — nicht als benannte, persistierbare Kamera-Definition nach aussen geführt. Schritt dazu: Kamera-Zustand als serialisierbares Objekt exponieren (get/set am Viewport), UI-Felder (Augenhöhe/Zielhöhe/Distanz) im Kamera-Popover, dann im Ausschnitt-Modell mitführen. - **Erweiterte Kamera-Settings als Teil des Ausschnitts (Nutzer 2026-07-07):** mehr einstellbare Kamera-Parameter, die der Ausschnitt MIT speichert — u. a. **Zielhöhe** und **Augenhöhe** (Eye/Target-Z getrennt), FOV, Blickrichtung/Distanz; die Parameter dürfen **pro Projektionsart unterschiedlich** sein (Perspektive vs. Isometrie/Ortho haben je eigene sinnvolle Sets — Iso braucht z. B. orthoHalfHeight statt FOV/Augenhöhe). Heutiger Zustand: nur FOV global (`fov` in App-State, CameraMenu) + Orbit-State intern im `Wasm3DViewport` (`OrbitState` yaw/pitch/dist/target, `orthoHalfHeight`) — nicht als benannte, persistierbare Kamera-Definition nach aussen geführt. Schritt dazu: Kamera-Zustand als serialisierbares Objekt exponieren (get/set am Viewport), UI-Felder (Augenhöhe/Zielhöhe/Distanz) im Kamera-Popover, dann im Ausschnitt-Modell mitführen.
- **Phasen-Vorschlag:** (1) Ausschnitt-Datenmodell + Speichern/Wiederherstellen (komponiert die bestehenden Combos/Overrides/View-State) + Ausschnitte-Panel (Liste/Ordner, wie `visibilitySets`-Muster). (2) Masterlayout + Layout-Blatt-Modell + Viewport-Platzierung, Viewport→Ausschnitt-Bindung. (3) Master-Vererbung + „Alle aktualisieren" + Multi-Page-PDF@DPI. Mit Nutzer Detailgrade/Papier-Defaults/Ordnerstruktur festlegen. - **Phasen-Vorschlag:** (1) Ausschnitt-Datenmodell + Speichern/Wiederherstellen (komponiert die bestehenden Combos/Overrides/View-State) + Ausschnitte-Panel (Liste/Ordner, wie `visibilitySets`-Muster). (2) Masterlayout + Layout-Blatt-Modell + Viewport-Platzierung, Viewport→Ausschnitt-Bindung. (3) Master-Vererbung + „Alle aktualisieren" + Multi-Page-PDF@DPI. Mit Nutzer Detailgrade/Papier-Defaults/Ordnerstruktur festlegen.
- [ ] **SCHNITTEBENEN als editierbare, gekoppelte Objekte (2D↔3D) — Nutzer-Vision 2026-07-07 „ein richtig ausgereiftes Tool".** Schnittebenen sollen sichtbare, im Grundriss platzier- und editierbare Objekte sein, die 2D-Schnitt und 3D-Live-Schnitt verbinden. Bausteine der Vision (verbindlich festhalten): - [ ] **SCHNITTEBENEN als editierbare, gekoppelte Objekte (2D↔3D) — Nutzer-Vision 2026-07-07 „ein richtig ausgereiftes Tool".** Schnittebenen sollen sichtbare, im Grundriss platzier- und editierbare Objekte sein, die 2D-Schnitt und 3D-Live-Schnitt verbinden. Bausteine der Vision (verbindlich festhalten):
-**Phase 1 erledigt 2026-07-17 (Kopplung + Navigation, KEIN Rust — plan: `.claude/plans/cozy-bubbling-goose.md`):** der 3D-Live-Schnitt (`Wasm3DViewport`) folgt jetzt der gewählten Schnittlinie statt einer fest verdrahteten horizontalen Ebene — die Engine kann beliebige Ebenen längst (`set_section_plane(point,normal)`), es fehlte nur die Bindung. App-State `section3dCutId` + abgeleitete `section3dPlane` via `sectionPlaneFromLevel` (world point+normal aus `linePoints`/`directionSign`, `plan/toSection.ts`) → durch `Viewport3D``Wasm3DViewport` (neue Props `section3d`/`onClearSection3d`). Schalter „Im 3D schneiden" in der Schnittlinien-Sektion des Objekt-Info-Panels (`host.section3dCutActive`/`onToggleSection3dCut`); der Viewport-Overlay-Knopf ist an die aktive Linie gebunden und hebt sie auf. **Doppelklick auf die Schnittlinie im Grundriss → springt in die 2D-Schnittansicht** (`PlanView.onOpenSectionLevel``onSelectLevel`). 2D-Editieren (Endpunkte/Blickrichtung/Tiefe) existierte schon im Objekt-Info. `tsc` + `vitest` 866 grün. **Nutzer prüft visuell in Tauri** (render3d-Pfad).
-**Phase 2 Teil „eigene Ebene / Sichtbarkeit" erledigt 2026-07-17:** eine unsichtbare Schnitt-/Ansichtsebene (`visible===false`) zeigt ihre Führungslinie im Grundriss nicht mehr (`addSectionLines` in `generatePlan.ts`, +1 Test). Ein-/ausblendbar wie andere Ebenen (Zeichnungsebenen-Panel).
- **Offen Phase 2 (engine-schwer, mit Nutzer im Tauri-Loop):** Farbe der 3D-Schnittebene + **Cut-away-Schleier/Schraffur auf der weggeschnittenen Seite** (wgpu-Shader/`section_fill.rs`/`section.rs`) — blind über Nacht bewusst NICHT gebaut (unverifizierbar ohne Tauri-Sicht, s. Arbeitsstil „Nutzer prüft render3d visuell").
- **Offen Phase 3 (engine-schwer):** 3D-POV je Schnitt (Augen-/Zielhöhe/Winkel getrennt, pro Projektionsart) im Datenmodell + eigenes 3D-Schnittfenster (Doppelklick 3D-Symbol → Clip+Kamera); koppelt an das Ausschnitte-/Kamera-System. Kamera-Kopplung an `Wasm3DViewport`-Orbit → Tauri-Verifikation nötig.
- Restliche Vision-Bausteine (unverändert offen):
- **Eigene Zeichnungs-/Grafik-Ebene „Schnittebenen":** die Schnittlinie des 2D-Schnitts liegt auf einer eigenen Ebene (ein-/ausblendbar wie andere Layer). Auch die 3D-Schnittebene wird im GRUNDRISS als Objekt dargestellt (Linie/Symbol) — vor allem, um die 3D-Schnittebene dort PRÄZISE zu setzen (im 2D positionieren statt im 3D fummeln). - **Eigene Zeichnungs-/Grafik-Ebene „Schnittebenen":** die Schnittlinie des 2D-Schnitts liegt auf einer eigenen Ebene (ein-/ausblendbar wie andere Layer). Auch die 3D-Schnittebene wird im GRUNDRISS als Objekt dargestellt (Linie/Symbol) — vor allem, um die 3D-Schnittebene dort PRÄZISE zu setzen (im 2D positionieren statt im 3D fummeln).
- **3D-Schnittebene über Topbar ein/aus** (existiert teilweise: Viewport-Toggle) + **Farbe**; alles HINTER der Ebene (die weggeschnittene Seite) wird mit einer **Misch-/Andeutungsschraffur umhüllt**, damit visuell klar ist, dass dieser Teil abgeschnitten wird — sowohl im 3D als auch als Vorschau im 2D. - **3D-Schnittebene über Topbar ein/aus** (existiert teilweise: Viewport-Toggle) + **Farbe**; alles HINTER der Ebene (die weggeschnittene Seite) wird mit einer **Misch-/Andeutungsschraffur umhüllt**, damit visuell klar ist, dass dieser Teil abgeschnitten wird — sowohl im 3D als auch als Vorschau im 2D.
- **Doppelklick auf eine Schnittebene → springt in die zugehörige Schnitt-Ansicht:** 2D-Schnittebene-Symbol → 2D-Schnittfenster; 3D-Schnittebene-Symbol → 3D-Schnittfenster (Perspektive/Ansicht mit gesetzter Clip-Ebene). Zwei Objektarten, zwei Zielansichten, gleiche Interaktion. - **Doppelklick auf eine Schnittebene → springt in die zugehörige Schnitt-Ansicht:** 2D-Schnittebene-Symbol → 2D-Schnittfenster; 3D-Schnittebene-Symbol → 3D-Schnittfenster (Perspektive/Ansicht mit gesetzter Clip-Ebene). Zwei Objektarten, zwei Zielansichten, gleiche Interaktion.
@@ -209,6 +214,7 @@
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._ _Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
- [x] 2026-07-17 **Schnittebenen 2D↔3D — Phase 1 (Kopplung + Navigation) + Phase-2-Sichtbarkeit** (uncommittet) — der 3D-Live-Schnitt folgt jetzt der gewählten Grundriss-Schnittlinie statt einer fest verdrahteten horizontalen Ebene (`Wasm3DViewport`/`Viewport3D` neue Props `section3d`/`onClearSection3d`; App-State `section3dCutId``sectionPlaneFromLevel`). „Im 3D schneiden"-Schalter im Objekt-Info (`host.section3dCutActive`/`onToggleSection3dCut`), Viewport-Knopf an die Linie gebunden. Doppelklick auf die Schnittlinie → 2D-Schnittansicht (`PlanView.onOpenSectionLevel`). Unsichtbare Schnitt-/Ansichtsebene blendet ihre Grundriss-Linie aus (`addSectionLines`). `tsc` + `vitest` 867 grün (+1). Cut-away-Schleier/Farbe (Phase 2) + POV/3D-Schnittfenster (Phase 3) bewusst offen (engine-schwer, Tauri-Verifikation nötig — s. Backlog-Eintrag). Nutzer prüft Phase 1 visuell in Tauri. UI-Nebenarbeit derselben Session (uncommittet): Werkzeug-Panel (Symbole/Liste-Umschalter, neue SIA-Icons, helle Auswahl), Topbar-Quick-Access-Icons entfernt (dupliziert das native Menü), Zahnrad→Einstellungen in Zeichnungsebenen/Ebenen-Köpfen, Footerbar + Snap-Marker + Maß-HUD auf die helle Pillen-Sprache umgestellt.
- [x] 2026-07-12 **swissBUILDINGS3D-Import auf Suchradius zugeschnitten** (`31b76a2`) — Nutzer-Frage „importiert es wirklich georeferenziert, oder landet alles am 0-Punkt?" Live gegen die echte STAC-API geprüft: Georeferenzierung selbst war KORREKT (`geocode`/`makeOrigin`/`shiftMeshToOrigin` liefern echte, nicht-triviale LV95-Koordinaten). Echter Bug daneben gefunden: eine STAC-Kachel liefert ALLE Gebäude der Kachel als EIN Mesh (`parseDxf` sammelt alles in denselben Puffer) — ohne Zuschnitt landete bei jedem Import die KOMPLETTE Kachel im Modell, bis zu mehreren km über den Suchradius hinaus (verifiziert: zwei 1.4 km auseinanderliegende Adressen derselben Kachel lieferten VORHER identische, ungeschnittene 62'532-Dreiecke-Geometrie). Neues `clipMeshToBbox` (`swissBuildings3d.ts`) schneidet + kompaktiert auf den gesuchten Radius. +4 Tests, Suite 827 grün. - [x] 2026-07-12 **swissBUILDINGS3D-Import auf Suchradius zugeschnitten** (`31b76a2`) — Nutzer-Frage „importiert es wirklich georeferenziert, oder landet alles am 0-Punkt?" Live gegen die echte STAC-API geprüft: Georeferenzierung selbst war KORREKT (`geocode`/`makeOrigin`/`shiftMeshToOrigin` liefern echte, nicht-triviale LV95-Koordinaten). Echter Bug daneben gefunden: eine STAC-Kachel liefert ALLE Gebäude der Kachel als EIN Mesh (`parseDxf` sammelt alles in denselben Puffer) — ohne Zuschnitt landete bei jedem Import die KOMPLETTE Kachel im Modell, bis zu mehreren km über den Suchradius hinaus (verifiziert: zwei 1.4 km auseinanderliegende Adressen derselben Kachel lieferten VORHER identische, ungeschnittene 62'532-Dreiecke-Geometrie). Neues `clipMeshToBbox` (`swissBuildings3d.ts`) schneidet + kompaktiert auf den gesuchten Radius. +4 Tests, Suite 827 grün.
- [x] 2026-07-12 **Fenster/Tür: 2D+3D setzen "aussen" jetzt auf dieselbe Wandfläche** (`a631dda`) — Nutzer-Report „im 2D innen bündig und im 3D aussen bündig". Root Cause: das 2D-Vorzeichen für `insetFace:"aussen"` (`windowSymbol` in `geometry/opening.ts`) war gegenüber der ECHTEN Aussenschicht der Wand (erste Wandtyp-Schicht, kleinster Achs-Offset in `addWallPoche`/`pushSegment`) invertiert — landete am inneren statt äusseren Wandputz. Dieselbe Verwechslung steckte auch in `addOpeningFrameBand` (Tür-Rahmenband), den Sturzlinien und dem Rollladenkasten (alle in `generatePlan.ts`) — alle vier gefixt. Die 3D-Seite (`openingAxisBox`/`resolveFrameNormalRange` in `toWalls3d.ts`) war bereits korrekt (zwei kompensierende Vorzeichen ergaben zufällig das richtige Ergebnis) und blieb unverändert. +1 Regressionstest, der 2D- und 3D-Rahmenposition direkt gegen die bekannte Aussenschicht der Wand prüft (statt nur relative Deltas). Suite 823 grün. - [x] 2026-07-12 **Fenster/Tür: 2D+3D setzen "aussen" jetzt auf dieselbe Wandfläche** (`a631dda`) — Nutzer-Report „im 2D innen bündig und im 3D aussen bündig". Root Cause: das 2D-Vorzeichen für `insetFace:"aussen"` (`windowSymbol` in `geometry/opening.ts`) war gegenüber der ECHTEN Aussenschicht der Wand (erste Wandtyp-Schicht, kleinster Achs-Offset in `addWallPoche`/`pushSegment`) invertiert — landete am inneren statt äusseren Wandputz. Dieselbe Verwechslung steckte auch in `addOpeningFrameBand` (Tür-Rahmenband), den Sturzlinien und dem Rollladenkasten (alle in `generatePlan.ts`) — alle vier gefixt. Die 3D-Seite (`openingAxisBox`/`resolveFrameNormalRange` in `toWalls3d.ts`) war bereits korrekt (zwei kompensierende Vorzeichen ergaben zufällig das richtige Ergebnis) und blieb unverändert. +1 Regressionstest, der 2D- und 3D-Rahmenposition direkt gegen die bekannte Aussenschicht der Wand prüft (statt nur relative Deltas). Suite 823 grün.
- [x] 2026-07-12 **Georeferenzierung: persistenter Standort-Bezug statt Neuberechnung je Import (Teil-Fix)** (`ae47b4f`) — erster Design-Punkt aus dem GEO-BLOCK-Report umgesetzt. Neues `Project.geoAnchor` (`{lv95, model, label?}`) verbindet einmalig „dieser LV95-Punkt = dieser Modellpunkt"; der ERSTE Import in einem Projekt setzt ihn automatisch, alle folgenden Importe (`ContextImportDialog`) verwenden denselben Anker statt je unabhängig `makeOrigin(center)` neu zu berechnen — behebt die Lage-Inkonsistenz zwischen mehreren Importen in einem Projekt. `tsc` sauber, Suite 794 grün. **Bewusst Teil-Fix:** Anker sitzt immer bei Modell-(0,0), es gibt noch kein Werkzeug, um ihn auf einen beliebigen, vom Nutzer gewählten Modellpunkt zu legen — bleibt offen. - [x] 2026-07-12 **Georeferenzierung: persistenter Standort-Bezug statt Neuberechnung je Import (Teil-Fix)** (`ae47b4f`) — erster Design-Punkt aus dem GEO-BLOCK-Report umgesetzt. Neues `Project.geoAnchor` (`{lv95, model, label?}`) verbindet einmalig „dieser LV95-Punkt = dieser Modellpunkt"; der ERSTE Import in einem Projekt setzt ihn automatisch, alle folgenden Importe (`ContextImportDialog`) verwenden denselben Anker statt je unabhängig `makeOrigin(center)` neu zu berechnen — behebt die Lage-Inkonsistenz zwischen mehreren Importen in einem Projekt. `tsc` sauber, Suite 794 grün. **Bewusst Teil-Fix:** Anker sitzt immer bei Modell-(0,0), es gibt noch kein Werkzeug, um ihn auf einen beliebigen, vom Nutzer gewählten Modellpunkt zu legen — bleibt offen.
+105 -94
View File
@@ -1,16 +1,17 @@
# DOSSIER Standalone # Dossier
Open-Source-CAAD (Computer Aided Architecture Design). Modelliert ein Gebäude Open-Source-CAAD (Computer Aided Architecture Design). Modelliert ein Gebäude
aus semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne — aus semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne —
ohne Revit. Als Desktop-App und im Browser zugänglich; die Desktop-App ist die ohne Revit. **Desktop-App** — auf macOS über **Tauri**, auf Linux über eine
vollständige Fassung. **Electron**-Shell (weil Tauris Linux-Webview WebKitGTK kein zuverlässiges
WebGPU bietet, das die Rendering-Engines brauchen); dieselbe Codebasis läuft
auch im Browser (eingeschränkt, ohne native Fenster/Dateisystem-Integration).
Das ist die eigenständige Standalone-Variante des Rhino-Plugins Das ist die eigenständige Neuimplementierung des Rhino-Plugins
[DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse, [DOSSIER](https://git.openbureau.ch/karim/dossier): dieselbe Denkweise
Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als (Geschosse, Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-
React-App mit eigener Rendering-Engine (Rust/WASM/WebGPU) statt als Rhino-Aufsatz. Verschneidung), aber mit eigenem Datenmodell + eigener Rendering-Engine
Als Desktop-App (Tauri) läuft sie im eigenen Fenster mit voller Engine-Leistung; (Rust/WASM/WebGPU, intern „Nordstern" genannt) statt Rhino-Aufsatz.
browserseitig ist derselbe Kern zugänglich, die App ist die vollständige Fassung.
## Grundgedanke ## Grundgedanke
@@ -22,117 +23,124 @@ in die Geometrie eingebacken. Wer eine Wand verschiebt, verschiebt sie überall.
Konkret heißt das auch: Der Grundriss entsteht nicht aus einem zerschnittenen Konkret heißt das auch: Der Grundriss entsteht nicht aus einem zerschnittenen
3D-Mesh, sondern **analytisch aus den Bauteil-Parametern** (Wandachsen + Dicken → 3D-Mesh, sondern **analytisch aus den Bauteil-Parametern** (Wandachsen + Dicken →
Linien, Öffnungen → Lücken + Symbol). PDF-Export ist derselbe Plan, nur mit Linien, Öffnungen → Lücken + Symbol). PDF-Export ist derselbe Plan, nur mit
echten mm-Stiftstärken statt Bildschirm-Hairlines — kein zweites Rendering. echten mm-Stiftstärken statt Bildschirm-Hairlines. Schnitte und Ansichten laufen
Schnitte und Ansichten brauchen den zweiten Weg — echte 3D-Projektion mit über eine eigene, analytische Rust-Pipeline (kein Hidden-Line-Removal nötig,
verdeckten Kanten (HLR) —, dessen Machbarkeit per Spike bewiesen, aber noch siehe [ARCHITECTURE.md](ARCHITECTURE.md) §4.3) und sind seit Juli live im
nicht ans UI angebunden ist. 3D-Viewport verdrahtet, nicht nur ein Spike.
## Stand heute ## Stand heute
Ehrlich eingeordnet: aus dem Risiko-Spike ist ein **benutzbares 2D-CAD mit Aus dem ursprünglichen Risiko-Spike ist binnen weniger Wochen ein
abgeleiteter 3D-Sicht, Vektor-PDF/DXF-Export und einer parametrischen **funktionsreiches Desktop-BIM-Tool** geworden: eigenes semantisches
Wand-Engine** geworden. Der einfachere Teil steht und ist per Screenshot/Probe Gebäudemodell, zwei eigene Rust/WASM-Rendering-Engines, IFC/DXF/PDF/STL/OBJ-
verifiziert; die schwierigen BIM-Kernrisiken sind bewusst noch offen. Export, Swisstopo-Import, SIA-416-Flächen, Materialbibliothek, Layouts/
Plansätze, native Tauri-Fenster. Eine vollständige, ehrliche Bestandsaufnahme
(inklusive offener Punkte und bekannter technischer Schulden) steht in
**[STATUS.md](STATUS.md)**.
**Funktioniert:** **Funktioniert (Auszug — volle Liste in STATUS.md §3):**
- Semantisches Modell mit **mehrschichtigen Wänden** und automatischer - Semantisches Modell mit **mehrschichtigen Wänden**, L-Eck-Gehrung **und**
**L-Ecken-Gehrung** (`computeJoins`); dieselbe Logik speist Plan *und* 3D. Prioritäts-T-/X-Stössen (`joinPriority` am Component) — konsistent in
- **Parametrische Wände** (`ParametricWall`-Regelwerke: Grid/Modul/Sequenz/ Grundriss, 3D-Viewport und 3D-Live-Schnitt.
Referenzlinie/bedingte Dicke) lösen sich zu konkreten `Wall`-Objekten auf, - **Parametrische Wände**, Decken, Treppen (gerade/L/Wendel), Dächer
statt jede Wand einzeln von Hand zu ziehen. (Flach/Pult/Sattel/Walm/Mansarde/Zelt), Stützen, SIA-416-Räume mit
- **Dokumentmodell wie in DOSSIER:** Zeichnungsebenen (Geschosse + Schnitte/ automatischer Bilanz + CSV-Export.
Ansichten/Zeichnungen) × Kategorie-Ebenen (Code-Baum 1:1, `20 Wände`, `30 Decken` …). - **Türen & Fenster** gehostet in Wänden, mit Rahmen/Zarge/Kämpfer/Oberlicht,
- **Zeichnen & Editieren** im Grundriss: Wand, Linie, Polylinie, Rechteck, Kreis, Detailgrad grob/mittel/fein, echte Rechteck-Löcher im 3D-Wandkörper.
Öffnungen, Treppen, Decken, Raumstempel; Snapping (Endpunkt/Mittelpunkt/ - Rhino-artiges **Kommandosystem** mit getippten Koordinaten (`5,3` · `r5,3` ·
Schnittpunkt/Lot/Raster/Ortho), Grips, Move/Copy/Offset, Spiegeln/Drehen/Array, `5<45`) und Tab-Feld-Zyklus; Snapping, Grips, Trim/Split/Join, 2D-Booleans.
Trim/Split/Join. - **Live 2D↔3D-Schnitt**: eine Schnittebene im 3D-Viewport folgt derselben
- **Rhino-artiges Befehlssystem** mit getippten Koordinaten (`5,3` · `r5,3` · Prioritäts-Logik wie der 2D-Plan-Schnitt (Rust-Port, keine Diskrepanz).
`5<45`) und Tab-Feld-Zyklus (Länge → Winkel …). - **Vektor-Export:** PDF (Einzel- **und Mehrseiten-Layouts**), DXF, **IFC4**
- **Vektor-Export:** PDF (A4/A3, Titelblock, echte mm-Stiftstärken nach ISO-Pen- (mit echten Fenster-/Tür-Löchern), STL/OBJ, CSV-Bauteilliste.
Steps, keine Rasterbilder) und DXF — beide aus derselben `Plan`-Struktur wie - **Materialbibliothek**: 13 gebündelte PBR-Starter **plus** Live-Suche der
der Bildschirm. kompletten ambientCG-Bibliothek (1K/2K/4K, On-Demand-Download).
- **PBR-Material-Bibliothek** (ambientCG-Import, `manifest.json`) für die - **Layouts/Plansätze** mit mehreren Viewports pro Blatt, Ausschnitte
3D-Ansicht. (View-Snapshots), Kamera-Presets, Norden-Rotation.
- **Resource Manager** (Component / Hatch / Line) — alles per id referenziert, - **Import:** DXF/DWG, Swisstopo (swissBUILDINGS3D/-ALTI3D/SWISSIMAGE,
zentral änderbar. radiusgenau zugeschnitten), OSM-Kontextimport, `.lin`/`.pat`.
- **Panel-System** (dockbar, stapelbar, floatend), Top-Bar + Statusleiste - **Native Desktop-Integration** (Tauri): eigene randlose Fenster, native
(echter Maßstab 1:N, Cursor X/Y), eigenes Kontextmenü, **i18n** (de/en). Speichern/Öffnen-Dialoge, eigenes `.obp`-Dateiformat mit OS-Level-Lock gegen
- **Import:** DXF/DWG (Konturen/Mesh) → Terrain-TIN, Swisstopo/LV95-Geokontext, Doppelöffnen, mehrere native Zusatzfenster (Resources, Settings, …).
OSM-Kontextimport; `.lin`/`.pat` für Linien/Schraffuren. - **Resource Manager** (Component/Hatch/Line/Typ-Editoren), regelbasierte
Overrides, Panel-System (dockbar/floatend), i18n (de/en).
**Bewusst noch offen** (die eigentlich harten Teile): **Bewusst noch offen** (Details + volle Liste: STATUS.md §4.7):
- Echte **3D-Booleans** für Öffnungen — Türen/Fenster sind im Plan eine Lücke, - **Echtes Mesh-Boolean für Öffnungen**funktioniert heute über achsparallele
noch kein geschnittenes Volumen. Rechteck-Löcher; ein generisches CSG-Boolean existiert bereits
- **HLR** (verdeckte Kanten) für Schnitte und Ansichten: Machbarkeit per (`trucksolid`/`csgrs`), ist aber nicht an die Wand-Pipeline angeschlossen.
OCCT-WASM-Spike bewiesen (`docs/welle-c-hlr-spike/`, `src/section/hlr.ts`), - **3D-Griffsystem**: Feld-Controller (Tab-Zyklus) und Snapping fehlen für
aber noch nicht ans UI/Dokumentmodell verdrahtet — Views sind noch Stubs. 3D-Grip-Drag (im 2D vollständig vorhanden).
- **Prioritäts-T-/X-Stöße** mehrschichtiger Wände (Beton läuft durch, Putz - **DWG/DXF-Domänenimport** (Entitäten → echte Wände/Öffnungen) — Lesen
verbindet seitlich) — das berüchtigte Risiko #1. funktioniert, das Mapping auf das Modell ist unbegonnen; DWG-Schreiben fehlt.
- **Multi-Page-Layouts/Ausschnitte** (mehrere Viewports pro Blatt) — PDF-Export - **`make2D`**-Kommando (3D-Ansicht → flacher 2D-Plan mit Füllungen).
ist noch single-sheet. - Bekannte, noch nicht bereinigte Doppelspur `Door[]`/`Opening[]` im Modell.
Details und die Begründungen stehen im
[HANDOVER](HANDOVER.md) (laufendes Arbeitsprotokoll) und in der
[ROADMAP](ROADMAP.md) (Vision, Phasen, Backlog).
## Stack ## Stack
Bewusst leichtgewichtig — Three.js ist reiner Display-Layer, kein schwerer
Geometrie-Kernel verfrüht eingezogen.
| | | | | |
|---|---| |---|---|
| Shell | Electron (eigenes randloses App-Fenster, kein Browser-Tab) | | Shell | **Tauri** auf macOS (WKWebView+WebGPU) · **Electron/Chromium** auf Linux (WebKitGTK kann kein WebGPU) — beide randlos, eigene Titelleiste, gemeinsame React-App |
| Frontend | React + TypeScript + Vite | | Frontend | React + TypeScript + Vite |
| 3D | Three.js, plus eigener nativer Rust/WASM/WebGPU-Renderer (`render3d`) | | 3D | eigener Rust/WASM/WebGPU-Renderer „Nordstern" (`render3d`, Default), Three.js als leichtgewichtiger Zweitpfad |
| 2D-Plan | eigener SVG-Renderer, plus eigener nativer Rust/WASM/WebGPU-Renderer (`render2d`) | | 2D-Plan | eigener Rust/WASM/WebGPU-Renderer (`render2d`), plus ein TS/WebGL2-Renderer (`plan/glPlan`), plus SVG (`PlanView.tsx`, immer als Interaktions-/Fallback-Ebene aktiv) |
| Geometrie | eigener 2D-Kernel (`src/geometry/kernel2d.ts`), `delaunator` fürs Terrain | | Geometrie | eigener 2D-Kernel (`src/geometry/kernel2d.ts`); ein Rust-Port (`src-tauri/kernel2d`) existiert nur als Paritätstest, läuft nicht produktiv |
| Export | `jspdf`/`svg2pdf.js` (Vektor-PDF), eigener DXF-Writer | | CSG/Extrusion | `trucksolid`-Crate (`truck` + `csgrs`), fürs Extrude-Kommando |
| Export | eigener IFC4-Writer, `jspdf`/`svg2pdf.js` (Vektor-PDF), eigener DXF-Writer, STL/OBJ |
| Import | `dxf-parser`, `@mlightcad/libredwg-web` (DWG), eigene `.lin`/`.pat`-Parser | | Import | `dxf-parser`, `@mlightcad/libredwg-web` (DWG), eigene `.lin`/`.pat`-Parser |
| Schnitt/HLR-Spike | `opencascade.js` (OCCT-WASM) — isoliert, noch nicht verdrahtet | | Materialien | `jszip` (ambientCG-Zip-Entpacken), `three.js` `TextureLoader` |
Geplant, aber noch nicht eingezogen: `rhino3dm` (NURBS / `.3dm`), web-ifc (IFC), `opencascade.js` steht noch als Dependency in `package.json`, wird aber nur
Manifold (exakte 3D-Booleans). OCCT-WASM ist für HLR bereits als Spike da (s.o.), noch von totem Code (`src/section/hlr.ts`, superseded durch die Rust-
aber noch nicht an Dokumentmodell/UI angebunden. Siehe ROADMAP §4. Schnitt-Pipeline) importiert. Details zu allen Rust-Crates (inkl. zwei
aktuell unbenutzten WASM-Builds) in [ARCHITECTURE.md](ARCHITECTURE.md) §1
und [STATUS.md](STATUS.md) §4.1.
## Entwicklung ## Entwicklung
```bash ```bash
npm install npm install
npm run dev # Vite, http://localhost:5187 npm run dev # Vite, http://localhost:5187
npm run electron # Electron-Fenster (Dev-Server + randlose App-Shell) npm run tauri:dev # Tauri-Fenster (macOS — nativer Zielrahmen dort)
npm run electron # Electron-Fenster (Linux — WebGPU über Chromium statt WebKitGTK)
npx tsc -b # Typecheck npx tsc -b # Typecheck
npm run build # tsc -b && vite build npm run build # tsc -b && vite build
npm test # vitest run npm test # vitest run
``` ```
Verifiziert wird visuell: `node scripts/probe.mjs` rendert die App headless und WASM-Engines nach Rust-Änderungen neu bauen (nur `.rs` committen,
schreibt `scripts/probe.png` — Screenshot ansehen, Geometrie prüfen. Weitere `src/engine/pkg*/` ist gitignored):
`scripts/probe-*.mjs` decken einzelne Features ab (PDF, Trim, Materialien,
Theme, Boolean-Ops …); für Firefox-Fälle gibt es `scripts/probe-ff*.mjs` ```bash
(Playwright). npm run build:engine # render2d
npm run build:engine3d # render3d
npm run build:truck # trucksolid
```
render3d/Wasm3DViewport-Änderungen sind nicht per Puppeteer/Browser
verifizierbar — im laufenden `tauri:dev`-Fenster selbst testen.
## Aufbau ## Aufbau
``` ```
src/ src/
model/ semantisches Modell + Ableitungen (types, parametricWalls, roomStamp, joins, terrain) model/ semantisches Modell (types, joins, parametricWalls, roomStamp, terrain)
geometry/ 2D-Kernel (offset/trim/fillet/intersect, ceiling, opening, roomArea/-Boundary, stair) geometry/ 2D-Kernel (offset/trim/fillet/intersect, ceiling/opening/roomArea/stair/roof/column)
commands/ Rhino-artiges Befehlssystem (engine, parser, registry, cmds/) commands/ Rhino-artiges Befehlssystem (engine, parser, registry, cmds/)
tools/ interaktive Zeichenwerkzeuge + Snapping + Transformationen tools/ interaktive Zeichenwerkzeuge + Snapping + Transformationen
plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG) + glPlan (WebGL2-Renderer) plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG) + glPlan (WebGL2) + Rust-Bindings
viewport/ Viewport3D (Three.js) viewport/ Viewport3D (Three.js) + Wasm3DViewport (Rust/wgpu „Nordstern", Default)
section/ HLR-Spike (OCCT-WASM) — Schnitte/Ansichten, noch nicht verdrahtet section/ TOTER Code (OCCT-HLR-Spike) — Schnitt läuft über render3d, siehe ARCHITECTURE.md §4.3
export/ PDF- und DXF-Export aus derselben Plan-Struktur wie der Bildschirm export/ IFC4-, PDF-, DXF-, STL/OBJ-, CSV-Export aus derselben Plan-/Modell-Struktur
materials/ PBR-Material-Bibliothek (ambientCG-Import, Runtime) materials/ ambientCG-Live-Suche + gebündelte Starter-Bibliothek + PBR-Runtime
text/ Rich-Text (Beschriftungen, Textobjekte)
panels/ dockbares Panel-System + die einzelnen Paletten panels/ dockbares Panel-System + die einzelnen Paletten
state/ Store + Slices (project/selection/view/layout) state/ eigener Store (useSyncExternalStore) + Slices (project/selection/view/layout/…)
ui/ Top-Bar, Statusleiste, Kontextmenü, Resource Manager, Command-Line native/ Tauri-only: native Fenster, macOS-Menüleiste, Fenster-Chrome
ui/ App.tsx-Shell, Top-Bar, Resource Manager, Kontextmenü, Kommandozeile, Ribbon
io/ Import/Export (DXF, DWG, .lin, .pat), Swisstopo/LV95, OSM-Kontext io/ Import/Export (DXF, DWG, .lin, .pat), Swisstopo/LV95, OSM-Kontext
i18n/ Wörterbücher de/en i18n/ Wörterbücher de/en
src-tauri/ Rust-Crates (render2d/render3d) — headless per wasm-pack zu WASM src-tauri/ 6 Rust-Crates (render2d/render3d/geometry/kernel2d/trucksolid/dwgimport,
gebaut (npm run build:engine), Tauri-Host selbst ist ausrangiert je headless UND per wasm-pack baubar) + der Tauri-Host selbst
``` ```
## Konventionen ## Konventionen
@@ -141,21 +149,24 @@ src-tauri/ Rust-Crates (render2d/render3d) — headless per wasm-pack zu WAS
(Design Layer, Component, Hatch, Wall Style). **UI-Texte sind deutsch**, immer (Design Layer, Component, Hatch, Wall Style). **UI-Texte sind deutsch**, immer
über `t('key')` — keine hartcodierten Strings im JSX. über `t('key')` — keine hartcodierten Strings im JSX.
- Intern alles in **Metern**; Anzeige via `formatM`. - Intern alles in **Metern**; Anzeige via `formatM`.
- `App.tsx` bleibt dünner Shell, Zustand lebt im Store. Verbindliches in - Verbindliches in [CONVENTIONS.md](CONVENTIONS.md).
[CONVENTIONS.md](CONVENTIONS.md).
## Weiterlesen ## Weiterlesen
- [ROADMAP.md](ROADMAP.md) — Produktvision, Architektur-Entscheidungen, Phasen 07, DOSSIER-Backlog - [STATUS.md](STATUS.md) — **vollständige, ehrliche Bestandsaufnahme**: Zahlen,
- [ARCHITECTURE.md](ARCHITECTURE.md) — Technische Architektur im Detail Feature-Inventar, Mist-Liste, Doku-Widersprüche, Tag-1-Vision vs. heute
- [HANDOVER.md](HANDOVER.md) — aktueller Arbeitsstand, Befunde, nächste Schritte - [ARCHITECTURE.md](ARCHITECTURE.md) — technische Architektur im Detail (Ist-Zustand)
- [docs/](docs/) — Design-Specs (Befehlssystem, Zeichenwerkzeuge, Wand-Joins, Backend …) - [ROADMAP.md](ROADMAP.md) — ursprüngliche Produktvision (Tag-1-Stand, historisch)
- [HANDOVER.md](HANDOVER.md) / [PENDENZEN.md](PENDENZEN.md) — laufendes
Arbeitsprotokoll bzw. Backlog (Single Source of Truth für offene Punkte)
- [docs/](docs/) — Design-Specs (teils ebenfalls Tag-1-Vision, siehe Hinweis
in `docs/README.md`)
## Lizenz ## Lizenz
Copyright © 2026 Karim Gabriele Varano. Veröffentlicht unter der Copyright © 2026 Karim Gabriele Varano. Veröffentlicht unter der
**GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)** **GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)**
siehe [LICENSE](LICENSE). DOSSIER ist Teil der **openbureau**-Suite. siehe [LICENSE](LICENSE). Dossier ist Teil der **openbureau**-Suite.
Die AGPL verlangt, dass auch bei Betrieb als Netzwerk-/Webdienst der (ggf. Die AGPL verlangt, dass auch bei Betrieb als Netzwerk-/Webdienst der (ggf.
geänderte) Quellcode für die Nutzer verfügbar gemacht wird. Drittkomponenten geänderte) Quellcode für die Nutzer verfügbar gemacht wird. Drittkomponenten
@@ -164,4 +175,4 @@ behalten ihre jeweiligen Lizenzen (siehe „Über"-Dialog in der App).
--- ---
Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen, Swisstopo-Geodaten) Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen, Swisstopo-Geodaten)
sind als Differenzierer eingeplant. sind ein bewusster Differenzierer.
+11 -1
View File
@@ -1,6 +1,16 @@
# Browser-BIM für Wohnbau — Architektur & Produkt-Roadmap # Browser-BIM für Wohnbau — Architektur & Produkt-Roadmap
> Arbeitstitel: **cad** (Name später) > **Historisches Dokument (Tag-1-Vision, Stand 2026-06-28).** Vieles hier als
> „Phase 25"/„Backlog" gelistete ist inzwischen längst gebaut (Treppen, Dächer,
> Stützen, SIA-416, Swisstopo, OSM, Layouts, Ausschnitte, Kamera-Presets …), und
> mehrere Technologie-Entscheidungen liefen anders (eigene Rust/WASM-Engines
> statt Three.js/OpenCascade.js/web-ifc, siehe unten §4/§8). Für den aktuellen
> Ist-Zustand: **[STATUS.md](STATUS.md)** (Bestandsaufnahme + Mist-Liste) und
> **[ARCHITECTURE.md](ARCHITECTURE.md)** (aktuelle Architektur). Dieses Dokument
> bleibt als ursprüngliche Produktvision/Phasenplan stehen, wird aber nicht mehr
> laufend nachgeführt.
>
> Arbeitstitel: **cad** (Name später: **Dossier**)
> Ausrichtung: **BIM-first** · Nische: **Wohnbau / Einfamilienhäuser** > Ausrichtung: **BIM-first** · Nische: **Wohnbau / Einfamilienhäuser**
> Stand: 2026-06-28 > Stand: 2026-06-28
+342
View File
@@ -0,0 +1,342 @@
# STATUS — Codebase-Analyse
> Stand: 2026-07-21 · Vollständige Bestandsaufnahme von Code + Dokumentation.
> Ersetzt NICHT [ROADMAP.md](ROADMAP.md)/[ARCHITECTURE.md](ARCHITECTURE.md) (die wurden
> im gleichen Zug überarbeitet), sondern begründet die Überarbeitung mit Zahlen und
> Befunden. [HANDOVER.md](HANDOVER.md) und [PENDENZEN.md](PENDENZEN.md) bleiben die
> laufenden Arbeitsprotokolle (nicht rückwirkend umgeschrieben).
## 0. TL;DR
„Dossier" (Arbeitstitel `cad`, Rhino-Vorbild `DOSSIER`) ist in **3 Wochen**
(erster Commit 2026-06-30, 362 Commits bis 2026-07-20) von einem Risiko-Spike zu
einem **funktionsreichen Desktop-CAD/BIM-Tool** gewachsen: eigenes semantisches
Gebäudemodell, zwei eigene Rust/WASM-Rendering-Engines („Nordstern"), ein
Rhino-artiges Kommandosystem, IFC/DXF/PDF/STL/OBJ-Export, Swisstopo-Import,
SIA-416-Flächen, Materialbibliothek (statisch + live von ambientCG), Layouts/
Plansätze, Ausschnitte, native Tauri-Fenster. **~125.000 Zeilen Code** (TS+Rust,
inkl. Tests), gebaut über viele autonome Agent-Sessions.
Die drei zentralen Vision-Dokumente (ARCHITECTURE.md, README.md, ROADMAP.md)
stammen aus der **allerersten Woche** (Stand 28./29.6.) und beschreiben einen
Plan, der in der Zwischenzeit an vielen Stellen überholt, anders gelöst oder
längst umgesetzt wurde (z. B. HLR/OCCT→eigene Rust-Schnitt-Pipeline,
„Booleans noch offen"→teilweise längst gelöst). Diese Doku-Drift war der Auslöser
für diese Analyse; die Docs sind im gleichen Zug revidiert worden.
## 1. Kennzahlen
### Code-Umfang
| Bereich | Dateien | LOC (ohne Tests) | Tests |
|---|---:|---:|---:|
| `src/` (TypeScript, gesamt) | ~230 | 87.736 | 869 (Vitest, 71 Dateien) / 16.855 LOC |
| `src-tauri/render3d` („Nordstern" 3D) | 14 | 10.246 | 97 `#[test]` |
| `src-tauri/render2d` (2D-WGSL-Renderer) | 11 | 3.530 | 18 `#[test]` |
| `src-tauri/kernel2d` (Rust-Geometriekern, Paritätstest) | 1 | 3.383 | 18 `#[test]` |
| `src-tauri/geometry` (Wand-Join-Mathe, **unbenutzt**) | 1 | 1.057 | 8 `#[test]` |
| `src-tauri/trucksolid` (CSG/Extrusion, `truck`+`csgrs`) | 2 | 758 | 15 `#[test]` |
| `src-tauri/dwgimport` (DXF-Parser-Spike, **unbenutzt**) | 1 | 284 | 1 `#[test]` |
| `src-tauri/src` (Tauri-Host: Fenster, Dialoge, Lock) | 4 | 1.044 | — |
| **Gesamt** | | **~108.000** (ohne Tests) / **~125.000** (mit Tests) | 869 Vitest + 157 Rust-Tests |
Größte TS-Bereiche: `plan/` (23.626 LOC — Plan-Ableitung + 3 Renderer),
`ui/` (12.897 LOC — App-Shell, ResourceManager, Ribbon), `panels/` (9.538 LOC),
`model/` (7.043 LOC inkl. Tests), `commands/` (6.973 LOC), `io/` (5.299 LOC),
`state/` (5.297 LOC), `geometry/` (5.559 LOC), `viewport/` (5.165 LOC),
`export/` (4.280 LOC). `src/App.tsx` allein ist **7.130 Zeilen**.
### Tempo
362 Commits in 3 Wochen; Woche 27 (30.6.6.7.): 233 Commits, Woche 28: 126,
danach starker Rückgang (Woche 30 bislang 3) — die Session-Dichte hat spürbar
abgenommen, nicht das Projekt gestoppt (siehe PENDENZEN.md, weiterhin aktiv).
## 2. Architektur, wie sie WIRKLICH ist (nicht wie geplant)
### 2.1 Datenmodell — kein `Element[]`-Union, sondern typisierte Arrays
`ARCHITECTURE.md` (alt) plante eine diskriminierte Union `Element = Wall | Door |
Window | …`. Tatsächlich hält `Project` (`src/model/types.ts:2095`) **pro
Bauteiltyp ein eigenes optionales Array**: `walls`, `ceilings?`, `roofs?`,
`doors`, `openings?` (Fenster/Türen gehostet in Wänden, `kind:"window"|"door"`),
`stairs?`, `rooms?`, `columns?`, `extrudedSolids?`, `drawings2d`, `context?`
(Importe/Terrain), plus die Bibliotheks-/Typtabellen (`lineStyles`, `hatches`,
`components`, `wallTypes`, `roofTypes?`, `doorTypes?`, …) und die
Dokument-Ebene (`viewSnapshots?`, `layouts?`, `masterLayouts?`, …). Der Typ-Alias
`Element` (types.ts:1601) existiert zwar noch, wird aber **nirgends** verwendet
(Element-Baum/Selektion arbeiten direkt auf den typisierten Arrays). Praktisch
funktioniert das gut (jeder Bauteiltyp hat sein eigenes, spezifisches Interface),
ist aber eine bewusste Abweichung vom ursprünglichen Uniform-Union-Plan.
### 2.2 State — kein Zustand/Redux/Immer, sondern ein Eigenbau
`docs/design/state-architecture.md` empfahl **Zustand**. Gebaut wurde stattdessen
ein **abhängigkeitsfreier Store auf `useSyncExternalStore`** (`src/state/store.ts`,
gleiches Muster wie `src/i18n`). `createStore()` komponiert Slice-Fabriken
(`projectSlice` inkl. Undo/Redo, `historySlice`, `selectionSlice`, `viewSlice`,
`layoutSlice`, `siteSlice`, `notifySlice`) über eine gemeinsame `RootState`.
Funktioniert, aber: der geplante Folgeschritt „App.tsx wird dünne Shell,
View-Routing nach `src/views/`, Kontextmenüs nach `src/menus/`" ist **nicht**
passiert — `src/views/` und `src/menus/` existieren nicht, View-Umschaltung und
Kontextmenü-Aufbau liegen weiterhin inline in `App.tsx` (7.130 Zeilen). Das ist
der deutlichste Doku-vs-Code-Widerspruch im ganzen Repo (CONVENTIONS.md
verlangt explizit das Gegenteil).
### 2.3 Rendering — drei 2D-Pfade, zwei 3D-Viewports
**2D-Plan:** es gibt tatsächlich **drei** koexistierende Renderer, nicht einen:
1. `PlanView.tsx` (SVG) — Referenz-/Fallback-Pfad, bleibt IMMER im DOM für
Hit-Testing/Grips, unabhängig davon was zeichnet.
2. `plan/glPlan/` — eigener TypeScript-WebGL2-Renderer (`glPlanCompile/-Render/
-Shaders/-Hatch.ts`).
3. `useWasmPlanRenderer.ts` → Rust-`render2d`-Crate (WGSL, nativ via wgpu,
Web via WebGPU/WebGL2-Fallback, inkl. echtem Text-Rendering via `glyphon`).
Beide GPU-Pfade fallen bei Initialisierungsfehler still auf SVG zurück.
**3D:** ebenfalls zwei Viewports: `Viewport3D.tsx` (three.js, „Free"-Stufe) und
`Wasm3DViewport.tsx` (Rust/wgpu „Nordstern", editierbar, **Default**). Ein
Settings-Schalter wählt die Engine.
### 2.4 Schnitt/Section — NICHT über HLR, sondern eigene Rust-Pipeline
`src/section/hlr.ts` + `occt.ts` (der ursprüngliche OpenCascade.js-HLR-Spike aus
Phase 0) hat **keinen einzigen Aufrufer mehr** im gesamten `src/` — toter Code.
Der tatsächliche, funktionierende Live-Schnitt läuft über einen völlig anderen,
analytischen Mechanismus: `App.tsx` (`section3dCutId`/`section3dPlane`) →
`Wasm3DViewport.tsx` (`section3d`-Prop → `setSectionPlane`) →
`src-tauri/render3d/src/{section.rs, section_boolean.rs, section_fill.rs}`.
Die Rust-Seite nutzt aus, dass jedes Bauteil ein Prisma mit konstantem
Querschnitt ist — eine Schnittebene liefert dadurch immer ein
achsparalleles Rechteck, nie ein Trapez; `section_boolean.rs` ist ein 1:1-Port
von `toSection.ts::subtractDominantBands`, damit 2D-Plan-Schnitt und
3D-Live-Schnitt exakt übereinstimmen. Kein Worker, kein Comlink (beides war
geplant, keines existiert) — läuft synchron/GPU-seitig.
### 2.5 Öffnungen als Löcher — echt, aber kein Mesh-Boolean
Fenster/Türen schneiden echte achsparallele Rechteck-Löcher aus dem Wandkörper
(`plan/toWalls3d.ts` `RHole`/`subtractSpans`, gespiegelt in
`render3d/{mesh.rs,section.rs}`) — funktioniert, ist aber KEIN generisches
Mesh-Boolean. Ein echtes CSG-Boolean existiert bereits (`trucksolid::boolean_mesh`,
`csgrs`-basiert, 15 Rust-Tests) und wird von `src/engine/truckSolid.ts` für das
Extrusions-Kommando genutzt — ist aber **nicht** an die Wand/Öffnungs-Pipeline
angeschlossen (bestätigt: `booleanMesh` hat ausserhalb von `truckSolid.ts`
keinen Aufrufer).
### 2.6 Rust-Workspace: sechs unabhängige Crates, nicht ein Workspace
`src-tauri/Cargo.toml` bindet nur den Tauri-Host (`cad-tauri`) als Workspace-
Mitglied; `render2d/render3d/geometry/kernel2d/trucksolid/dwgimport` sind
**eigenständige Cargo-Packages**, die dem Host nur optional (Features
`native2d`/`native3d`, standardmässig AUS) als Path-Dependency zugespielt
werden. Jedes Crate muss headless (`cargo test`) UND per `wasm-pack --features
web` bauen, ohne den Tauri-Toolchain-Zwang zu erben — bewusst so geschnitten.
Geteilte Abhängigkeiten: `wgpu 29`/`naga 29` (render2d+render3d, versionsgekoppelt
wegen `glyphon 0.11`), `truck-modeling`+`csgrs`(gepinnter Git-Rev)+`nalgebra`
nur in `trucksolid`.
### 2.7 Zwei Desktop-Rahmen: Tauri (macOS) + Electron (Linux)
Die App läuft plattformabhängig in **zwei verschiedenen nativen Rahmen** —
das ist Absicht, kein Wildwuchs, und hängt an **WebGPU**:
- **macOS → Tauri.** WKWebView unterstützt WebGPU, das die render2d/render3d-
WASM-Engines brauchen. `src-tauri/tauri.conf.json` (Identifier
`ch.dossier.cad`, eigene Titelleiste, `trafficLightPosition`) +
`isTauriRuntime()`-Gates an 6+ Stellen in `App.tsx` + vier eigene native
Zusatzfenster (`src/native/`: Resources, Settings, DrawingLevels,
LayerSettings, ContextImport).
- **Linux → Electron.** Tauris Linux-Webview **WebKitGTK unterstützt WebGPU
nicht zuverlässig** → dort läuft die App über eine Electron/Chromium-Shell
(`scripts/electron-main.cjs` + `electron-preload.cjs`, gestartet via
`npm run electron`). Der Kommentar in `electron-main.cjs` sagt es explizit:
„Ersetzt WebKitGTK durch Chromium, damit WebGPU zuverlässig läuft."
Beide teilen sich **dieselbe** React-App und dieselbe randlose eigene
Titelleiste; die Laufzeit erkennt den Host über `window.__TAURI__` (Tauri)
bzw. `window.dossierWindow` (Electron, per `contextBridge` injiziert). Die
Fenstersteuerung (`src/ui/WindowControls.tsx`) ist an beide Wege angebunden.
Kein Rust-Backend nötig auf dem Electron-Pfad — `computeJoins` hat einen
TS-Fallback (`src/compute/index.ts`). Electron ist also **kein** totes Gleis,
sondern der aktive Linux-Zielrahmen.
## 3. Feature-Inventar (was tatsächlich funktioniert)
### Modell & Bauteile
- Mehrschichtige Wände (`WallType.layers[]`) mit L-Eck-Gehrung UND
Prioritäts-T-/X-Stössen (`joinPriority` am Component) — **fertig**, in 2D-Plan,
3D-Viewport UND 3D-Live-Schnitt konsistent (Rust-Port `section_boolean.rs`).
- Parametrische Wände (`ParametricWall`: Grid/Modul/Sequenz/Referenzlinie/
bedingte Dicke) lösen sich zu konkreten `Wall[]` auf.
- Decken (Slabs, `ceilings?`) mit Aussparungen, eigenem Typkatalog.
- Türen/Fenster gehostet in Wänden, mit Rahmen/Zarge/Blockrahmen, Kämpfer,
Oberlicht, Detailgrad grob/mittel/fein (2D UND 3D), Schwenkbogen; daneben
existiert weiterhin ein **älteres, separates `Door[]`** neben `Opening[]` —
laut PENDENZEN.md explizit als offene Doppelspur/Aufräum-Punkt vermerkt.
- Treppen (gerade/L/Wendel), geschossübergreifend, 2D-Symbol mit Lauflinie/Pfeil.
- Dächer (Flach/Pult/Sattel/Walm/Mansarde/Zelt) über Rechteck-Umriss, First/
Traufe/Grat im 2D-Plan.
- Stützen (Column) mit Profilbibliothek (Quadrat/Rechteck/Rund/I/Rohr).
- Räume (SIA-416: HNF/NNF/VF/FF/GF/AGF) mit automatischer Bilanz + CSV-Export,
Raumstempel-Editor (Drag&Drop-Felder).
- Extrudierte Volumenkörper (truck-Integration: konkave Profile, Verjüngung).
- Kontext-Layer (Terrain-TIN, importierte Meshes, Konturen) — semantisch getrennt.
### Zeichnen & Bedienung
- Rhino-artiges Kommandosystem (`commands/`): getippte Koordinaten
(`5,3`/`r5,3`/`5<45`), Tab-Feld-Zyklus für Präzisionseingabe
(`src/ui/CommandLine.tsx`), Alias/Autocomplete, ~25 Kommandos (wall, ceiling,
opening, stair, column, roof, room, line/polyline/rect/circle/arc, text,
move/mirror/copy/offset/trim/join, extrude, import, terrain, measure,
Schnittlinie, Georef).
- Snapping (Endpunkt/Mitte/Schnittpunkt/Lot/Raster/Ortho), Grips, Array,
Trim/Split/Join, 2D-Booleans (Union/Subtract/Intersect via `polygon-clipping`).
- Messwerkzeug (Polygonzug, Länge + Fläche).
- Rich-Text-Annotationen (Bold/Kursiv/Hoch-/Tiefstellung).
### Darstellung / Ressourcen
- Resource Manager: Line/Hatch/Component-Manager, Wand-/Decken-/Tür-/Fenster-/
Treppen-/Dach-Typeditoren — als eigenständiges natives Fenster (nicht als
Dock-Panel).
- Materialbibliothek: 13 fest gebündelte PBR-Starter (ambientCG, lokale
Texturen) **plus** Live-Suche der kompletten ambientCG-Bibliothek (Auflösung
1K/2K/4K, on-demand Download+Entpacken via `jszip`, Proxy wegen CORS) — heute
bereinigt (siehe Commit-Historie dieser Session).
- Regelbasierte Overrides (Bedingung → Farbe/Strichstärke/Schraffur/Sichtbarkeit).
- Detailgrad grob/mittel/fein je Bauteil + Dokument-Override.
- Hell-/Dunkel-Theme, Akzentfarben.
### Pläne / Output
- Ausschnitte (View-Snapshots: Kamera, Massstab, Detailgrad, Sichtbarkeiten,
Override-Preset) in Ordnerstruktur.
- Layout-Blätter (Plansätze): Papierformat/-grösse, mehrere Viewports pro
Blatt, Masterlayout-Vererbung (Titelblock), Ordnerstruktur, freie 2D-Annotationen.
- Vektor-Export: PDF (Einzelblatt UND **Mehrseiten pro Ordner**,
`layoutPdf.ts::buildFolderPdf`), DXF, IFC4 (mit echten Fenster-/Tür-Löchern,
deterministischen GUIDs), STL, OBJ, CSV-Bauteil-Schedule (volles Element-Set).
- Kamera-Presets (Kardinal + Iso), Norden-Rotation.
### Import / Kontext
- DXF (Konturen), DWG (`@mlightcad/libredwg-web`, WASM), `.lin`/`.pat`.
- Swisstopo: swissBUILDINGS3D (radiusgenau zugeschnitten, nicht die ganze
STAC-Kachel), swissALTI3D, SWISSIMAGE-Orthofoto-Draping, LV95↔WGS84,
Georeferenzierung über EINEN Vermessungspunkt (E/N/H, `geoAnchor`).
- OSM/Overpass-Kontextimport (7 Kategorien).
- Terrain-Mesh-Generator.
### Desktop-Integration (Tauri)
- Eigene randlose Fenster mit nativer Titelleiste (macOS-Ampel-Position).
- Native Speichern/Öffnen-Dialoge (`plugin-fs`/`plugin-dialog`), eigenes
`.obp`-Projektdateiformat.
- OS-Level-Exklusiv-Lock gegen Doppelöffnen desselben Projekts (`fs4`-Crate).
- Vier eigenständige native Zusatzfenster (Resources, Settings, DrawingLevels,
LayerSettings, ContextImport) statt Overlay/Modal.
- Native macOS-Menüleiste.
- i18n de/en durchgängig, eigener `t()`-Mechanismus (kein i18next).
## 4. Mist-Liste — Befunde, Doku-Widersprüche, offene Fäden
### 4.1 Verwaiste WASM-Crates (gebaut, aber nirgends importiert)
- **`src-tauri/geometry`** (1.057 LOC, 8 Tests) → `pkgGeometry` — **null**
Importstellen in `src/`. Wand-Join-Mathe existiert redundant als TS
(`src/model/joins.ts`) UND als Rust-Port, aber nur die TS-Version läuft.
- **`src-tauri/dwgimport`** (284 LOC) → `pkgDwgImport` — **null** Importstellen;
DWG-Import läuft stattdessen über `@mlightcad/libredwg-web` (npm-Paket).
- **`src-tauri/kernel2d`** (3.383 LOC, 18 Tests) → `pkgKernel2d` — wird nur von
einem Paritätstest (`kernel2d.parity.test.ts`) konsumiert, nicht produktiv.
Die TS-Version `src/geometry/kernel2d.ts` (886 Zeilen) ist die tatsächlich
laufende Implementierung. Laut PENDENZEN.md bewusst so belassen: ein
Join-Benchmark zeigte WASM unter ~100 Wänden **langsamer** als die naive
TS-Routine. Kein Bug, aber die drei Crates zusammen sind ~4.700 Zeilen Rust
(+44 Tests), die aktuell nichts zur Laufzeit beitragen ausser einem
Korrektheits-Cross-Check für kernel2d.
**Empfehlung:** entweder (a) `geometry`- und `dwgimport`-Crate + ihre
`build:*`-Scripts entfernen (kein Nutzen, nur Wartungslast), oder (b)
explizit als „Referenzimplementierung/Zukunftsoption" in ARCHITECTURE.md
dokumentieren, damit niemand sie für aktiv hält.
### 4.2 Toter Code
- `src/section/hlr.ts` + `occt.ts` + `occt-wasm.d.ts` (473 LOC) — OCCT-WASM-
HLR-Spike aus Phase 0, **keine Aufrufer mehr**. Ersetzt durch die analytische
Rust-Schnitt-Pipeline (§2.4). `opencascade.js` bleibt als npm-Dependency
bestehen, obwohl nur noch dieser tote Code sie importiert.
- `src/export/planToPrintSvg.ts` — Kommentar im Code selbst sagt „ERSETZT durch
`sceneToPrintSvg.ts`", ist aber noch im Baum.
- `Element`-Typalias (`src/model/types.ts:1601`) — definiert, nirgends benutzt.
### 4.3 Das grösste Doku-vs-Code-Problem: App.tsx
CONVENTIONS.md verlangt seit Tag 1 „App.tsx bleibt dünner Shell, keine
Geschäftslogik". `docs/design/state-architecture.md` plante explizit die
Extraktion nach `src/views/` (View-Routing) und `src/menus/`
(Kontextmenü-Aufbau). Beide Ordner **existieren nicht**. `App.tsx` ist mit
**7.130 Zeilen** die grösste Einzeldatei des Projekts und enthält weiterhin
View-Umschaltung und Kontextmenü-Konstruktion inline. Das ist der genaue
„God-Component"-Zustand, den die Doku von Anfang an vermeiden wollte.
### 4.4 Bekannte Doppelspur: `Door[]` vs. `Opening[]`
`Project` führt sowohl ein älteres `doors: Door[]` als auch das neuere,
allgemeinere `openings?: Opening[]` (`kind:"door"|"window"`). Laut
PENDENZEN.md ist das erkannt und als Aufräum-Punkt vorgemerkt, aber nicht
konsolidiert.
### 4.5 Doku-Widersprüche (README/ARCHITECTURE/CONVENTIONS vs. Realität)
| Dokument | Behauptung | Realität |
|---|---|---|
| README.md | Shell = Electron, Tauri „ausrangiert" | Falsch andersrum gedacht: **beide** sind aktiv — Tauri auf macOS (WKWebView+WebGPU), Electron auf Linux (WebKitGTK kann kein WebGPU); siehe §2.7 |
| README.md | „HLR noch nicht ans UI verdrahtet, Views sind Stubs" | Der OCCT-HLR-Pfad stimmt (tot), aber Schnitte/Ansichten funktionieren real über eine andere, eigene Rust-Pipeline |
| README.md | „Prioritäts-T-/X-Stösse … Risiko #1" unter „bewusst offen" | Seit 7.7. erledigt, inkl. Rust-Port |
| README.md | „PDF-Export ist noch single-sheet" | `layoutPdf.ts::buildFolderPdf` erzeugt echte Mehrseiten-PDFs pro Ordner |
| README.md | Engine-Liste nennt nur render2d/render3d | Es gibt 6 Rust-Crates (+kernel2d/geometry/trucksolid/dwgimport) |
| CONVENTIONS.md | Struktur-Ziel `src/views/`, `src/menus/` | Existieren nicht; Logik liegt in `App.tsx` |
| CONVENTIONS.md | Dev-Port 5173 | Tatsächlich 5187 (`vite.config.ts`, `tauri.conf.json`) |
| ARCHITECTURE.md | Ziel-Struktur `store/`, `sheets/`, `workers/geometry.worker.ts` (Comlink) | Tatsächlich `state/`, `panels/layoutModel.ts`; kein Worker/Comlink irgendwo im Projekt |
| ARCHITECTURE.md | HLR „im Web Worker (Comlink)" | Kein Comlink im Projekt; Schnitt läuft synchron GPU-seitig in Rust |
| HANDOVER.md | Neuester Block: „Stand 2026-07-09" | Jüngster Commit + PENDENZEN.md sind vom 17.20.7. — 8+ Tage/mehrere Sessions veraltet |
### 4.6 Codequalität — besser als der Tempo vermuten lässt
Trotz 3 Wochen / 362 Commits über viele autonome Sessions: **keine** FIXME/HACK/
XXX-Marker im ganzen Projekt; nur 2 TODOs (beide bekannt/harmlos: Geländer in
`Viewport3D.tsx:2649`, Kanten-Tiefe in `toSection.ts:1093`); `eslint-disable`
fast ausschliesslich `react-hooks/exhaustive-deps` (bewusst); keine
`_v2`/`_old`/`_backup`-Dateileichen. Die eigentliche Aufgabenliste lebt
diszipliniert in PENDENZEN.md statt in Code-Kommentaren verstreut — gesünder
als der Durchschnitt für dieses Bau-Tempo.
### 4.7 Genuine offene Punkte (Auszug aus PENDENZEN.md, Details dort)
- Geo-Block: Layer-Zuordnung für importierte Gebäude/Terrain, reale Höhen +
Projekt-müM-Draping, SWISSIMAGE-Draping.
- 3D-Feinschliff: Fensterrahmen-Ecken bei „fein" überlappen (kein Gehrungs-
Union), Dach-First/Grat hat unverschmolzene Dreiecke, unerklärte
Vertikalstreifen auf oberen Wandflächen (undiagnostiziert).
- truck-Boolean nicht an die Wand/Öffnungs-Pipeline angeschlossen (Scope-
Entscheid mit Nutzer ausstehend).
- Feld-Controller (Tab-Zyklus) fehlt für Body-Move und für 3D-Griffe generell
(nur 2D-Einzelpunkt-Drag hat ihn); 3D-Griff-Drag hat gar kein Snapping.
- `make2D`-Kommando (3D→flacher 2D-Plan mit Füllungen) ungebaut.
- Ribbon-3D-Tab leer; einige Punkte visuell noch nicht in Tauri abgenommen
(u. a. Materialfarben-Textur-Array, Schraffur-Schnittfüllung — laut
PENDENZEN als „[~] implementiert, aber unverifiziert" markiert).
- DWG/DXF-Domänen-Mapping (Entitäten → Wände/Öffnungen) unbegonnen; DWG-Schreiben
fehlt (Lesen über `libredwg-web` vorhanden).
- Teamwork/Kollaboration (Supabase) bewusst nicht begonnen, gilt als späte Phase.
## 5. Vergleich: Tag-1-Vision vs. heute
| Vision (28./29.6.) | Heute |
|---|---|
| Three.js als einziger 3D-Renderer, OpenCascade.js für Booleans/HLR | Eigene Rust/WASM-Engines („Nordstern") für 2D+3D; three.js nur noch „Free"-Fallback; OCCT-Pfad tot |
| Ein `Element[]`-Union | Typisierte Arrays pro Bauteiltyp auf `Project` |
| Zustand-Store | Eigener `useSyncExternalStore`-Store |
| Web Worker + Comlink für HLR | Synchrone, analytische Rust-GPU-Schnitt-Pipeline |
| „Booleans: Entscheidung in Phase 0" | Trucksolid/csgrs-CSG existiert, ist getestet, aber nicht an Wände angeschlossen |
| web-ifc für IFC | Eigener IFC4-Writer (`exportIfc.ts`) |
| Phase 25 grösstenteils „Backlog" | Treppen, Dächer, Stützen, SIA-416, Swisstopo, OSM, Kamera-Presets, Layouts, Ausschnitte, Terrain — alles bereits gebaut |
Kurz: die **Prinzipien** (ein Modell, viele Ableitungen; Darstellung erst beim
Rendern; keine Cache-Stale-Bugs) haben gehalten und wurden korrekt umgesetzt.
Die **konkreten Technologie-Entscheidungen** sind fast durchgängig anders
gelaufen als geplant — meist zugunsten einer eigenen, schnelleren Rust/WASM-
Lösung statt einer Drittbibliothek.
+8 -1
View File
@@ -1,6 +1,13 @@
# Dokumentation — Standalone Browser-BIM (cad) # Dokumentation — Standalone Browser-BIM (cad)
> Stand: 2026-06-29 · Übergeordnet: [ROADMAP.md](../ROADMAP.md) (Vision & Phasen) · > Stand: 2026-06-29 · **Historische Recherche-/Design-Dokumente aus der ersten
> Woche.** Mehrere Kern-Empfehlungen hier (replicad/OCCT als B-Rep-Kernel,
> Manifold, web-ifc, `three/webgpu`) wurden im tatsächlichen Bau **nicht**
> umgesetzt — stattdessen entstanden eigene Rust/WASM-Rendering-Engines
> („Nordstern"). Für den aktuellen Ist-Zustand: **[../STATUS.md](../STATUS.md)**
> und **[../ARCHITECTURE.md](../ARCHITECTURE.md)**. Die Dokumente unten sind als
> Recherche-Hintergrund weiterhin lesenswert, aber nicht mehr aktueller Plan.
> Übergeordnet: [ROADMAP.md](../ROADMAP.md) (Vision & Phasen, ebenfalls historisch) ·
> [CONVENTIONS.md](../CONVENTIONS.md) (Konventionen) · [ARCHITECTURE.md](../ARCHITECTURE.md). > [CONVENTIONS.md](../CONVENTIONS.md) (Konventionen) · [ARCHITECTURE.md](../ARCHITECTURE.md).
Dieses Verzeichnis bündelt die Recherche- und Design-Dokumente für `cad`, die Dieses Verzeichnis bündelt die Recherche- und Design-Dokumente für `cad`, die
+1 -1
View File
@@ -15,7 +15,7 @@
rel="icon" rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232f6df6'/%3E%3Cg fill='white'%3E%3Crect x='3' y='3' width='4' height='4'/%3E%3Crect x='9' y='3' width='4' height='4'/%3E%3Crect x='3' y='9' width='4' height='4'/%3E%3Crect x='9' y='9' width='4' height='4'/%3E%3C/g%3E%3C/svg%3E" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232f6df6'/%3E%3Cg fill='white'%3E%3Crect x='3' y='3' width='4' height='4'/%3E%3Crect x='9' y='3' width='4' height='4'/%3E%3Crect x='3' y='9' width='4' height='4'/%3E%3Crect x='9' y='9' width='4' height='4'/%3E%3C/g%3E%3C/svg%3E"
/> />
<title>cad — Phase 0 Spike</title> <title>Dossier</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "cad", "name": "dossier",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

+13 -1
View File
@@ -1,7 +1,7 @@
{ {
"source": "ambientCG (ambientcg.com)", "source": "ambientCG (ambientcg.com)",
"license": "CC0 1.0 Universal (Public Domain)", "license": "CC0 1.0 Universal (Public Domain)",
"resolution": "1K", "resolution": "2K",
"materials": [ "materials": [
{ {
"id": "Concrete048", "id": "Concrete048",
@@ -61,6 +61,18 @@
"ao": "/assets/materials/Bricks104/ao.jpg" "ao": "/assets/materials/Bricks104/ao.jpg"
} }
}, },
{
"id": "RoofingTiles013A",
"name": "Dachziegel",
"category": "Roofing Tiles",
"maps": {
"color": "/assets/materials/RoofingTiles013A/color.jpg",
"normal": "/assets/materials/RoofingTiles013A/normal.jpg",
"roughness": "/assets/materials/RoofingTiles013A/roughness.jpg",
"displacement": "/assets/materials/RoofingTiles013A/displacement.jpg",
"ao": "/assets/materials/RoofingTiles013A/ao.jpg"
}
},
{ {
"id": "Tiles141", "id": "Tiles141",
"name": "Bodenfliesen", "name": "Bodenfliesen",
+6 -2
View File
@@ -1,8 +1,8 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region).", "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.",
"windows": ["main"], "windows": ["main", "resources", "settings", "drawing-levels", "layer-settings", "context-import"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-minimize", "core:window:allow-minimize",
@@ -12,6 +12,10 @@
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-create",
"core:webview:allow-create-webview-window",
"core:menu:default",
"core:event:default",
"dialog:allow-save", "dialog:allow-save",
"fs:allow-write-text-file" "fs:allow-write-text-file"
] ]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 B

After

Width:  |  Height:  |  Size: 12 KiB

+159 -26
View File
@@ -91,11 +91,21 @@ fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2] a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
} }
/// Baut die Feature-Edge-LineList aus dem Mesh (siehe Moduldoc). /// Eine Kante samt ihrer deduplizierten Repraesentanten-Normalen (Rueckseiten
/// /// bereits gepaart, s. `representatives`). Grundlage BEIDER Kanten-Stile: die
/// Ausgabe: interleaved `[px,py,pz, r,g,b, ...]`, je 2 Vertices = 1 Segment. /// statischen Feature-Edges (`build_mesh_edges`) UND die blickabhaengigen
/// Leerer Vektor bei leerem/degeneriertem Mesh. /// Silhouetten-Konturen (`build_silhouette_edges`).
pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> { pub struct EdgeAdj {
pub p0: [f32; 3],
pub p1: [f32; 3],
/// Flaechennormalen der (max. 2) angrenzenden Original-Dreiecke.
pub reps: Vec<[f32; 3]>,
}
/// Baut die Kanten-Adjazenz aus dem Mesh: je eindeutiger Kante die Endpunkte +
/// die deduplizierten Repraesentanten-Normalen. GPU-frei; einmalig bei `set_model`
/// berechnet und gecacht (die Silhouette wird daraus je Blickwinkel neu abgeleitet).
pub fn build_edge_adjacency(mesh: &Mesh) -> Vec<EdgeAdj> {
if mesh.indices.len() < 3 { if mesh.indices.len() < 3 {
return Vec::new(); return Vec::new();
} }
@@ -137,20 +147,83 @@ pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
} }
} }
let mut verts: Vec<f32> = Vec::new(); edges
let mut push = |p: [f32; 3]| { .into_values()
verts.push(p[0]); .map(|rec| EdgeAdj {
verts.push(p[1]); p0: rec.p0,
verts.push(p[2]); p1: rec.p1,
verts.push(EDGE_COLOR[0]); reps: representatives(&rec.normals),
verts.push(EDGE_COLOR[1]); })
verts.push(EDGE_COLOR[2]); .collect()
}; }
for rec in edges.values() { fn push_edge(verts: &mut Vec<f32>, p0: [f32; 3], p1: [f32; 3]) {
if should_draw(&rec.normals) { for p in [p0, p1] {
push(rec.p0); verts.extend_from_slice(&[p[0], p[1], p[2], EDGE_COLOR[0], EDGE_COLOR[1], EDGE_COLOR[2]]);
push(rec.p1); }
}
/// Baut die Feature-Edge-LineList aus dem Mesh (siehe Moduldoc).
///
/// Ausgabe: interleaved `[px,py,pz, r,g,b, ...]`, je 2 Vertices = 1 Segment.
/// Leerer Vektor bei leerem/degeneriertem Mesh.
pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
let adj = build_edge_adjacency(mesh);
let mut verts: Vec<f32> = Vec::new();
for a in &adj {
if draws_crease(&a.reps) {
push_edge(&mut verts, a.p0, a.p1);
}
}
verts
}
/// Blickabhaengige Silhouetten-Konturen aus der gecachten Kanten-Adjazenz: gezeichnet
/// werden Randkanten (nur ein Dreieck) UND echte Silhouetten-Kanten — solche, deren
/// zwei angrenzende Flaechen aus Sicht der Kamera GEGENSAETZLICH orientiert sind
/// (eine vorder-, eine rueckseitig). Ergebnis ist der reine Umriss (Gipsmodell-/
/// Konturen-Look) ohne die inneren Knickkanten (Dachgrate). `eye` = Kameraposition
/// (world), `forward` = Blickrichtung (world, von der Kamera ins Bild); `perspective`
/// waehlt die pro-Kante-Blickrichtung (Perspektive) vs. die konstante (ortho).
pub fn build_silhouette_edges(
adj: &[EdgeAdj],
eye: [f32; 3],
forward: [f32; 3],
perspective: bool,
) -> Vec<f32> {
let mut verts: Vec<f32> = Vec::new();
for a in adj {
let draw = match a.reps.len() {
0 => false,
1 => true, // offene Randkante -> immer Teil der Kontur
_ => {
let mid = [
(a.p0[0] + a.p1[0]) * 0.5,
(a.p0[1] + a.p1[1]) * 0.5,
(a.p0[2] + a.p1[2]) * 0.5,
];
// Blickvektor von der Flaeche ZUR Kamera. Perspektive: eye - mid;
// ortho: entgegen der Blickrichtung (konstante Kamera).
let view = if perspective {
sub(eye, mid)
} else {
[-forward[0], -forward[1], -forward[2]]
};
let mut front = false;
let mut back = false;
for n in &a.reps {
let d = dot(*n, view);
if d > 0.0 {
front = true;
} else if d < 0.0 {
back = true;
}
}
front && back // Silhouette: eine Flaeche vorder-, eine rueckseitig
}
};
if draw {
push_edge(&mut verts, a.p0, a.p1);
} }
} }
verts verts
@@ -171,15 +244,18 @@ pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
/// nur EINEN Vertreter je Original-Dreieck behalten — danach greift dieselbe /// nur EINEN Vertreter je Original-Dreieck behalten — danach greift dieselbe
/// Rand-/Knick-Logik wie bei einseitigen Meshes (Waende), unabhaengig davon, /// Rand-/Knick-Logik wie bei einseitigen Meshes (Waende), unabhaengig davon,
/// ob doppelseitig gerendert wurde oder nicht. /// ob doppelseitig gerendert wurde oder nicht.
fn should_draw(normals: &[[f32; 3]]) -> bool { /// Dedupliziert die angrenzenden Normalen zu Repraesentanten: paart die von der
let mut representatives: Vec<[f32; 3]> = Vec::new(); /// doppelseitigen Emission stammenden Rueckseiten (dot ≈ -1) und behaelt je
/// Original-Dreieck EINEN Vertreter (s. Moduldoc `should_draw`-Historie).
fn representatives(normals: &[[f32; 3]]) -> Vec<[f32; 3]> {
let mut reps: Vec<[f32; 3]> = Vec::new();
let mut used = vec![false; normals.len()]; let mut used = vec![false; normals.len()];
for i in 0..normals.len() { for i in 0..normals.len() {
if used[i] { if used[i] {
continue; continue;
} }
used[i] = true; used[i] = true;
representatives.push(normals[i]); reps.push(normals[i]);
for j in (i + 1)..normals.len() { for j in (i + 1)..normals.len() {
if used[j] { if used[j] {
continue; continue;
@@ -190,14 +266,20 @@ fn should_draw(normals: &[[f32; 3]]) -> bool {
} }
} }
} }
match representatives.len() { reps
}
/// Entscheidet fuer die STATISCHEN Feature-Edges, ob eine Kante gezeichnet wird:
/// Randkante (ein Repraesentant) oder Knickkante (zwei stehen ueber dem Crease-
/// Winkel zueinander). Koplanare geteilte Kanten (Flaechendiagonalen) fallen weg.
fn draws_crease(reps: &[[f32; 3]]) -> bool {
match reps.len() {
0 => false, 0 => false,
1 => true, // Silhouette/Rand: gehoert nur einem Original-Dreieck 1 => true, // Silhouette/Rand: gehoert nur einem Original-Dreieck
_ => { _ => {
// Zeichnen, sobald irgendein Normalen-Paar deutlich abknickt. for i in 0..reps.len() {
for i in 0..representatives.len() { for j in (i + 1)..reps.len() {
for j in (i + 1)..representatives.len() { if dot(reps[i], reps[j]) < CREASE_COS {
if dot(representatives[i], representatives[j]) < CREASE_COS {
return true; return true;
} }
} }
@@ -277,6 +359,57 @@ mod tests {
let m = Mesh::default(); let m = Mesh::default();
assert!(build_mesh_edges(&m).is_empty()); assert!(build_mesh_edges(&m).is_empty());
} }
/// Ein geschlossener Wuerfel: 8 Ecken, 12 Dreiecke (6 Flaechen, aussen).
fn unit_cube() -> Mesh {
let c = [
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
[1.0, 0.0, 1.0],
[1.0, 1.0, 1.0],
[0.0, 1.0, 1.0],
];
#[rustfmt::skip]
let idx: Vec<u32> = vec![
0,3,2, 0,2,1, // -Z
4,5,6, 4,6,7, // +Z
0,1,5, 0,5,4, // -Y
3,7,6, 3,6,2, // +Y
0,4,7, 0,7,3, // -X
1,2,6, 1,6,5, // +X
];
mesh(&c, &idx)
}
#[test]
fn cube_feature_edges_are_twelve() {
// Statische Feature-Edges: die 12 Wuerfelkanten (Flaechendiagonalen weg).
let e = build_mesh_edges(&unit_cube());
assert_eq!(segment_count(&e), 12);
}
#[test]
fn cube_silhouette_from_generic_view_is_hexagon() {
// Aus einer generischen Ecken-Sicht ist die Silhouette eines Wuerfels ein
// Sechseck = 6 Kanten (nur der Umriss, KEINE inneren Knickkanten).
let adj = build_edge_adjacency(&unit_cube());
let eye = [5.0, 6.0, 7.0];
let fwd = normalize(sub([0.5, 0.5, 0.5], eye)); // Blick auf die Mitte
let sil = build_silhouette_edges(&adj, eye, fwd, true);
assert_eq!(segment_count(&sil), 6);
}
#[test]
fn cube_silhouette_ortho_is_hexagon_too() {
// Auch orthografisch (konstante Blickrichtung) ergibt sich das Sechseck.
let adj = build_edge_adjacency(&unit_cube());
let fwd = normalize([-1.0, -1.2, -1.4]);
let sil = build_silhouette_edges(&adj, [0.0, 0.0, 0.0], fwd, false);
assert_eq!(segment_count(&sil), 6);
}
} }
#[cfg(test)] #[cfg(test)]
+418 -5
View File
@@ -12,17 +12,20 @@
use bytemuck::{Pod, Zeroable}; use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt; use wgpu::util::DeviceExt;
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX}; use crate::edges::{
build_edge_adjacency, build_mesh_edges, build_silhouette_edges, EdgeAdj, EDGE_FLOATS_PER_VERTEX,
};
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX}; use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
use crate::math::{view_projection, Mat4}; use crate::math::{view_projection, Mat4};
use crate::mesh::{ use crate::mesh::{
build_scene_mesh, build_scene_mesh_textured, build_walls_mesh, build_walls_mesh_textured, build_free_wall_end_caps, build_scene_mesh, build_scene_mesh_textured, build_terrain_mesh,
build_walls_mesh, build_walls_mesh_textured,
}; };
use crate::section::SectionPlane; use crate::section::SectionPlane;
use crate::section_fill::{ use crate::section_fill::{
build_cut_cap_lines, build_cut_caps, CAP_FLOATS_PER_VERTEX, CUT_LINE_FLOATS_PER_VERTEX, build_cut_cap_lines, build_cut_caps, CAP_FLOATS_PER_VERTEX, CUT_LINE_FLOATS_PER_VERTEX,
}; };
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL}; use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_AERIAL_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL};
use crate::types::{ use crate::types::{
Camera, Mesh, MeshInput, SlabInput, TexturedMesh, WallInput, FLOATS_PER_VERTEX, Camera, Mesh, MeshInput, SlabInput, TexturedMesh, WallInput, FLOATS_PER_VERTEX,
TEXTURED_FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
@@ -51,6 +54,11 @@ pub enum RenderStyle {
/// Modell-Kanten obenauf (wie bei `Hidden`, aber mit Material- statt /// Modell-Kanten obenauf (wie bei `Hidden`, aber mit Material- statt
/// Weiss-Flaechen). /// Weiss-Flaechen).
ShadedEdges, ShadedEdges,
/// „Nur Konturen" (Gipsmodell-Look): flach-weisse Flaechen wie `Hidden`, aber
/// obenauf NUR die blickabhaengige Silhouette (Umriss je Volumen), nicht die
/// inneren Knickkanten (Dachgrate). Die Silhouette wird je Kamera aus der
/// gecachten Kanten-Adjazenz neu abgeleitet (siehe `edges::build_silhouette_edges`).
Contour,
} }
impl RenderStyle { impl RenderStyle {
@@ -62,6 +70,7 @@ impl RenderStyle {
"wireframe" => RenderStyle::Wireframe, "wireframe" => RenderStyle::Wireframe,
"hidden" => RenderStyle::Hidden, "hidden" => RenderStyle::Hidden,
"shaded-edges" => RenderStyle::ShadedEdges, "shaded-edges" => RenderStyle::ShadedEdges,
"contour" => RenderStyle::Contour,
_ => RenderStyle::Shaded, _ => RenderStyle::Shaded,
} }
} }
@@ -72,7 +81,8 @@ impl RenderStyle {
fn mesh_mode(self) -> f32 { fn mesh_mode(self) -> f32 {
match self { match self {
RenderStyle::White => 1.0, RenderStyle::White => 1.0,
RenderStyle::Hidden => 2.0, // Contour teilt den flach-weissen Flaechen-Look von Hidden (mode 2).
RenderStyle::Hidden | RenderStyle::Contour => 2.0,
// Shaded, Textured, Wireframe (Flaechen werden bei Wireframe eh nicht // Shaded, Textured, Wireframe (Flaechen werden bei Wireframe eh nicht
// gezeichnet), ShadedEdges -> Material. // gezeichnet), ShadedEdges -> Material.
_ => 0.0, _ => 0.0,
@@ -98,7 +108,10 @@ impl RenderStyle {
/// zeichnen (Flaechen + Kanten) — bislang nur `Hidden`, jetzt auch /// zeichnen (Flaechen + Kanten) — bislang nur `Hidden`, jetzt auch
/// `ShadedEdges`. /// `ShadedEdges`.
fn needs_biased_face_pipeline(self) -> bool { fn needs_biased_face_pipeline(self) -> bool {
matches!(self, RenderStyle::Hidden | RenderStyle::ShadedEdges) matches!(
self,
RenderStyle::Hidden | RenderStyle::ShadedEdges | RenderStyle::Contour
)
} }
} }
@@ -174,6 +187,19 @@ struct TexturedMeshBuffers {
index_count: u32, index_count: u32,
} }
/// GPU-Ressourcen EINES georeferenzierten Luftbilds (SWISSIMAGE), das aufs Gelaende
/// drapiert wird (`gpu::aerial_pipeline`). Haelt Textur + View am Leben und die
/// fertige Bind-Group (group 1: Textur + Sampler + Bbox-Uniform).
struct AerialBuffers {
#[allow(dead_code)]
texture: wgpu::Texture,
#[allow(dead_code)]
view: wgpu::TextureView,
#[allow(dead_code)]
bbox: wgpu::Buffer,
bind_group: wgpu::BindGroup,
}
/// Kantenlaenge der prozeduralen Test-Textur (Schachbrett), in Texeln. /// Kantenlaenge der prozeduralen Test-Textur (Schachbrett), in Texeln.
const TEXTURE_SIZE: u32 = 256; const TEXTURE_SIZE: u32 = 256;
@@ -268,6 +294,30 @@ pub struct Renderer {
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur /// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
/// Shader/Layout unterscheiden sich. /// Shader/Layout unterscheiden sich.
textured_pipeline: wgpu::RenderPipeline, textured_pipeline: wgpu::RenderPipeline,
/// Luftbild-Drape-Pipeline (`MESH_AERIAL_WGSL`): zeichnet das Terrain-Overlay
/// (`terrain_overlay`) ein zweites Mal mit einer Bild-Textur (group 1). Vertex-
/// Layout = Haupt-Mesh-Layout `[pos, normal, color]`. Leichter Depth-Bias zur
/// Kamera hin (wie `cap_pipeline`), damit das Overlay das deckungsgleiche
/// Terrain im Hauptpuffer ueberlagert statt zu flimmern.
aerial_pipeline: wgpu::RenderPipeline,
/// Bind-Group-Layout (group 1) des Luftbild-Pfades: Textur + Sampler + Bbox-
/// Uniform. Festgehalten fuer den Bind-Group-Neubau in `set_aerial_texture`.
aerial_bind_group_layout: wgpu::BindGroupLayout,
/// Sampler des Luftbilds (ClampToEdge/Linear — kein Kacheln, weiche Filterung).
aerial_sampler: wgpu::Sampler,
/// Das aktuell drapierte Luftbild (Textur + Bind-Group). None = kein Luftbild.
aerial: Option<AerialBuffers>,
/// Nur die Gelaende-Dreiecke (aus `build_terrain_mesh`) als eigener Puffer —
/// Grundlage des Luftbild-Overlays. Bei `upload_model` erzeugt; None = kein
/// Terrain. Wird nur gezeichnet, wenn zusaetzlich `aerial` gesetzt ist.
terrain_overlay: Option<MeshBuffers>,
/// Gecachte Kanten-Adjazenz des Modells (bei `upload_*` erzeugt) — Grundlage
/// der blickabhaengigen Silhouette (Stil `Contour`). Leer = kein Modell.
edge_adj: Vec<EdgeAdj>,
/// Zuletzt gebaute Silhouetten-LineList (Stil `Contour`) + die Kamera-Signatur,
/// fuer die sie gilt — bei Blickwechsel neu abgeleitet (nicht je Frame blind).
silhouette: Option<LineBuffers>,
silhouette_key: Option<[i64; 6]>,
/// Bind-Group des Material-Textur-Arrays (group 1): Array-View + Sampler. /// Bind-Group des Material-Textur-Arrays (group 1): Array-View + Sampler.
/// Wird bei `set_material_textures` neu gebaut (mehr Ebenen); bis dahin haelt /// Wird bei `set_material_textures` neu gebaut (mehr Ebenen); bis dahin haelt
/// sie das 1-Ebenen-Array mit dem Fallback-Schachbrett. /// sie das 1-Ebenen-Array mit dem Fallback-Schachbrett.
@@ -310,6 +360,12 @@ pub struct Renderer {
/// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht, /// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht,
/// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight. /// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight.
caps: Option<CapBuffers>, caps: Option<CapBuffers>,
/// STATISCHE Schraffur-Kappen an freien Wandenden (`mesh::build_free_wall_end_caps`,
/// kein Live-Schnitt noetig — unabhaengig von `caps`/`cut_lines`, die nur bei
/// aktivem Live-Schnitt existieren). Bei `upload_model` neu gebaut; None = keine
/// Wand hat eine offene, schraffierte Kante. Immer gezeichnet, wenn Flaechen
/// gezeichnet werden (nicht an den Live-Schnitt-Zustand gebunden).
wall_end_caps: Option<CapBuffers>,
/// Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) als Ribbon-TriangleList /// Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) als Ribbon-TriangleList
/// (`[pos, color]`). None = kein Schnitt aktiv / keine Kanten. Ueber /// (`[pos, color]`). None = kein Schnitt aktiv / keine Kanten. Ueber
/// `set_cut_lines` gesetzt/geloescht, gezeichnet NACH den Caps. /// `set_cut_lines` gesetzt/geloescht, gezeichnet NACH den Caps.
@@ -733,6 +789,115 @@ impl Renderer {
cache: None, cache: None,
}); });
// ── Luftbild-Drape-Pfad (aerial_pipeline) ───────────────────────────────
// group 1: EINE 2D-Bild-Textur + Sampler + Bbox-Uniform (16 Byte, vec4).
// group 0 (Globals) bleibt geteilt. Vertex-Layout = Haupt-Mesh-Layout
// [pos, normal, color], damit der Terrain-Overlay-Puffer direkt passt.
let aerial_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("aerial.layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(16),
},
count: None,
},
],
});
let aerial_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("3d.aerial.layout"),
bind_group_layouts: &[Some(&bind_group_layout), Some(&aerial_bind_group_layout)],
immediate_size: 0,
});
let aerial_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("mesh_aerial.wgsl"),
source: wgpu::ShaderSource::Wgsl(MESH_AERIAL_WGSL.into()),
});
let aerial_vertex_layout = wgpu::VertexBufferLayout {
array_stride: (FLOATS_PER_VERTEX * 4) as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x3],
};
let aerial_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mesh.aerial.pipeline"),
layout: Some(&aerial_pipeline_layout),
vertex: wgpu::VertexState {
module: &aerial_module,
entry_point: Some("vs_main"),
buffers: &[aerial_vertex_layout],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &aerial_module,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: Some(true),
// Das Overlay nutzt EXAKT dieselbe Terrain-Geometrie wie der
// Hauptpuffer -> bit-identische Tiefe. Mit LessEqual gewinnt das
// NACH dem Terrain gezeichnete Overlay bei gleicher Tiefe
// deterministisch (kein Bias noetig). Ein Slope-Bias wuerde in
// ORTHO-Praesets (iso/axo) je nach Dreiecks-Neigung ungleich
// schieben und fleckige „Abschnitte" erzeugen — daher KEIN Bias.
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: SAMPLE_COUNT,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
// ClampToEdge (Randpixel ausserhalb der Bbox) + Linear (weiche Filterung).
let aerial_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("aerial.sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
// Prozedurale 256x256-Schachbrett-Textur ANLEGEN (kein Asset). RGBA8-srgb, // Prozedurale 256x256-Schachbrett-Textur ANLEGEN (kein Asset). RGBA8-srgb,
// damit die im Shader gesampelten Werte im linearen Raum landen (die // damit die im Shader gesampelten Werte im linearen Raum landen (die
// Beleuchtung rechnet linear) — analog zum srgb-Surface-Format. Das // Beleuchtung rechnet linear) — analog zum srgb-Surface-Format. Das
@@ -809,6 +974,14 @@ impl Renderer {
cap_pipeline, cap_pipeline,
cut_line_pipeline, cut_line_pipeline,
textured_pipeline, textured_pipeline,
aerial_pipeline,
aerial_bind_group_layout,
aerial_sampler,
aerial: None,
terrain_overlay: None,
edge_adj: Vec::new(),
silhouette: None,
silhouette_key: None,
texture_bind_group, texture_bind_group,
texture, texture,
texture_bind_group_layout, texture_bind_group_layout,
@@ -823,6 +996,7 @@ impl Renderer {
grid: None, grid: None,
highlight: None, highlight: None,
caps: None, caps: None,
wall_end_caps: None,
cut_lines: None, cut_lines: None,
grid_visible: false, grid_visible: false,
style: RenderStyle::Shaded, style: RenderStyle::Shaded,
@@ -859,19 +1033,63 @@ impl Renderer {
build_scene_mesh(walls, slabs, meshes), build_scene_mesh(walls, slabs, meshes),
build_scene_mesh_textured(walls, slabs, meshes), build_scene_mesh_textured(walls, slabs, meshes),
); );
// Terrain-Overlay-Puffer (nur Gelaende-Dreiecke) fuer das Luftbild-Drapieren.
let terrain = build_terrain_mesh(meshes);
self.terrain_overlay = if terrain.indices.is_empty() {
None
} else {
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("terrain.overlay.vbo"),
contents: bytemuck::cast_slice(&terrain.verts),
usage: wgpu::BufferUsages::VERTEX,
});
let ibo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("terrain.overlay.ibo"),
contents: bytemuck::cast_slice(&terrain.indices),
usage: wgpu::BufferUsages::INDEX,
});
Some(MeshBuffers {
vbo,
ibo,
index_count: terrain.indices.len() as u32,
})
};
// Schraffur-Kappen an freien (unangeschlossenen) Wandenden — statisch aus
// der Wandkonnektivitaet, unabhaengig vom Live-Schnitt-Zustand.
let end_caps = build_free_wall_end_caps(walls);
self.wall_end_caps = if end_caps.is_empty() {
None
} else {
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("wall_end_caps.vbo"),
contents: bytemuck::cast_slice(&end_caps),
usage: wgpu::BufferUsages::VERTEX,
});
Some(CapBuffers {
vbo,
vertex_count: (end_caps.len() / CAP_FLOATS_PER_VERTEX) as u32,
})
};
} }
/// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer) und /// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer) und
/// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer /// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer
/// die Stile wireframe/hidden — einmalig hier, nicht je Frame. /// die Stile wireframe/hidden — einmalig hier, nicht je Frame.
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh, tex_mesh: TexturedMesh) { fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh, tex_mesh: TexturedMesh) {
// Silhouette-Cache verwerfen (neues Modell) — wird bei Bedarf neu gebaut.
self.silhouette = None;
self.silhouette_key = None;
if mesh.indices.is_empty() { if mesh.indices.is_empty() {
self.mesh = None; self.mesh = None;
self.textured_mesh = None; self.textured_mesh = None;
self.edges = None; self.edges = None;
self.edge_adj = Vec::new();
return; return;
} }
// Kanten-Adjazenz einmalig ableiten: Grundlage der statischen Feature-Edges
// UND der blickabhaengigen Silhouette (Stil `Contour`).
self.edge_adj = build_edge_adjacency(&mesh);
// Kanten aus der Mesh-Geometrie ableiten und (falls vorhanden) hochladen. // Kanten aus der Mesh-Geometrie ableiten und (falls vorhanden) hochladen.
let edge_verts = build_mesh_edges(&mesh); let edge_verts = build_mesh_edges(&mesh);
self.edges = if edge_verts.is_empty() { self.edges = if edge_verts.is_empty() {
@@ -1111,6 +1329,111 @@ impl Renderer {
/// `WallInput::material_index` (1-basiert) auf seine Ebene; Baender ohne /// `WallInput::material_index` (1-basiert) auf seine Ebene; Baender ohne
/// Material (`-1`) bleiben beim Schachbrett (Ebene 0). `layer_count == 0` (oder /// Material (`-1`) bleiben beim Schachbrett (Ebene 0). `layer_count == 0` (oder
/// leere/zu kleine Daten) setzt auf das reine Schachbrett-Array zurueck. /// leere/zu kleine Daten) setzt auf das reine Schachbrett-Array zurueck.
/// Setzt/ersetzt das aufs Gelaende drapierte Luftbild (SWISSIMAGE). `rgba` =
/// dicht gepackte RGBA8-Bytes (`width*height*4`, zeilenweise von OBEN = Nord);
/// `bbox = [min_x, min_y, max_x, max_y]` die Modell-Meter-Bounding-Box, die das
/// Bild ausfuellt (x = Ost/world.x, y = Nord/world.z). Erzeugt Textur + Bind-
/// Group (group 1) fuer die `aerial_pipeline`. Ungueltige/leere Eingabe loescht
/// das Luftbild. Gezeichnet wird nur, wenn zusaetzlich Terrain vorliegt.
pub fn set_aerial_texture(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
rgba: &[u8],
width: u32,
height: u32,
bbox: [f32; 4],
) {
let needed = (width as usize) * (height as usize) * 4;
if width == 0 || height == 0 || rgba.len() < needed {
self.aerial = None;
return;
}
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("aerial.texture"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
// srgb: die gesampelten Werte landen linear (Beleuchtung rechnet linear).
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
// write_texture verlangt bytes_per_row als Vielfaches von 256. Bei Bedarf
// Zeilen in einen gepolsterten Puffer kopieren (sonst 1:1 hochladen).
let unpadded = (width * 4) as usize;
let padded = unpadded.div_ceil(256) * 256;
let upload: std::borrow::Cow<[u8]> = if padded == unpadded {
std::borrow::Cow::Borrowed(&rgba[..needed])
} else {
let mut buf = vec![0u8; padded * height as usize];
for row in 0..height as usize {
let src = &rgba[row * unpadded..row * unpadded + unpadded];
buf[row * padded..row * padded + unpadded].copy_from_slice(src);
}
std::borrow::Cow::Owned(buf)
};
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&upload,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(height),
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let bbox_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("aerial.bbox"),
contents: bytemuck::cast_slice(&bbox),
usage: wgpu::BufferUsages::UNIFORM,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("aerial.bind"),
layout: &self.aerial_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.aerial_sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: bbox_buf.as_entire_binding(),
},
],
});
self.aerial = Some(AerialBuffers {
texture,
view,
bbox: bbox_buf,
bind_group,
});
}
/// Loescht das drapierte Luftbild (das Gelaende erscheint wieder einfarbig/shaded).
pub fn clear_aerial(&mut self) {
self.aerial = None;
}
pub fn set_material_textures( pub fn set_material_textures(
&mut self, &mut self,
device: &wgpu::Device, device: &wgpu::Device,
@@ -1307,6 +1630,51 @@ impl Renderer {
self.ensure_depth(device, w, h); self.ensure_depth(device, w, h);
self.ensure_msaa(device, w, h); self.ensure_msaa(device, w, h);
// Silhouette (Stil `Contour`) je Blickwinkel neu ableiten — aber nur, wenn
// sich die Kamera merklich bewegt hat (Signatur-Cache), nicht blind je Frame.
if self.style == RenderStyle::Contour && !self.edge_adj.is_empty() {
let eye = camera.eye;
let mut fwd = [
camera.target[0] - eye[0],
camera.target[1] - eye[1],
camera.target[2] - eye[2],
];
let fl = (fwd[0] * fwd[0] + fwd[1] * fwd[1] + fwd[2] * fwd[2]).sqrt();
if fl > 1e-6 {
fwd = [fwd[0] / fl, fwd[1] / fl, fwd[2] / fl];
}
let perspective = matches!(camera.projection, crate::types::Projection::Perspective);
// Signatur: Auge (0.01 m) + Blickrichtung (0.001). Perspektive haengt
// von der Auge-Position ab, Ortho nur von der Richtung — beide erfasst.
let q = |v: f32, s: f32| (v * s).round() as i64;
let key = [
q(eye[0], 100.0),
q(eye[1], 100.0),
q(eye[2], 100.0),
q(fwd[0], 1000.0),
q(fwd[1], 1000.0),
q(fwd[2], 1000.0),
];
if self.silhouette.is_none() || self.silhouette_key != Some(key) {
let verts = build_silhouette_edges(&self.edge_adj, eye, fwd, perspective);
self.silhouette = if verts.is_empty() {
None
} else {
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("silhouette.vbo"),
contents: bytemuck::cast_slice(&verts),
usage: wgpu::BufferUsages::VERTEX,
});
Some(LineBuffers {
vbo,
vertex_count: (verts.len() / EDGE_FLOATS_PER_VERTEX) as u32,
})
};
self.silhouette_key = Some(key);
}
}
let depth_view = &self.depth.as_ref().unwrap().view; let depth_view = &self.depth.as_ref().unwrap().view;
let msaa_view = &self.msaa.as_ref().unwrap().view; let msaa_view = &self.msaa.as_ref().unwrap().view;
@@ -1375,6 +1743,38 @@ impl Renderer {
} }
} }
// 1a) Luftbild-Drape: das Terrain-Overlay ein zweites Mal texturiert
// ueber das (deckungsgleiche) Terrain im Hauptpuffer zeichnen. Nur
// wenn Flaechen gezeichnet werden, ein Luftbild gesetzt ist UND ein
// Terrain-Overlay vorliegt. Der Depth-Bias der Pipeline haelt es vor
// dem Terrain (kein Z-Fighting). group 1 = Bild-Textur + Bbox.
if self.style.draws_faces() {
if let (Some(a), Some(t)) = (&self.aerial, &self.terrain_overlay) {
pass.set_pipeline(&self.aerial_pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_bind_group(1, &a.bind_group, &[]);
pass.set_vertex_buffer(0, t.vbo.slice(..));
pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..t.index_count, 0, 0..1);
}
}
// 1a2) Schraffur-Kappen an freien Wandenden (STATISCH, unabhaengig vom
// Live-Schnitt-Zustand — `mesh::build_free_wall_end_caps`): ein
// unangeschlossenes Wandende zeigt die Bauteil-Schraffur statt der
// Flaechenfarbe, wie ein echter Schnitt. Dieselbe `cap_pipeline`
// (Bias zur Kamera) wie die Live-Schnitt-Kappen — sitzt exakt auf
// der (weiterhin vorhandenen) flach eingefaerbten End-Kappe im
// Hauptpuffer und gewinnt den Tiefentest.
if self.style.draws_faces() {
if let Some(wc) = &self.wall_end_caps {
pass.set_pipeline(&self.cap_pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_vertex_buffer(0, wc.vbo.slice(..));
pass.draw(0..wc.vertex_count, 0..1);
}
}
// 1b) Schnittflaechen-Kappen (Cut-Caps) NACH den Flaechen, VOR den // 1b) Schnittflaechen-Kappen (Cut-Caps) NACH den Flaechen, VOR den
// Kanten: sie fuellen die an der Schnittebene weggeschnittene Wunde // Kanten: sie fuellen die an der Schnittebene weggeschnittene Wunde
// mit der Diagonalschraffur (CAP_WGSL). Werden selbst NICHT gekappt. // mit der Diagonalschraffur (CAP_WGSL). Werden selbst NICHT gekappt.
@@ -1411,6 +1811,19 @@ impl Renderer {
} }
} }
// 2b) „Nur Konturen" (Stil Contour): die blickabhaengige Silhouette
// obenauf (tiefengetestet gegen die gebiasten Flaechen -> nur
// sichtbare Umrisslinien). Gleiche LineList-Pipeline wie die
// Feature-Edges, aber der reduzierte Kantensatz.
if self.style == RenderStyle::Contour {
if let Some(s) = &self.silhouette {
pass.set_pipeline(&self.grid_pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_vertex_buffer(0, s.vbo.slice(..));
pass.draw(0..s.vertex_count, 0..1);
}
}
// 3) Bodengitter NACH dem Modell zeichnen (gemeinsamer Tiefenpuffer -> // 3) Bodengitter NACH dem Modell zeichnen (gemeinsamer Tiefenpuffer ->
// das Modell verdeckt das Gitter korrekt). Unabhaengig vom Stil, nur // das Modell verdeckt das Gitter korrekt). Unabhaengig vom Stil, nur
// wenn sichtbar + vorhanden. // wenn sichtbar + vorhanden.
+146 -2
View File
@@ -55,10 +55,13 @@ pub use types::{
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::mesh::{ use super::mesh::{
build_walls_mesh, build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX, build_free_wall_end_caps, build_scene_mesh_textured, build_walls_mesh,
build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX,
}; };
use super::section_fill::CAP_FLOATS_PER_VERTEX;
use super::types::{ use super::types::{
Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX, Hatch, Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX,
TEXTURED_FLOATS_PER_VERTEX,
}; };
/// Bequemer Bau einer achsparallelen Wand entlang +X. /// Bequemer Bau einer achsparallelen Wand entlang +X.
@@ -161,6 +164,65 @@ mod tests {
assert!((max_idx as usize) < mesh.vertex_count()); assert!((max_idx as usize) < mesh.vertex_count());
} }
/// `wall_x` mit gesetzter Bauteil-Schraffur (Voraussetzung fuer die Kappen).
fn wall_x_hatched(len: f32, thickness: f32, height: f32) -> WallInput {
WallInput {
hatch: Some(Hatch { pattern: 3, angle: 45.0, scale: 1.0, line_weight: 0.13 }),
..wall_x(len, thickness, height)
}
}
#[test]
fn isolierte_wand_ohne_hatch_bekommt_keine_kappen() {
// Bestehende Test-Fixtures (kein `hatch`) bleiben unangetastet: keine
// Kappen-Geometrie, unabhaengig davon, dass beide Enden frei sind.
let caps = build_free_wall_end_caps(&[wall_x(3.0, 0.2, 2.5)]);
assert!(caps.is_empty());
}
#[test]
fn isolierte_wand_mit_hatch_bekommt_kappen_an_beiden_freien_enden() {
// Eine einzelne Wand hat KEINE Nachbarn -> beide Achsenenden sind frei ->
// je Ende eine Schraffur-Kappe (1 Schicht x 2 Dreiecke x 3 Vertices = 6
// Vertices je Ende, 12 insgesamt).
let caps = build_free_wall_end_caps(&[wall_x_hatched(3.0, 0.2, 2.5)]);
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 12);
// Jeder Kappen-Vertex traegt die Schraffur-Parameter (Muster 3 = crosshatch).
for v in caps.chunks_exact(CAP_FLOATS_PER_VERTEX) {
assert_eq!(v[5], 3.0, "pattern");
assert!((v[6] - 45.0_f32.to_radians()).abs() < 1e-5, "angle_rad");
}
}
#[test]
fn verbundene_wandenden_bekommen_keine_kappe_dort() {
// Zwei Waende treffen sich rechtwinklig bei (3,0) -> DIESES Ende ist bei
// beiden Waenden NICHT frei (Gehrung/Anschluss), die beiden anderen
// (freien) Enden bekommen weiterhin je eine Kappe -> 2 Kappen gesamt
// (nicht 4), macht 12 Vertices statt 24.
let a = wall_x_hatched(3.0, 0.2, 2.5); // (0,0) frei .. (3,0) verbunden
let b = WallInput {
start: [3.0, 0.0],
end: [3.0, 4.0],
..wall_x_hatched(4.0, 0.2, 2.5) // (3,0) verbunden .. (3,4) frei
};
let caps = build_free_wall_end_caps(&[a, b]);
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 12, "nur die 2 freien Enden -> 2 Kappen");
}
#[test]
fn t_stoss_bekommt_ebenfalls_keine_kappe_am_knoten() {
// Drei Waende treffen sich am selben Punkt (T-Stoss, Gehrung bleibt
// rechtwinklig, s. compute_wall_miters) -> trotzdem gilt der Knoten als
// "beruehrt", KEINE der drei dortigen Enden bekommt eine Kappe.
let a = wall_x_hatched(3.0, 0.2, 2.5); // endet bei (3,0)
let b = WallInput { start: [3.0, 0.0], end: [3.0, 4.0], ..wall_x_hatched(4.0, 0.2, 2.5) };
let c = WallInput { start: [3.0, 0.0], end: [6.0, 0.0], ..wall_x_hatched(3.0, 0.2, 2.5) };
let caps = build_free_wall_end_caps(&[a, b, c]);
// Freie Enden: a.start(0,0), b.end(3,4), c.end(6,0) -> 3 Kappen (nicht 6).
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 18);
}
#[test] #[test]
fn mehrere_waende_addieren_sich() { fn mehrere_waende_addieren_sich() {
let mesh = build_walls_mesh(&[ let mesh = build_walls_mesh(&[
@@ -840,6 +902,8 @@ mod tests {
indices: vec![0, 1, 2], indices: vec![0, 1, 2],
kind, kind,
color, color,
material_index: None,
vertex_colors: Vec::new(),
} }
} }
@@ -881,6 +945,73 @@ mod tests {
assert_eq!([mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]], [0.1, 0.2, 0.3]); assert_eq!([mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]], [0.1, 0.2, 0.3]);
} }
#[test]
fn context_mesh_pro_vertex_farbe_wird_uebernommen() {
// Luftbild-Drape: jeder Vertex traegt eine eigene Farbe. Wird 1:1 an die
// Ausgabe-Vertices durchgereicht (statt der Einzelfarbe).
let mut mesh = Mesh::default();
append_context_mesh(
&mut mesh,
&MeshInput {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
indices: vec![0, 1, 2],
kind: MeshKind::Terrain,
color: None,
material_index: None,
vertex_colors: vec![
1.0, 0.0, 0.0, // Vertex 0 rot
0.0, 1.0, 0.0, // Vertex 1 gruen
0.0, 0.0, 1.0, // Vertex 2 blau
],
},
);
// Vorderseite = erste 3 Vertices (Winding a,b,c): Farben 0,1,2.
assert_eq!([mesh.verts[6], mesh.verts[7], mesh.verts[8]], [1.0, 0.0, 0.0]);
assert_eq!([mesh.verts[15], mesh.verts[16], mesh.verts[17]], [0.0, 1.0, 0.0]);
assert_eq!([mesh.verts[24], mesh.verts[25], mesh.verts[26]], [0.0, 0.0, 1.0]);
// Zu kurzes vertex_colors-Array -> Einzelfarbe (kein Panic, kein Teilbezug).
let mut mesh2 = Mesh::default();
append_context_mesh(
&mut mesh2,
&MeshInput {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
indices: vec![0, 1, 2],
kind: MeshKind::Terrain,
color: None,
material_index: None,
vertex_colors: vec![1.0, 0.0, 0.0], // nur 1 Vertex -> ignoriert
},
);
assert_eq!(
[mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]],
MeshKind::Terrain.default_color()
);
}
#[test]
fn kontext_mesh_materialindex_wandert_in_die_textured_layer() {
// Daecher (und potenziell andere Kontext-Meshes) koennen jetzt einen
// Material-Textur-Index tragen (`MeshInput::material_index`) — vorher
// war er fuer ALLE Kontext-Meshes hart auf -1 (Schachbrett) gesetzt, der
// Stil „Textured" konnte also nie eine echte Dachziegel-Textur zeigen.
let m = MeshInput {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
indices: vec![0, 1, 2],
kind: MeshKind::Extrusion,
color: None,
material_index: Some(2),
vertex_colors: Vec::new(),
};
let tex = build_scene_mesh_textured(&[], &[], std::slice::from_ref(&m));
assert!(!tex.layers.is_empty());
assert!(tex.layers.iter().all(|&l| l == 2.0), "alle Vertices Ebene 2");
// Ohne material_index bleibt der Fallback (-1 -> Schachbrett).
let m2 = MeshInput { material_index: None, ..m };
let tex2 = build_scene_mesh_textured(&[], &[], std::slice::from_ref(&m2));
assert!(tex2.layers.iter().all(|&l| l == -1.0));
}
#[test] #[test]
fn context_mesh_ueberspringt_kaputte_indizes_und_entartung() { fn context_mesh_ueberspringt_kaputte_indizes_und_entartung() {
let mut mesh = Mesh::default(); let mut mesh = Mesh::default();
@@ -892,6 +1023,8 @@ mod tests {
indices: vec![0, 1, 9], indices: vec![0, 1, 9],
kind: MeshKind::Imported, kind: MeshKind::Imported,
color: None, color: None,
material_index: None,
vertex_colors: Vec::new(),
}, },
); );
assert_eq!(mesh.vertex_count(), 0); assert_eq!(mesh.vertex_count(), 0);
@@ -903,6 +1036,8 @@ mod tests {
indices: vec![0, 1, 2], indices: vec![0, 1, 2],
kind: MeshKind::Imported, kind: MeshKind::Imported,
color: None, color: None,
material_index: None,
vertex_colors: Vec::new(),
}, },
); );
assert_eq!(mesh.vertex_count(), 0); assert_eq!(mesh.vertex_count(), 0);
@@ -1078,5 +1213,14 @@ mod tests {
Validator::new(ValidationFlags::all(), Capabilities::all()) Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&tex_module) .validate(&tex_module)
.unwrap_or_else(|e| panic!("textured: WGSL-Validierung fehlgeschlagen: {e:?}")); .unwrap_or_else(|e| panic!("textured: WGSL-Validierung fehlgeschlagen: {e:?}"));
// Luftbild-Drape-Shader (Terrain-Overlay, Bild-Textur + Bbox-Uniform in
// group 1) ebenso headless validieren.
let aerial_src = super::shaders::MESH_AERIAL_WGSL;
let aerial_module = naga::front::wgsl::parse_str(aerial_src)
.unwrap_or_else(|e| panic!("aerial: WGSL-Parse-Fehler: {e:?}"));
Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&aerial_module)
.unwrap_or_else(|e| panic!("aerial: WGSL-Validierung fehlgeschlagen: {e:?}"));
} }
} }
+192 -5
View File
@@ -835,6 +835,160 @@ fn compute_wall_miters(walls: &[WallInput]) -> Vec<WallMiters> {
out out
} }
/// Ermittelt je Wand, ob ihr Start-/Endpunkt ein FREIES Ende ist — d. h. KEIN
/// anderes Wandende (auch kein T-/X-Stoss) trifft sich dort im selben
/// Hoehenbereich. Dieselbe Punkt-Rundung + Hoehen-Ueberlappungs-Clustering wie
/// `compute_wall_miters`, aber OHNE dessen Zwei-Wandenden-Einschraenkung: jeder
/// Cluster der Groesse ≥ 2 (Gehrung ODER T-/X-Stoss) gilt als "beruehrt", nur
/// ein isolierter Cluster der Groesse 1 (nur die Wand selbst) bleibt frei.
/// Grundlage der Schraffur-Kappen an unverbundenen Wandenden
/// (`build_free_wall_end_caps`) — ein Wandende "ohne Anschluss" (Nutzer-Begriff)
/// zeigt dort die Bauteil-Schraffur statt der Flaechenfarbe, wie ein echter
/// Schnitt.
fn compute_free_wall_ends(walls: &[WallInput]) -> Vec<(bool, bool)> {
let mut out = vec![(true, true); walls.len()];
let mut ends: Vec<EndRef> = Vec::with_capacity(walls.len() * 2);
for (i, w) in walls.iter().enumerate() {
let n = left_normal(w.start, w.end);
if n == [0.0, 0.0] {
continue; // entartete Wand: weder Ende gilt als "frei" (irrelevant)
}
let dir: Point2 = [n[1], -n[0]];
let z0 = w.base_elevation;
let z1 = w.base_elevation + w.height;
ends.push(EndRef { wall: i, is_start: true, point: w.start, dir, z0, z1 });
ends.push(EndRef { wall: i, is_start: false, point: w.end, dir, z0, z1 });
}
let mut groups: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for (idx, e) in ends.iter().enumerate() {
groups.entry(round_key(e.point)).or_default().push(idx);
}
for members in groups.into_values() {
if members.len() < 2 {
continue; // einziges Ende an diesem Punkt -> bleibt frei (Default)
}
for cluster in cluster_by_height_overlap(&ends, &members) {
if cluster.len() < 2 {
continue; // eigener, hoehenmaessig isolierter Cluster -> frei
}
for &idx in &cluster {
let e = &ends[idx];
if e.is_start {
out[e.wall].0 = false;
} else {
out[e.wall].1 = false;
}
}
}
}
out
}
/// Haengt EINE Schraffur-Kappe (alle Schicht-Baender gestapelt) an einem
/// Wand-Achsenende an `verts` an — Vertex-Format identisch zu
/// `section_fill::build_cut_caps` (`CAP_FLOATS_PER_VERTEX`), damit dieselbe
/// `cap_pipeline`/`CAP_WGSL` sie zeichnen kann. `(u,v)` = (Quer-Versatz `s` zur
/// Wandachse, Welt-Hoehe) — metrisch, an den Schicht-Grenzen stetig.
#[allow(clippy::too_many_arguments)]
fn push_wall_end_cap(
verts: &mut Vec<f32>,
point: Point2,
n: Point2,
layers: &[WallLayer],
total: f32,
y0: f32,
y1: f32,
pattern: f32,
angle_rad: f32,
scale: f32,
line_weight: f32,
) {
let corner = |s: f32, y: f32| -> [f32; 9] {
let p = [point[0] + n[0] * s, point[1] + n[1] * s];
[p[0], y, p[1], s, y, pattern, angle_rad, scale, line_weight]
};
let mut off_a = total * 0.5;
for layer in layers {
let off_b = off_a - layer.thickness;
let c0 = corner(off_a, y0);
let c1 = corner(off_b, y0);
let c2 = corner(off_b, y1);
let c3 = corner(off_a, y1);
// Fan-Triangulierung des Quads (Winding egal, cap_pipeline kennt kein
// Backface-Culling — siehe section_fill.rs-Moduldoc).
verts.extend_from_slice(&c0);
verts.extend_from_slice(&c1);
verts.extend_from_slice(&c2);
verts.extend_from_slice(&c0);
verts.extend_from_slice(&c2);
verts.extend_from_slice(&c3);
off_a = off_b;
}
}
/// Schraffur-Kappen an FREIEN Wandenden (kein Anschluss an eine andere Wand,
/// s. `compute_free_wall_ends`): additive, ZWEITE Geometrie neben der
/// (unveraendert weiterhin flach eingefaerbten) End-Kappe der normalen
/// Extrusion — gezeichnet mit Tiefen-Bias VOR der Flaeche (analog dem
/// Luftbild-Overlay `gpu::aerial_pipeline`), sodass sie diese optisch
/// ueberdeckt, OHNE `extrude_layer_segment` selbst anzufassen (kein
/// Regressionsrisiko fuer bestehende Vollkoerper-Tests). Nur Waende MIT
/// explizit gesetzter `hatch` (Bauteil-Schraffur, `toWalls3d.ts` loest sie je
/// Materialschicht auf) bekommen eine Kappe — ohne `hatch` (z. B. alle
/// bestehenden Test-Fixtures) bleibt das Alt-Verhalten unangetastet. Bildet NUR
/// die echten Achsen-Enden ab (Wandanfang/-ende), keine Oeffnungs-Laibungen —
/// "Wand endet an Decke" (vertikale Terminierung) ist bewusst NICHT Teil dieser
/// Kappen (separates, noch offenes Thema).
pub fn build_free_wall_end_caps(walls: &[WallInput]) -> Vec<f32> {
let free_ends = compute_free_wall_ends(walls);
let mut verts: Vec<f32> = Vec::new();
for (wi, wall) in walls.iter().enumerate() {
let Some(hatch) = wall.hatch else { continue };
let (free_start, free_end) = free_ends[wi];
if !free_start && !free_end {
continue;
}
let n = left_normal(wall.start, wall.end);
if n == [0.0, 0.0] || wall.height <= 1e-6 {
continue;
}
let y0 = wall.base_elevation;
let y1 = wall.base_elevation + wall.height;
let synthetic;
let layers: &[WallLayer] = match &wall.layers {
Some(ls) if !ls.is_empty() => ls.as_slice(),
_ => {
synthetic = [WallLayer { thickness: wall.thickness, color: wall.color }];
&synthetic
}
};
let total: f32 = layers.iter().map(|l| l.thickness).sum();
let pattern = hatch.pattern as f32;
let angle_rad = hatch.angle.to_radians();
let scale = hatch.scale.max(0.05);
let line_weight = hatch.line_weight.max(0.0);
if free_start {
push_wall_end_cap(
&mut verts, wall.start, n, layers, total, y0, y1, pattern, angle_rad, scale,
line_weight,
);
}
if free_end {
push_wall_end_cap(
&mut verts, wall.end, n, layers, total, y0, y1, pattern, angle_rad, scale,
line_weight,
);
}
}
verts
}
/// Grundriss-Fussabdruck EINER Wand (die vier Band-Eckpunkte g0..g3 in CCW), /// Grundriss-Fussabdruck EINER Wand (die vier Band-Eckpunkte g0..g3 in CCW),
/// an ihren echten Achsenenden ggf. gehrt — bitgenau dieselbe Ecken-Konstruktion /// an ihren echten Achsenenden ggf. gehrt — bitgenau dieselbe Ecken-Konstruktion
/// wie `extrude_layer_segment` fuer das Vollstueck (Einzel-Layer, keine /// wie `extrude_layer_segment` fuer das Vollstueck (Einzel-Layer, keine
@@ -1026,7 +1180,10 @@ pub fn build_scene_mesh_textured(
for m in meshes { for m in meshes {
let before = mesh.vertex_count(); let before = mesh.vertex_count();
append_context_mesh(&mut mesh, m); append_context_mesh(&mut mesh, m);
fill(&mesh, &mut layers, before, -1.0); // Material-Ebene des Kontext-Meshes (aktuell nur Daecher setzen sie,
// s. `MeshInput::material_index`); ohne -> Fallback-Schachbrett.
let layer = m.material_index.map(|i| i as f32).unwrap_or(-1.0);
fill(&mesh, &mut layers, before, layer);
} }
let mut tex = textured_from_mesh(&mesh); let mut tex = textured_from_mesh(&mesh);
@@ -1063,6 +1220,20 @@ pub fn build_scene_mesh(walls: &[WallInput], slabs: &[SlabInput], meshes: &[Mesh
mesh mesh
} }
/// Baut NUR die Gelaende-Dreiecke (`MeshKind::Terrain`) zu EINEM Mesh — Grundlage
/// des Luftbild-Overlays (`gpu::aerial_pipeline`): dieselbe Geometrie wie im
/// Hauptpuffer, aber separat, damit sie ein zweites Mal texturiert obenauf
/// gezeichnet werden kann. Leer, wenn kein Terrain vorliegt.
pub fn build_terrain_mesh(meshes: &[MeshInput]) -> Mesh {
let mut mesh = Mesh::default();
for m in meshes {
if matches!(m.kind, crate::types::MeshKind::Terrain) {
append_context_mesh(&mut mesh, m);
}
}
mesh
}
// ── Rohe Kontext-Meshes (Terrain / importierte Volumen) ────────────────────── // ── Rohe Kontext-Meshes (Terrain / importierte Volumen) ──────────────────────
/// Haengt EIN rohes Dreiecks-Mesh (Terrain-TIN oder importiertes Volumen, siehe /// Haengt EIN rohes Dreiecks-Mesh (Terrain-TIN oder importiertes Volumen, siehe
@@ -1085,6 +1256,21 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
let pos = &input.positions; let pos = &input.positions;
let vcount = pos.len() / 3; let vcount = pos.len() / 3;
let color = input.effective_color(); let color = input.effective_color();
// Pro-Vertex-Farben nur nutzen, wenn genau ein RGB je Vertex vorliegt
// (sonst greift die Einzelfarbe fuers ganze Mesh). Beispiel: Luftbild-Drape.
let per_vertex = input.vertex_colors.len() >= vcount * 3 && vcount > 0;
let vcol = |i: usize| -> Rgb {
if per_vertex {
let b = i * 3;
[
input.vertex_colors[b],
input.vertex_colors[b + 1],
input.vertex_colors[b + 2],
]
} else {
color
}
};
// world-Position aus Modell (x, y, z=Hoehe): (x, hoehe, y). // world-Position aus Modell (x, y, z=Hoehe): (x, hoehe, y).
let vworld = |i: usize| -> [f32; 3] { let vworld = |i: usize| -> [f32; 3] {
let b = i * 3; let b = i * 3;
@@ -1099,6 +1285,7 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
let a = vworld(ia); let a = vworld(ia);
let b = vworld(ib); let b = vworld(ib);
let c = vworld(ic); let c = vworld(ic);
let (ca, cb, cc) = (vcol(ia), vcol(ib), vcol(ic));
// Flaechen-Normale (b-a) x (c-a); entartete Dreiecke ueberspringen. // Flaechen-Normale (b-a) x (c-a); entartete Dreiecke ueberspringen.
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
@@ -1113,8 +1300,8 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
} }
let n = [gn[0] / len, gn[1] / len, gn[2] / len]; let n = [gn[0] / len, gn[1] / len, gn[2] / len];
// Vorderseite + Rueckseite (siehe Moduldoc: doppelseitig). // Vorderseite + Rueckseite (siehe Moduldoc: doppelseitig).
push_ctx_tri(mesh, a, b, c, n, color); push_ctx_tri(mesh, a, b, c, n, [ca, cb, cc]);
push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], color); push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], [ca, cc, cb]);
} }
} }
@@ -1127,10 +1314,10 @@ fn push_ctx_tri(
b: [f32; 3], b: [f32; 3],
c: [f32; 3], c: [f32; 3],
normal: [f32; 3], normal: [f32; 3],
color: Rgb, colors: [Rgb; 3],
) { ) {
let base = (mesh.verts.len() / FLOATS_PER_VERTEX) as u32; let base = (mesh.verts.len() / FLOATS_PER_VERTEX) as u32;
for p in [a, b, c] { for (p, color) in [a, b, c].iter().zip(colors.iter()) {
mesh.verts.extend_from_slice(&[ mesh.verts.extend_from_slice(&[
p[0], p[1], p[2], normal[0], normal[1], normal[2], color[0], color[1], color[2], p[0], p[1], p[2], normal[0], normal[1], normal[2], color[0], color[1], color[2],
]); ]);
+84
View File
@@ -220,6 +220,90 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
} }
"#; "#;
/// WGSL des LUFTBILD-Drapierens aufs Gelände (`gpu::aerial_pipeline`). Zeichnet die
/// TERRAIN-Dreiecke ein zweites Mal, texturiert mit EINEM georeferenzierten Luftbild
/// (SWISSIMAGE). Die UV kommen NICHT aus einem Vertex-Attribut, sondern werden im
/// Fragment aus der WELT-Lage (world.x = Ost, world.z = Nord) und der Bild-Bounding-
/// Box (group 1, Uniform `bbox = (min_x, min_y, max_x, max_y)` in Modell-Metern)
/// berechnet — dadurch ist die Auflösung PIXELSCHARF (unabhängig von der TIN-Dichte,
/// anders als die Pro-Vertex-Färbung). Beleuchtung identisch zu `MESH_WGSL`. Vertex-
/// Layout = das Haupt-Mesh-Layout `[pos, normal, color]` (color wird ignoriert), damit
/// derselbe Vertex-Puffer (Terrain-Overlay) ohne Re-Meshing genutzt werden kann.
/// Bild-Zeile 0 liegt im NORDEN (+Y) -> die Textur-v-Achse wird gespiegelt (1 - v).
pub const MESH_AERIAL_WGSL: &str = r#"
struct Globals {
view_proj : mat4x4<f32>,
light_dir : vec4<f32>,
sky_color : vec4<f32>,
ground_color : vec4<f32>,
sun_color : vec4<f32>,
mode : vec4<f32>,
section_plane : vec4<f32>,
};
@group(0) @binding(0) var<uniform> globals : Globals;
// Luftbild-Textur + Sampler + Bounding-Box (group 1). `bbox = (min_x, min_y,
// max_x, max_y)` in Modell-Metern (x = Ost/world.x, y = Nord/world.z).
@group(1) @binding(0) var tex : texture_2d<f32>;
@group(1) @binding(1) var samp : sampler;
struct Aerial { bbox : vec4<f32>, };
@group(1) @binding(2) var<uniform> aerial : Aerial;
struct VsIn {
@location(0) position : vec3<f32>,
@location(1) normal : vec3<f32>,
@location(2) color : vec3<f32>,
};
struct VsOut {
@builtin(position) clip_pos : vec4<f32>,
@location(0) world_normal : vec3<f32>,
@location(1) world_pos : vec3<f32>,
};
@vertex
fn vs_main(in : VsIn) -> VsOut {
var out : VsOut;
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
out.world_normal = in.normal;
out.world_pos = in.position;
return out;
}
@fragment
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
// Live-Schnitt-Kappung identisch zur Mesh-Pipeline (mode.y > 0.5 = aktiv).
if (globals.mode.y > 0.5) {
if (dot(in.world_pos, globals.section_plane.xyz) + globals.section_plane.w > 0.0) {
discard;
}
}
let n = normalize(in.world_normal);
let l = normalize(globals.light_dir.xyz);
// Hemisphaerisches Ambient (identisch zu MESH_WGSL).
let hemi_t = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
let ambient = mix(globals.ground_color.rgb, globals.sky_color.rgb, hemi_t);
let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb;
let fill_l = normalize(vec3<f32>(-l.x, abs(l.y) * 0.35 + 0.15, -l.z));
let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb
* vec3<f32>(0.24, 0.27, 0.30);
// UV aus der Welt-Lage + Bild-Bbox. world.x = Ost, world.z = Nord.
let span = vec2<f32>(aerial.bbox.z - aerial.bbox.x, aerial.bbox.w - aerial.bbox.y);
let u = (in.world_pos.x - aerial.bbox.x) / span.x;
let v = (in.world_pos.z - aerial.bbox.y) / span.y;
// Bild-Zeile 0 = Nord (+Y) -> v spiegeln. Ausserhalb [0,1] -> Randpixel (Clamp
// uebernimmt der Sampler mit ClampToEdge).
let albedo = textureSample(tex, samp, vec2<f32>(u, 1.0 - v)).rgb;
var shaded = albedo * (ambient + diffuse + fill);
let edge = 0.90 + 0.10 * abs(n.y);
shaded = shaded * edge;
return vec4<f32>(shaded, 1.0);
}
"#;
/// WGSL des Referenz-Bodengitters (grid.rs). Eigene, schlanke `LineList`-Pipeline: /// WGSL des Referenz-Bodengitters (grid.rs). Eigene, schlanke `LineList`-Pipeline:
/// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion /// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion
/// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline. /// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline.
+23 -2
View File
@@ -336,6 +336,20 @@ pub struct MeshInput {
/// Default-Farbe je `kind` (siehe `effective_color`). /// Default-Farbe je `kind` (siehe `effective_color`).
#[serde(default)] #[serde(default)]
pub color: Option<Rgb>, pub color: Option<Rgb>,
/// Optionaler Material-Textur-Index fuer den Stil `Textured` (1-basiert,
/// Ebene 0 = Fallback-Schachbrett) — analog `WallInput::material_index`.
/// Bislang nutzen das nur Daecher (`toWalls3d.ts::emitRoofs`); andere
/// Kontext-Meshes (Terrain/Import) bleiben ohne (`None` -> Schachbrett).
#[serde(default, rename = "materialIndex")]
pub material_index: Option<u32>,
/// Optionale PRO-VERTEX-Albedo (flaches `[r,g,b, r,g,b, ...]`, 0..1) — ein
/// Wert je Positions-Vertex. Wird u. a. genutzt, um ein SWISSIMAGE-Luftbild
/// auf das Gelaende-TIN zu „drapieren" (jeder Vertex traegt den Bildpunkt an
/// seiner Lage). Ist das Array leer oder zu kurz, greift die Einzelfarbe
/// (`effective_color`) fuer das ganze Mesh. Die Farben werden im Shaded-Pfad
/// pro Vertex interpoliert (kein Textur-Sampler noetig).
#[serde(default, rename = "vertexColors")]
pub vertex_colors: Vec<f32>,
} }
impl MeshInput { impl MeshInput {
@@ -398,8 +412,15 @@ fn default_ortho_half_height() -> f32 {
fn default_near() -> f32 { fn default_near() -> f32 {
0.05 0.05
} }
fn default_far() -> f32 { // `pub(crate)`, damit `web::set_camera` denselben Basiswert als Untergrenze fuer
1000.0 // die modellgroessen-abhaengige Far-Ebene nutzen kann (s. Moduldoc dort).
// War 1000.0 — Nutzer-Report: Teile des Modells (und das Boden-Referenzgitter)
// verschwanden abhaengig von Kamera-Distanz/-Winkel. Grosszuegig angehoben,
// damit im ueblichen Gebrauch (auch grosse/georeferenzierte Situationen) nie
// etwas am Fern-Clipping haengenbleibt; `set_camera` skaliert ohnehin
// zusaetzlich mit der tatsaechlichen Modell-Bounding-Box nach oben.
pub(crate) fn default_far() -> f32 {
50_000.0
} }
impl Default for Camera { impl Default for Camera {
+86 -2
View File
@@ -114,7 +114,11 @@ pub struct WebModelRenderer {
/// Decken. Mirror der TS-`fitTargetDist`-Grow-Schleife (Wasm3DViewport.tsx): /// Decken. Mirror der TS-`fitTargetDist`-Grow-Schleife (Wasm3DViewport.tsx):
/// Wand-Achsenpunkte auf Basis-/Firsthoehe, Decken-Umriss auf Unter-/Oberkante. /// Wand-Achsenpunkte auf Basis-/Firsthoehe, Decken-Umriss auf Unter-/Oberkante.
/// `None` bei leerem Modell. /// `None` bei leerem Modell.
fn model_bounds(walls: &[WallInput], slabs: &[SlabInput]) -> Option<([f32; 3], [f32; 3])> { fn model_bounds(
walls: &[WallInput],
slabs: &[SlabInput],
meshes: &[MeshInput],
) -> Option<([f32; 3], [f32; 3])> {
let mut min = [f32::INFINITY; 3]; let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3]; let mut max = [f32::NEG_INFINITY; 3];
let mut grow = |x: f32, y: f32, z: f32| { let mut grow = |x: f32, y: f32, z: f32| {
@@ -147,6 +151,17 @@ fn model_bounds(walls: &[WallInput], slabs: &[SlabInput]) -> Option<([f32; 3], [
grow(p[0], s.z_top, p[1]); grow(p[0], s.z_top, p[1]);
} }
} }
// Meshes (Daecher, Extrusionen, Stuetzen UND importierte Gebaeude/Terrain):
// model (x,y,z=Hoehe) -> world (x, z, y). Ohne sie kann ein Praeset/Fit den
// (evtl. weit entfernten) Import nicht rahmen.
for m in meshes {
let p = &m.positions;
let mut i = 0;
while i + 2 < p.len() {
grow(p[i], p[i + 2], p[i + 1]);
i += 3;
}
}
if !min[0].is_finite() { if !min[0].is_finite() {
return None; return None;
} }
@@ -247,7 +262,7 @@ impl WebModelRenderer {
pub fn set_model(&mut self, json: &str) -> Result<(), JsValue> { pub fn set_model(&mut self, json: &str) -> Result<(), JsValue> {
let model: ModelInput = serde_json::from_str(json) let model: ModelInput = serde_json::from_str(json)
.map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?; .map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?;
self.bounds = model_bounds(&model.walls, &model.slabs); self.bounds = model_bounds(&model.walls, &model.slabs, &model.meshes);
self.renderer self.renderer
.upload_model(&self.device, &model.walls, &model.slabs, &model.meshes); .upload_model(&self.device, &model.walls, &model.slabs, &model.meshes);
// Wand-/Decken-Eingabe fuer den Live-Schnitt cachen (Klon, da der Renderer // Wand-/Decken-Eingabe fuer den Live-Schnitt cachen (Klon, da der Renderer
@@ -300,6 +315,15 @@ impl WebModelRenderer {
/// (`ortho_half_height` = halbe Sichthoehe in Metern). Die TS-Seite berechnet /// (`ortho_half_height` = halbe Sichthoehe in Metern). Die TS-Seite berechnet
/// `eye` aus Orbit-Winkeln/Distanz oder einem Praeset (front/top/side/iso/persp) /// `eye` aus Orbit-Winkeln/Distanz oder einem Praeset (front/top/side/iso/persp)
/// — siehe `useWasm3dRenderer`/`Wasm3DViewport`. /// — siehe `useWasm3dRenderer`/`Wasm3DViewport`.
///
/// FAR-EBENE: die feste `Camera::default()`-Far-Ebene (1000 m) reicht bei
/// grossen georeferenzierten Importen (Suchradius bis 2000 m, Bounding-Kugel
/// entsprechend gross) nicht mehr — Teile des Modells wurden je nach
/// Blickwinkel/Distanz VOM FERN-CLIPPING abgeschnitten (Nutzer-Report: "Teile
/// im 3D nicht sichtbar in einem Winkel"). Die Far-Ebene wird deshalb hier je
/// Aufruf aus der zuletzt hochgeladenen Modell-Bounding-Box abgeleitet: der
/// weiteste Bbox-Eckpunkt vom Auge aus + Sicherheitsmarge, mindestens der
/// bisherige Default (kleine Modelle bleiben unveraendert).
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn set_camera( pub fn set_camera(
&mut self, &mut self,
@@ -316,6 +340,32 @@ impl WebModelRenderer {
fov_y: f32, fov_y: f32,
ortho_half_height: f32, ortho_half_height: f32,
) { ) {
let far = match self.bounds {
Some((min, max)) => {
let corners = [
[min[0], min[1], min[2]],
[max[0], min[1], min[2]],
[min[0], max[1], min[2]],
[max[0], max[1], min[2]],
[min[0], min[1], max[2]],
[max[0], min[1], max[2]],
[min[0], max[1], max[2]],
[max[0], max[1], max[2]],
];
let mut max_dist = 0.0f32;
for c in corners {
let dx = c[0] - eye_x;
let dy = c[1] - eye_y;
let dz = c[2] - eye_z;
let d = (dx * dx + dy * dy + dz * dz).sqrt();
if d > max_dist {
max_dist = d;
}
}
(max_dist * 1.5).max(crate::types::default_far())
}
None => crate::types::default_far(),
};
self.camera = Camera { self.camera = Camera {
eye: [eye_x, eye_y, eye_z], eye: [eye_x, eye_y, eye_z],
target: [target_x, target_y, target_z], target: [target_x, target_y, target_z],
@@ -327,6 +377,7 @@ impl WebModelRenderer {
}, },
fov_y, fov_y,
ortho_half_height, ortho_half_height,
far,
..Camera::default() ..Camera::default()
}; };
} }
@@ -455,6 +506,39 @@ impl WebModelRenderer {
Ok(()) Ok(())
} }
/// Setzt/ersetzt das aufs Gelaende drapierte Luftbild (SWISSIMAGE). `rgba` =
/// dicht gepackte RGBA8-Bytes (`width*height*4`, zeilenweise von OBEN = Nord),
/// die der JS-Aufrufer aus der Bild-Data-URL dekodiert (siehe
/// `viewport/aerialTexture.ts`). `min_x/min_y/max_x/max_y` = Modell-Meter-
/// Bounding-Box (x = Ost, y = Nord). Ungueltige/leere Eingabe loescht das
/// Luftbild. Wirkt erst beim naechsten `render`.
#[allow(clippy::too_many_arguments)]
pub fn set_aerial_texture(
&mut self,
rgba: &[u8],
width: u32,
height: u32,
min_x: f32,
min_y: f32,
max_x: f32,
max_y: f32,
) -> Result<(), JsValue> {
self.renderer.set_aerial_texture(
&self.device,
&self.queue,
rgba,
width,
height,
[min_x, min_y, max_x, max_y],
);
Ok(())
}
/// Loescht das drapierte Luftbild (Gelaende erscheint wieder einfarbig/shaded).
pub fn clear_aerial(&mut self) {
self.renderer.clear_aerial();
}
/// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer). /// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
pub fn resize(&mut self, width: u32, height: u32) { pub fn resize(&mut self, width: u32, height: u32) {
let (w, h) = (width.max(1), height.max(1)); let (w, h) = (width.max(1), height.max(1));
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "cad", "productName": "Dossier",
"version": "0.1.0", "version": "0.1.0",
"identifier": "ch.dossier.cad", "identifier": "ch.dossier.cad",
"build": { "build": {
@@ -11,12 +11,13 @@
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
"title": "cad", "title": "Dossier",
"width": 1400, "width": 1400,
"height": 900, "height": 900,
"dragDropEnabled": false, "dragDropEnabled": false,
"titleBarStyle": "Overlay", "titleBarStyle": "Overlay",
"hiddenTitle": true "hiddenTitle": true,
"trafficLightPosition": { "x": 20, "y": 22 }
} }
], ],
"security": { "security": {
+416 -1491
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -308,6 +308,49 @@ describe("exportIfcSpf — IFC4-Export", () => {
expect(ifcGuid("W2")).not.toBe(guid); // unterschiedliche IDs → unterschiedliche GUIDs expect(ifcGuid("W2")).not.toBe(guid); // unterschiedliche IDs → unterschiedliche GUIDs
}); });
it("georeferenziert: mit geoAnchor werden die ECHTEN LV95-Koordinaten geschrieben (lokal bearbeitet, weltweit lagerichtig)", () => {
// Modell lokal nahe (0,0), aber geoAnchor sagt: Modell (0,0) = LV95
// (2600000,1200000). Der Export muss die Profil-/Punkt-Koordinaten auf die
// echten Landeskoordinaten (Millionenbereich) verschoben schreiben.
const local = fixtureProject();
const withoutAnchor = exportIfcSpf(local);
// Ohne Bezug: lokale Koordinaten, KEINE Millionenwerte im Text.
expect(withoutAnchor).not.toMatch(/26000\d\d\./);
const geo: Project = {
...local,
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 } },
};
const spf = exportIfcSpf(geo);
// W1.start (0,0) -> LV95 (2600000,1200000); W2.end (5,4) -> (2600005,1200004).
expect(spf).toContain("2600000.");
expect(spf).toContain("1200000.");
expect(spf).toContain("2600005.");
// Struktur bleibt valide (keine dangling refs / Duplikate).
const { defined, referenced, duplicates } = collectIds(spf);
expect(duplicates).toEqual([]);
expect([...referenced].filter((id) => !defined.has(id))).toEqual([]);
});
it("geoAnchor.height wird als IfcSite.RefElevation geschrieben (Vermessungspunkt-Höhe)", () => {
const local = fixtureProject();
const geo: Project = {
...local,
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, height: 683.5, model: { x: 0, y: 0 } },
};
const spf = exportIfcSpf(geo);
// Die IFCSITE-Zeile trägt die reale Datumshöhe (RefElevation, 12. Attribut).
const siteLine = spf.split("\n").find((l) => l.includes("=IFCSITE("));
expect(siteLine).toBeDefined();
expect(siteLine).toContain("683.5");
// Ohne Höhe bleibt RefElevation leer ($) — kein 683.5 irgendwo.
const noH = exportIfcSpf({
...local,
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 } },
});
expect(noH).not.toContain("683.5");
});
it("leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash)", () => { it("leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash)", () => {
const proj = fixtureProject(); const proj = fixtureProject();
proj.walls = []; proj.walls = [];
+14 -2
View File
@@ -59,6 +59,7 @@ import {
openingLabel, openingLabel,
wallTypeThickness, wallTypeThickness,
} from "../model/types"; } from "../model/types";
import { projectToWorldCoords } from "../model/geoRebase";
import { import {
ceilingVerticalExtent, ceilingVerticalExtent,
stairVerticalExtent, stairVerticalExtent,
@@ -283,7 +284,13 @@ interface Structure {
* File). Reiner Rechenkern kein Datei-IO. Leeres Projekt valider Minimal- * File). Reiner Rechenkern kein Datei-IO. Leeres Projekt valider Minimal-
* IFC (Project/Site/Building, kein Crash). * IFC (Project/Site/Building, kein Crash).
*/ */
export function exportIfcSpf(project: Project): string { export function exportIfcSpf(projectIn: Project): string {
// Georeferenziert exportieren: ist ein Standort-Bezug gesetzt (geoAnchor),
// wird die gesamte Geometrie auf die ECHTEN LV95-Weltkoordinaten verschoben,
// damit die IFC-Datei in anderer Software lagerichtig sitzt — auch wenn das
// Modell lokal nahe (0,0) bearbeitet wurde (s. projectToWorldCoords / der
// „Neuer Bezugspunkt"-Workflow). Ohne Bezug bleibt alles lokal (Identität).
const project = projectToWorldCoords(projectIn);
const w = new StepWriter(); const w = new StepWriter();
// Geteilte Grundgeometrie: Ursprung, Z-Extrusionsrichtung, Identitäts- // Geteilte Grundgeometrie: Ursprung, Z-Extrusionsrichtung, Identitäts-
@@ -341,9 +348,14 @@ export function exportIfcSpf(project: Project): string {
"IFCPROJECT", "IFCPROJECT",
`${S(ifcGuid(`${project.id}:project`))},#${ownerHistory},${S(project.name || "Projekt")},$,$,$,$,${LIST([`#${context}`])},#${unitAssignment}`, `${S(ifcGuid(`${project.id}:project`))},#${ownerHistory},${S(project.name || "Projekt")},$,$,$,$,${LIST([`#${context}`])},#${unitAssignment}`,
); );
// RefElevation (m ü. M.): reale Datumshöhe des Standorts aus dem
// Georeferenzierungs-Bezug (wie ArchiCADs Vermessungspunkt). Rein informativ —
// die Geometrie wird NICHT verschoben (das Terrain trägt bereits reale Höhen).
const refElevation = project.geoAnchor?.height;
const siteRefElev = typeof refElevation === "number" ? R(refElevation) : "$";
const siteId = w.add( const siteId = w.add(
"IFCSITE", "IFCSITE",
`${S(ifcGuid(`${project.id}:site`))},#${ownerHistory},${S("Standort")},$,$,#${sitePlacement},$,$,${ENUM("ELEMENT")},$,$,$,$,$`, `${S(ifcGuid(`${project.id}:site`))},#${ownerHistory},${S("Standort")},$,$,#${sitePlacement},$,$,${ENUM("ELEMENT")},$,$,${siteRefElev},$,$`,
); );
const buildingId = w.add( const buildingId = w.add(
"IFCBUILDING", "IFCBUILDING",
+105
View File
@@ -199,3 +199,108 @@ describe("exportScheduleCsv — Bauteilliste", () => {
expect(scheduleRows(proj)).toHaveLength(0); expect(scheduleRows(proj)).toHaveLength(0);
}); });
}); });
describe("scheduleRows — Dächer und Stützen (bisher fehlten sie in Liste/Element-Baum)", () => {
it("wertet ein Dach mit Dachtyp aus (Typname aus roofTypes, Dicke aus Schichtsumme via roof.thickness)", () => {
const proj = fixtureProject();
proj.roofTypes = [{ id: "rt", name: "Warmdach", layers: [{ componentId: "c", thickness: 0.35 }] }];
proj.roofs = [
{
id: "R1",
type: "roof",
floorId: "eg",
categoryCode: "31",
outline: [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 3 },
{ x: 0, y: 3 },
], // 4×3 = 12 m²
shape: "flach",
pitchDeg: 0,
overhang: 0,
ridgeAxis: "x",
thickness: 0.35,
roofTypeId: "rt",
},
];
const rows = scheduleRows(proj).filter((r) => r.kind === "Dach");
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
kind: "Dach",
id: "R1",
typeName: "Warmdach",
floor: "EG",
length: null,
heightM: null,
thickness: 0.35,
area: 12,
});
});
it("wertet eine rechteckige und eine runde Stütze aus (Profil-Masse als Länge/Dicke)", () => {
const proj = fixtureProject();
proj.columns = [
{
id: "C1",
type: "column",
floorId: "eg",
categoryCode: "50",
position: { x: 1, y: 1 },
profile: { kind: "rect", width: 0.3, depth: 0.4 },
rotation: 0,
height: 2.6,
},
{
id: "C2",
type: "column",
floorId: "eg",
categoryCode: "50",
position: { x: 2, y: 2 },
profile: { kind: "round", radius: 0.15 },
rotation: 0,
height: 2.6,
},
];
const rows = scheduleRows(proj).filter((r) => r.kind === "Stütze");
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
id: "C1",
typeName: "Rechteck",
length: 0.3,
heightM: 2.6,
thickness: 0.4,
area: null,
});
expect(rows[1]).toMatchObject({
id: "C2",
typeName: "Rund",
length: null,
heightM: 2.6,
thickness: 0.3, // 2 × Radius
area: null,
});
});
it("Räume bleiben bewusst ausgeschlossen (eigener CSV-Export, siehe Dateikopf)", () => {
const proj = fixtureProject();
proj.rooms = [
{
id: "RM1",
type: "room",
floorId: "eg",
categoryCode: "45",
siaCategory: "HNF",
name: "Wohnen",
boundary: [
{ x: 0, y: 0 },
{ x: 3, y: 0 },
{ x: 3, y: 3 },
{ x: 0, y: 3 },
],
color: "#fff",
},
];
expect(scheduleRows(proj).some((r) => r.id === "RM1")).toBe(false);
});
});
+47 -5
View File
@@ -18,6 +18,8 @@ import type {
Opening, Opening,
Stair, Stair,
ExtrudedSolid, ExtrudedSolid,
Roof,
Column,
} from "../model/types"; } from "../model/types";
import { import {
getWallType, getWallType,
@@ -63,7 +65,9 @@ export type ScheduleKind =
| "Fenster" | "Fenster"
| "Öffnung" | "Öffnung"
| "Treppe" | "Treppe"
| "Extrusion"; | "Extrusion"
| "Dach"
| "Stütze";
/** Eine ausgewertete Zeile der Bauteilliste (je Bauteil-Vorkommen ein Datensatz). */ /** Eine ausgewertete Zeile der Bauteilliste (je Bauteil-Vorkommen ein Datensatz). */
export interface ScheduleRow { export interface ScheduleRow {
@@ -215,12 +219,48 @@ function evalExtrudedSolid(project: Project, solid: ExtrudedSolid): ScheduleRow
}; };
} }
/** Wertet ein Dach für die Bauteilliste aus (defensiv gegen fehlenden Dachtyp). */
function evalRoof(project: Project, roof: Roof): ScheduleRow {
let typeName = roof.roofTypeId ?? roof.shape;
if (roof.roofTypeId) {
const rt = (project.roofTypes ?? []).find((t) => t.id === roof.roofTypeId);
if (rt) typeName = rt.name;
}
return {
kind: "Dach",
id: roof.id,
typeName,
floor: floorNameOf(project, roof.floorId),
floorId: roof.floorId,
length: null,
heightM: null,
thickness: roof.thickness,
area: polygonArea(roof.outline),
};
}
/** Wertet eine Stütze für die Bauteilliste aus (Querschnitt als „Dicke", Rechteck-Breite als „Länge"). */
function evalColumn(project: Project, column: Column): ScheduleRow {
return {
kind: "Stütze",
id: column.id,
typeName: column.profile.kind === "round" ? "Rund" : "Rechteck",
floor: floorNameOf(project, column.floorId),
floorId: column.floorId,
length: column.profile.kind === "rect" ? column.profile.width : null,
heightM: column.height,
thickness:
column.profile.kind === "rect" ? column.profile.depth : column.profile.radius * 2,
area: null,
};
}
/** /**
* Wertet ALLE Bauteil-Vorkommen eines Projekts aus (Wände, Decken, Türen, * Wertet ALLE Bauteil-Vorkommen eines Projekts aus (Wände, Decken, Türen,
* Fenster/Öffnungen, Treppen, Extrusionen Räume bewusst ausgenommen, siehe * Fenster/Öffnungen, Treppen, Extrusionen, Dächer, Stützen Räume bewusst
* Dateikopf). Reine Ableitung aus dem Modell nichts wird gespeichert. * ausgenommen, siehe Dateikopf). Reine Ableitung aus dem Modell nichts wird
* Reihenfolge: Wände, Decken, Türen, Öffnungen, Treppen, Extrusionen (je in * gespeichert. Reihenfolge: Wände, Decken, Türen, Öffnungen, Treppen,
* Dokumentreihenfolge). * Extrusionen, Dächer, Stützen (je in Dokumentreihenfolge).
*/ */
export function scheduleRows(project: Project): ScheduleRow[] { export function scheduleRows(project: Project): ScheduleRow[] {
const rows: ScheduleRow[] = []; const rows: ScheduleRow[] = [];
@@ -230,6 +270,8 @@ export function scheduleRows(project: Project): ScheduleRow[] {
for (const o of project.openings ?? []) rows.push(evalOpening(project, o)); for (const o of project.openings ?? []) rows.push(evalOpening(project, o));
for (const s of project.stairs ?? []) rows.push(evalStair(project, s)); for (const s of project.stairs ?? []) rows.push(evalStair(project, s));
for (const e of project.extrudedSolids ?? []) rows.push(evalExtrudedSolid(project, e)); for (const e of project.extrudedSolids ?? []) rows.push(evalExtrudedSolid(project, e));
for (const r of project.roofs ?? []) rows.push(evalRoof(project, r));
for (const c of project.columns ?? []) rows.push(evalColumn(project, c));
return rows; return rows;
} }
+93
View File
@@ -17,16 +17,25 @@ export const de = {
"view3d.group": "Ansicht 3D", "view3d.group": "Ansicht 3D",
"view3d.front": "Vorne", "view3d.front": "Vorne",
"view3d.front.hint": "Frontalansicht von vorne", "view3d.front.hint": "Frontalansicht von vorne",
"view3d.front.abbr": "V",
"view3d.back": "Hinten", "view3d.back": "Hinten",
"view3d.back.hint": "Rückansicht von hinten", "view3d.back.hint": "Rückansicht von hinten",
"view3d.back.abbr": "H",
"view3d.top": "Oben", "view3d.top": "Oben",
"view3d.top.hint": "Draufsicht (senkrecht von oben)", "view3d.top.hint": "Draufsicht (senkrecht von oben)",
"view3d.top.abbr": "O",
"view3d.side": "Rechts", "view3d.side": "Rechts",
"view3d.side.hint": "Rechte Seitenansicht (90° zur Front)", "view3d.side.hint": "Rechte Seitenansicht (90° zur Front)",
"view3d.side.abbr": "R",
"view3d.left": "Links", "view3d.left": "Links",
"view3d.left.hint": "Linke Seitenansicht (90° zur Front, gegenüber Rechts)", "view3d.left.hint": "Linke Seitenansicht (90° zur Front, gegenüber Rechts)",
"view3d.left.abbr": "L",
"view3d.perspective": "Perspektive", "view3d.perspective": "Perspektive",
"view3d.perspective.hint": "Freie Perspektive (3/4-Blick)", "view3d.perspective.hint": "Freie Perspektive (3/4-Blick)",
"topbar.views": "Ansichten",
"topbar.views.menu": "Ansicht wählen (Vorne/Hinten/Links/Rechts/Oben)",
"topbar.axo": "Axonometrie",
"topbar.axo.hint": "3D-Axonometrie (freier 3/4-Blick)",
"view3d.iso": "Isometrie", "view3d.iso": "Isometrie",
"view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)", "view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)",
"view3d.isoFrontLeft": "Vorne-Links", "view3d.isoFrontLeft": "Vorne-Links",
@@ -86,6 +95,7 @@ export const de = {
"display.style.shaded": "Schattiert", "display.style.shaded": "Schattiert",
"display.style.shadedEdges": "Schattiert mit Kanten", "display.style.shadedEdges": "Schattiert mit Kanten",
"display.style.white": "Weiss", "display.style.white": "Weiss",
"display.style.contour": "Nur Konturen",
"display.style.textured": "Texturiert", "display.style.textured": "Texturiert",
"display.style.wireframe": "Drahtgitter", "display.style.wireframe": "Drahtgitter",
"display.style.hidden": "Kanten", "display.style.hidden": "Kanten",
@@ -98,6 +108,41 @@ export const de = {
"topbar.resources": "Ressourcen", "topbar.resources": "Ressourcen",
"topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden", "topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden",
// ── Menüleiste (Topbar, AppMenuBar) ──────────────────────────────────────
"menu.edit": "Bearbeiten",
"menu.edit.undo": "Rückgängig",
"menu.edit.redo": "Wiederholen",
"menu.edit.delete": "Löschen",
"menu.view": "Ansicht",
"menu.view.floorplan": "Grundriss",
"menu.view.perspective": "Perspektive",
"menu.view.referenceLines": "Referenzlinien",
"menu.window": "Fenster",
"menu.window.resources": "Ressourcen",
"menu.window.settings": "Einstellungen",
"menu.window.drawingLevels": "Zeichnungsebenen (Fenster)",
"menu.window.layerSettings": "Ebeneneinstellungen (aktive Ebene)",
"menu.window.contextImport": "Standort importieren",
// ── Native macOS-Systemmenüleiste (appMenu.ts) — eigener Namensraum, da sie
// ausser Bearbeiten/Ansicht/Fenster (oben) auch Datei/Export/Transformieren
// zeigt, was die In-App-AppMenuBar nicht hat. ─────────────────────────────
"menu.native.file": "Datei",
"menu.native.open": "Öffnen …",
"menu.native.saveAs": "Speichern unter …",
"menu.native.import": "Importieren …",
"menu.native.export": "Exportieren",
"menu.native.exportSchedule": "Bauteilliste (CSV)",
"menu.native.edit": "Bearbeiten",
"menu.native.move": "Bewegen",
"menu.native.mirror": "Spiegeln",
"menu.native.rotate": "Drehen",
"menu.native.view": "Ansicht",
"menu.native.window": "Fenster",
"menu.native.panels": "Panels",
"menu.native.about": "Über Dossier",
"menu.native.quit": "Beenden",
// ── Layout-Menü (Topbar) ───────────────────────────────────────────────── // ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
"layout.title": "Arbeitsumgebung", "layout.title": "Arbeitsumgebung",
"layout.placeholder": "Arbeitsumgebung …", "layout.placeholder": "Arbeitsumgebung …",
@@ -148,6 +193,12 @@ export const de = {
"ribbon.group.components": "Bauteile", "ribbon.group.components": "Bauteile",
"ribbon.group.sections": "Schnitte", "ribbon.group.sections": "Schnitte",
"ribbon.group.custom": "Eigene Leiste", "ribbon.group.custom": "Eigene Leiste",
"tools.view.label": "Darstellung",
"tools.view.list": "Liste",
"tools.view.icons": "Symbole",
"tools.cat.2d": "2D",
"tools.cat.3d": "3D",
"tools.cat.edit": "Bearbeiten",
"ribbon.custom.add": " Hinzufügen", "ribbon.custom.add": " Hinzufügen",
"ribbon.custom.empty": "Elemente über Hinzufügen wählen", "ribbon.custom.empty": "Elemente über Hinzufügen wählen",
"ribbon.empty": "Noch keine Werkzeuge in diesem Tab", "ribbon.empty": "Noch keine Werkzeuge in diesem Tab",
@@ -292,6 +343,16 @@ export const de = {
"objinfo.kind.extrudedSolid": "Extrusion", "objinfo.kind.extrudedSolid": "Extrusion",
"objinfo.kind.column": "Stütze", "objinfo.kind.column": "Stütze",
"objinfo.kind.roof": "Dach", "objinfo.kind.roof": "Dach",
"objinfo.kind.contextObject": "Kontext-Objekt",
// ── Kontext-Objekt-Attribute (Object-Info-Panel) ────────────────────────
"objinfo.contextObject.section": "Georeferenzierung",
"objinfo.contextObject.type": "Art",
"objinfo.contextObject.type.importedMesh": "Importiertes Mesh",
"objinfo.contextObject.type.terrainMesh": "Gelände",
"objinfo.contextObject.type.contourSet": "Höhenlinien",
"objinfo.contextObject.lv95E": "LV95 Ost (E)",
"objinfo.contextObject.lv95N": "LV95 Nord (N)",
"objinfo.contextObject.noAnchor": "Kein Standort-Bezug im Projekt",
// ── Dach-Attribute (Object-Info-Panel) ────────────────────────────────── // ── Dach-Attribute (Object-Info-Panel) ──────────────────────────────────
"objinfo.roof.section": "Dach", "objinfo.roof.section": "Dach",
"objinfo.roof.shape": "Dachform", "objinfo.roof.shape": "Dachform",
@@ -501,6 +562,9 @@ export const de = {
"objinfo.stair.direction": "Laufrichtung", "objinfo.stair.direction": "Laufrichtung",
"objinfo.stair.direction.up": "Aufwärts", "objinfo.stair.direction.up": "Aufwärts",
"objinfo.stair.direction.down": "Abwärts", "objinfo.stair.direction.down": "Abwärts",
"objinfo.stair.arrow": "Pfeil",
"objinfo.stair.arrow.line": "Offen",
"objinfo.stair.arrow.filled": "Gefüllt",
"objinfo.stair.refFloor": "Referenzgeschoss", "objinfo.stair.refFloor": "Referenzgeschoss",
"objinfo.stair.bottom": "Unterkante (UK)", "objinfo.stair.bottom": "Unterkante (UK)",
"objinfo.stair.top": "Oberkante (OK)", "objinfo.stair.top": "Oberkante (OK)",
@@ -523,6 +587,7 @@ export const de = {
"objinfo.sectionLine.kind.elevation": "Ansicht", "objinfo.sectionLine.kind.elevation": "Ansicht",
"objinfo.sectionLine.name": "Name", "objinfo.sectionLine.name": "Name",
"objinfo.sectionLine.flip": "Blickrichtung umkehren", "objinfo.sectionLine.flip": "Blickrichtung umkehren",
"objinfo.sectionLine.cut3d": "Im 3D schneiden",
"objinfo.sectionLine.startX": "Start X", "objinfo.sectionLine.startX": "Start X",
"objinfo.sectionLine.startY": "Start Y", "objinfo.sectionLine.startY": "Start Y",
"objinfo.sectionLine.endX": "Ende X", "objinfo.sectionLine.endX": "Ende X",
@@ -557,6 +622,8 @@ export const de = {
"nav.show": "Einblenden", "nav.show": "Einblenden",
"nav.categoryVisible": "{name} sichtbar", "nav.categoryVisible": "{name} sichtbar",
"nav.categorySelect": "{name} als aktive Ebene wählen (Zeichen-Ziel)", "nav.categorySelect": "{name} als aktive Ebene wählen (Zeichen-Ziel)",
"nav.categorySettings": "Ebeneneinstellungen für {name}",
"nav.drawingLevelsWindow": "Zeichnungsebenen als eigenes Fenster öffnen",
// ── Badges (Art einer Zeichnungsebene) ─────────────────────────────────── // ── Badges (Art einer Zeichnungsebene) ───────────────────────────────────
"badge.floor": "Geschoss", "badge.floor": "Geschoss",
@@ -571,6 +638,7 @@ export const de = {
"display.active": "Nur aktive", "display.active": "Nur aktive",
"display.grey": "Andere grau", "display.grey": "Andere grau",
"display.grey_locked": "Andere grau & gesperrt", "display.grey_locked": "Andere grau & gesperrt",
"display.locked": "Andere gesperrt",
// ── Dock ───────────────────────────────────────────────────────────────── // ── Dock ─────────────────────────────────────────────────────────────────
"dock.resize": "Breite anpassen", "dock.resize": "Breite anpassen",
@@ -692,6 +760,7 @@ export const de = {
"resources.title": "Ressourcen", "resources.title": "Ressourcen",
"resources.label": "Ressourcen-Manager", "resources.label": "Ressourcen-Manager",
"resources.close": "Schließen", "resources.close": "Schließen",
"resources.loading": "Verbinde mit dem Hauptfenster …",
"resources.tab.components": "Bauteile", "resources.tab.components": "Bauteile",
"resources.tab.hatches": "Schraffuren", "resources.tab.hatches": "Schraffuren",
"resources.tab.lines": "Linien", "resources.tab.lines": "Linien",
@@ -774,6 +843,8 @@ export const de = {
"resources.field.frameWidth": "Rahmenbreite (m)", "resources.field.frameWidth": "Rahmenbreite (m)",
"resources.field.insetFromFace": "Schichteinzug (m)", "resources.field.insetFromFace": "Schichteinzug (m)",
"resources.field.insetFace": "Einzug ab Fläche", "resources.field.insetFace": "Einzug ab Fläche",
"resources.field.innerLayerRecess": "Laibungs-Rücksprung (innerste Schicht)",
"resources.field.innerLayerRecess.hint": "Die innerste Wandschicht (meist Gips/Holzverkleidung) weicht an diesem Fenster rundum um ihre eigene Dicke von der Laibung zurück, statt bündig mit den übrigen Schichten abzuschliessen — ändert die 3D-Wandgeometrie, nicht nur die Rahmenposition.",
"resources.field.transomHeight": "Oberlicht-Höhe (m)", "resources.field.transomHeight": "Oberlicht-Höhe (m)",
"resources.field.mullionRows": "Kämpfer-Zeilen", "resources.field.mullionRows": "Kämpfer-Zeilen",
"resources.frameKind.zarge": "Zarge", "resources.frameKind.zarge": "Zarge",
@@ -1145,9 +1216,15 @@ export const de = {
"site.contourSet": "Kontur-Satz", "site.contourSet": "Kontur-Satz",
"site.empty": "Kein Kontext geladen. Importiere eine DXF-Datei (Höhenlinien oder Mesh).", "site.empty": "Kein Kontext geladen. Importiere eine DXF-Datei (Höhenlinien oder Mesh).",
"site.delete": "Entfernen", "site.delete": "Entfernen",
"site.deleteLayer": "Ganze Ebene entfernen",
"site.group.imported": "Gebäude",
"site.group.terrain": "Terrain",
"site.group.contours": "Höhenlinien",
"site.type.mesh": "Mesh", "site.type.mesh": "Mesh",
"site.type.contours": "Konturen", "site.type.contours": "Konturen",
"site.type.terrain": "Gelände", "site.type.terrain": "Gelände",
"site.type.image": "Luftbild",
"site.group.image": "Luftbild",
"site.dwgHint": "DWG? Zuerst mit dem gratis ODA File Converter in DXF umwandeln.", "site.dwgHint": "DWG? Zuerst mit dem gratis ODA File Converter in DXF umwandeln.",
"site.terrainFailed": "Kein Gelände erzeugbar (zu wenige/kollineare Konturpunkte).", "site.terrainFailed": "Kein Gelände erzeugbar (zu wenige/kollineare Konturpunkte).",
@@ -1254,6 +1331,9 @@ export const de = {
"settings.title": "Einstellungen", "settings.title": "Einstellungen",
"settings.section.workspace": "Arbeitsumgebung", "settings.section.workspace": "Arbeitsumgebung",
"settings.section.display": "Darstellung", "settings.section.display": "Darstellung",
"settings.theme": "Erscheinungsbild",
"settings.theme.light": "Hell",
"settings.theme.dark": "Dunkel",
"settings.marqueeColor": "Auswahlrahmen-Farbe", "settings.marqueeColor": "Auswahlrahmen-Farbe",
"settings.marqueeColor.hint": "settings.marqueeColor.hint":
"Farbe des Auswahlrahmens beim Aufziehen einer Auswahl (Marquee) im Grundriss.", "Farbe des Auswahlrahmens beim Aufziehen einer Auswahl (Marquee) im Grundriss.",
@@ -1281,8 +1361,10 @@ export const de = {
"ctxImport.title": "Standort importieren", "ctxImport.title": "Standort importieren",
"ctxImport.close": "Schließen", "ctxImport.close": "Schließen",
"ctxImport.geoAnchor": "Georeferenziert ab: {label}", "ctxImport.geoAnchor": "Georeferenziert ab: {label}",
"ctxImport.geoAnchor.coords": "E {e} / N {n} / H {h}",
"ctxImport.geoAnchor.reset": "Zurücksetzen", "ctxImport.geoAnchor.reset": "Zurücksetzen",
"ctxImport.geoAnchor.reset.hint": "Löscht den Standort-Bezug — der nächste Import setzt ihn neu.", "ctxImport.geoAnchor.reset.hint": "Löscht den Standort-Bezug — der nächste Import setzt ihn neu.",
"ctxImport.rawHint": "Wird an den echten LV95-Koordinaten importiert (weit vom Nullpunkt). Danach mit „Neuer Bezugspunkt“ an einen bequemen Modellpunkt holen — der IFC-/DXF-Export schreibt trotzdem die echten Koordinaten.",
"ctxImport.location": "Ort / Adresse", "ctxImport.location": "Ort / Adresse",
"ctxImport.searchPlaceholder": "z. B. Bundesplatz Bern", "ctxImport.searchPlaceholder": "z. B. Bundesplatz Bern",
"ctxImport.search": "Suchen", "ctxImport.search": "Suchen",
@@ -1290,6 +1372,12 @@ export const de = {
"ctxImport.noResults": "Keine Treffer.", "ctxImport.noResults": "Keine Treffer.",
"ctxImport.searchError": "Suche fehlgeschlagen.", "ctxImport.searchError": "Suche fehlgeschlagen.",
"ctxImport.radius": "Radius", "ctxImport.radius": "Radius",
"ctxImport.refPoint": "Bezugspunkt (Modell-Nullpunkt)",
"ctxImport.refPoint.e": "LV95 Ost (E)",
"ctxImport.refPoint.n": "LV95 Nord (N)",
"ctxImport.refPoint.h": "Höhe (m ü. M.)",
"ctxImport.refPoint.hint": "Optional: reale Koordinate des Bezugspunkts (wie ArchiCADs Vermessungspunkt). Ost/Nord (LV95) legen fest, welcher Punkt im Modell auf (0,0) liegt; die Höhe (m ü. M.) wird informativ verankert und beim IFC-Export als Standort-Datumshöhe geschrieben. Leer = das Suchzentrum wird der Nullpunkt.",
"ctxImport.refPoint.anchorLabel": "Bezugspunkt {e} / {n}",
"ctxImport.sources": "Quellen", "ctxImport.sources": "Quellen",
"ctxImport.src.swissBuildings": "Swisstopo-Gebäude", "ctxImport.src.swissBuildings": "Swisstopo-Gebäude",
"ctxImport.buildings.off": "Aus", "ctxImport.buildings.off": "Aus",
@@ -1297,6 +1385,8 @@ export const de = {
"ctxImport.buildings.v2": "Echt 2.0 (stabil)", "ctxImport.buildings.v2": "Echt 2.0 (stabil)",
"ctxImport.buildings.v3": "Echt 3.0 (Beta)", "ctxImport.buildings.v3": "Echt 3.0 (Beta)",
"ctxImport.src.swissTerrain": "Swisstopo-Gelände", "ctxImport.src.swissTerrain": "Swisstopo-Gelände",
"ctxImport.src.swissImage": "SWISSIMAGE-Luftbild (2D-Hintergrund)",
"ctxImport.src.swissImageDrape": "Luftbild aufs Gelände legen (3D)",
"ctxImport.terrain.resolution": "Auflösung", "ctxImport.terrain.resolution": "Auflösung",
"ctxImport.terrain.res2": "2 m (schnell)", "ctxImport.terrain.res2": "2 m (schnell)",
"ctxImport.terrain.res05": "0.5 m (fein, grössere Kacheln)", "ctxImport.terrain.res05": "0.5 m (fein, grössere Kacheln)",
@@ -1379,6 +1469,7 @@ export const de = {
"viewport3d.section.label": "Schnittebene", "viewport3d.section.label": "Schnittebene",
"viewport3d.section.show": "Schnittebene ein", "viewport3d.section.show": "Schnittebene ein",
"viewport3d.section.hide": "Schnittebene aus", "viewport3d.section.hide": "Schnittebene aus",
"viewport3d.section.bound": "3D-Schnitt aufheben (an Schnittlinie gebunden)",
"viewport3d.grip.vertex": "Wandende ziehen", "viewport3d.grip.vertex": "Wandende ziehen",
"viewport3d.grip.height": "Höhe ziehen", "viewport3d.grip.height": "Höhe ziehen",
"viewport3d.grip.move": "Verschieben", "viewport3d.grip.move": "Verschieben",
@@ -1396,6 +1487,8 @@ export const de = {
"elements.kind.opening": "Öffnung", "elements.kind.opening": "Öffnung",
"elements.kind.stair": "Treppe", "elements.kind.stair": "Treppe",
"elements.kind.extrusion": "Extrusion", "elements.kind.extrusion": "Extrusion",
"elements.kind.roof": "Dach",
"elements.kind.column": "Stütze",
// Ausschnitte / View-Snapshots (DOSSIER A2). // Ausschnitte / View-Snapshots (DOSSIER A2).
"viewsnap.title": "Ausschnitte", "viewsnap.title": "Ausschnitte",
+92
View File
@@ -16,16 +16,25 @@ export const en: Record<TranslationKey, string> = {
"view3d.group": "3D view", "view3d.group": "3D view",
"view3d.front": "Front", "view3d.front": "Front",
"view3d.front.hint": "Front view (frontal)", "view3d.front.hint": "Front view (frontal)",
"view3d.front.abbr": "F",
"view3d.back": "Back", "view3d.back": "Back",
"view3d.back.hint": "Back view (rear)", "view3d.back.hint": "Back view (rear)",
"view3d.back.abbr": "B",
"view3d.top": "Top", "view3d.top": "Top",
"view3d.top.hint": "Top view (straight down)", "view3d.top.hint": "Top view (straight down)",
"view3d.top.abbr": "T",
"view3d.side": "Right", "view3d.side": "Right",
"view3d.side.hint": "Right side view (90° to the front)", "view3d.side.hint": "Right side view (90° to the front)",
"view3d.side.abbr": "R",
"view3d.left": "Left", "view3d.left": "Left",
"view3d.left.hint": "Left side view (90° to the front, opposite Right)", "view3d.left.hint": "Left side view (90° to the front, opposite Right)",
"view3d.left.abbr": "L",
"view3d.perspective": "Perspective", "view3d.perspective": "Perspective",
"view3d.perspective.hint": "Free perspective (3/4 view)", "view3d.perspective.hint": "Free perspective (3/4 view)",
"topbar.views": "Views",
"topbar.views.menu": "Pick a view (Front/Back/Left/Right/Top)",
"topbar.axo": "Axonometric",
"topbar.axo.hint": "3D axonometric (free 3/4 view)",
"view3d.iso": "Isometric", "view3d.iso": "Isometric",
"view3d.iso.hint": "Isometric view (equiangular from front-top-right)", "view3d.iso.hint": "Isometric view (equiangular from front-top-right)",
"view3d.isoFrontLeft": "Front Left", "view3d.isoFrontLeft": "Front Left",
@@ -85,6 +94,7 @@ export const en: Record<TranslationKey, string> = {
"display.style.shaded": "Shaded", "display.style.shaded": "Shaded",
"display.style.shadedEdges": "Shaded with edges", "display.style.shadedEdges": "Shaded with edges",
"display.style.white": "White", "display.style.white": "White",
"display.style.contour": "Contours only",
"display.style.textured": "Textured", "display.style.textured": "Textured",
"display.style.wireframe": "Wireframe", "display.style.wireframe": "Wireframe",
"display.style.hidden": "Hidden line", "display.style.hidden": "Hidden line",
@@ -97,6 +107,40 @@ export const en: Record<TranslationKey, string> = {
"topbar.resources": "Resources", "topbar.resources": "Resources",
"topbar.resources.hint": "Show/hide the resources window", "topbar.resources.hint": "Show/hide the resources window",
// Menu bar (Topbar, AppMenuBar).
"menu.edit": "Edit",
"menu.edit.undo": "Undo",
"menu.edit.redo": "Redo",
"menu.edit.delete": "Delete",
"menu.view": "View",
"menu.view.floorplan": "Floor plan",
"menu.view.perspective": "Perspective",
"menu.view.referenceLines": "Reference lines",
"menu.window": "Window",
"menu.window.resources": "Resources",
"menu.window.settings": "Settings",
"menu.window.drawingLevels": "Drawing Levels (Window)",
"menu.window.layerSettings": "Layer Settings (active layer)",
"menu.window.contextImport": "Import Location",
// Native macOS system menu bar (appMenu.ts) — separate namespace since it
// also shows File/Export/Transform, unlike the in-app AppMenuBar.
"menu.native.file": "File",
"menu.native.open": "Open…",
"menu.native.saveAs": "Save As…",
"menu.native.import": "Import…",
"menu.native.export": "Export",
"menu.native.exportSchedule": "Schedule (CSV)",
"menu.native.edit": "Edit",
"menu.native.move": "Move",
"menu.native.mirror": "Mirror",
"menu.native.rotate": "Rotate",
"menu.native.view": "View",
"menu.native.window": "Window",
"menu.native.panels": "Panels",
"menu.native.about": "About Dossier",
"menu.native.quit": "Quit",
// Layout menu. // Layout menu.
"layout.title": "Workspace", "layout.title": "Workspace",
"layout.placeholder": "Workspace …", "layout.placeholder": "Workspace …",
@@ -147,6 +191,12 @@ export const en: Record<TranslationKey, string> = {
"ribbon.group.components": "Components", "ribbon.group.components": "Components",
"ribbon.group.sections": "Sections", "ribbon.group.sections": "Sections",
"ribbon.group.custom": "Custom bar", "ribbon.group.custom": "Custom bar",
"tools.view.label": "Display",
"tools.view.list": "List",
"tools.view.icons": "Icons",
"tools.cat.2d": "2D",
"tools.cat.3d": "3D",
"tools.cat.edit": "Modify",
"ribbon.custom.add": " Add", "ribbon.custom.add": " Add",
"ribbon.custom.empty": "Pick elements via “+ Add”", "ribbon.custom.empty": "Pick elements via “+ Add”",
"ribbon.empty": "No tools in this tab yet", "ribbon.empty": "No tools in this tab yet",
@@ -291,6 +341,16 @@ export const en: Record<TranslationKey, string> = {
"objinfo.kind.extrudedSolid": "Extrusion", "objinfo.kind.extrudedSolid": "Extrusion",
"objinfo.kind.column": "Column", "objinfo.kind.column": "Column",
"objinfo.kind.roof": "Roof", "objinfo.kind.roof": "Roof",
"objinfo.kind.contextObject": "Context object",
// ── Context object attributes (object info panel) ───────────────────────
"objinfo.contextObject.section": "Georeferencing",
"objinfo.contextObject.type": "Type",
"objinfo.contextObject.type.importedMesh": "Imported mesh",
"objinfo.contextObject.type.terrainMesh": "Terrain",
"objinfo.contextObject.type.contourSet": "Contours",
"objinfo.contextObject.lv95E": "LV95 East (E)",
"objinfo.contextObject.lv95N": "LV95 North (N)",
"objinfo.contextObject.noAnchor": "No location reference in project",
// ── Roof attributes (object info panel) ───────────────────────────────── // ── Roof attributes (object info panel) ─────────────────────────────────
"objinfo.roof.section": "Roof", "objinfo.roof.section": "Roof",
"objinfo.roof.shape": "Roof shape", "objinfo.roof.shape": "Roof shape",
@@ -499,6 +559,9 @@ export const en: Record<TranslationKey, string> = {
"objinfo.stair.direction": "Run direction", "objinfo.stair.direction": "Run direction",
"objinfo.stair.direction.up": "Upwards", "objinfo.stair.direction.up": "Upwards",
"objinfo.stair.direction.down": "Downwards", "objinfo.stair.direction.down": "Downwards",
"objinfo.stair.arrow": "Arrow",
"objinfo.stair.arrow.line": "Open",
"objinfo.stair.arrow.filled": "Filled",
"objinfo.stair.refFloor": "Reference floor", "objinfo.stair.refFloor": "Reference floor",
"objinfo.stair.bottom": "Bottom (UK)", "objinfo.stair.bottom": "Bottom (UK)",
"objinfo.stair.top": "Top (OK)", "objinfo.stair.top": "Top (OK)",
@@ -520,6 +583,7 @@ export const en: Record<TranslationKey, string> = {
"objinfo.sectionLine.kind.elevation": "Elevation", "objinfo.sectionLine.kind.elevation": "Elevation",
"objinfo.sectionLine.name": "Name", "objinfo.sectionLine.name": "Name",
"objinfo.sectionLine.flip": "Flip view direction", "objinfo.sectionLine.flip": "Flip view direction",
"objinfo.sectionLine.cut3d": "Cut in 3D",
"objinfo.sectionLine.startX": "Start X", "objinfo.sectionLine.startX": "Start X",
"objinfo.sectionLine.startY": "Start Y", "objinfo.sectionLine.startY": "Start Y",
"objinfo.sectionLine.endX": "End X", "objinfo.sectionLine.endX": "End X",
@@ -553,6 +617,8 @@ export const en: Record<TranslationKey, string> = {
"nav.show": "Show", "nav.show": "Show",
"nav.categoryVisible": "{name} visible", "nav.categoryVisible": "{name} visible",
"nav.categorySelect": "Make {name} the active layer (drawing target)", "nav.categorySelect": "Make {name} the active layer (drawing target)",
"nav.categorySettings": "Layer settings for {name}",
"nav.drawingLevelsWindow": "Open drawing levels as its own window",
// Badges. // Badges.
"badge.floor": "Floor", "badge.floor": "Floor",
@@ -567,6 +633,7 @@ export const en: Record<TranslationKey, string> = {
"display.active": "Active only", "display.active": "Active only",
"display.grey": "Others grey", "display.grey": "Others grey",
"display.grey_locked": "Others grey & locked", "display.grey_locked": "Others grey & locked",
"display.locked": "Others locked",
// Dock. // Dock.
"dock.resize": "Adjust width", "dock.resize": "Adjust width",
@@ -687,6 +754,7 @@ export const en: Record<TranslationKey, string> = {
"resources.title": "Resources", "resources.title": "Resources",
"resources.label": "Resource manager", "resources.label": "Resource manager",
"resources.close": "Close", "resources.close": "Close",
"resources.loading": "Connecting to the main window…",
"resources.tab.components": "Components", "resources.tab.components": "Components",
"resources.tab.hatches": "Hatches", "resources.tab.hatches": "Hatches",
"resources.tab.lines": "Lines", "resources.tab.lines": "Lines",
@@ -769,6 +837,8 @@ export const en: Record<TranslationKey, string> = {
"resources.field.frameWidth": "Frame width (m)", "resources.field.frameWidth": "Frame width (m)",
"resources.field.insetFromFace": "Layer inset (m)", "resources.field.insetFromFace": "Layer inset (m)",
"resources.field.insetFace": "Inset from face", "resources.field.insetFace": "Inset from face",
"resources.field.innerLayerRecess": "Reveal recess (innermost layer)",
"resources.field.innerLayerRecess.hint": "The innermost wall layer (usually plasterboard/wood lining) steps back all around this window's reveal by its own thickness, instead of finishing flush with the other layers — changes the 3D wall geometry, not just the frame position.",
"resources.field.transomHeight": "Transom height (m)", "resources.field.transomHeight": "Transom height (m)",
"resources.field.mullionRows": "Transom rows", "resources.field.mullionRows": "Transom rows",
"resources.frameKind.zarge": "Lining", "resources.frameKind.zarge": "Lining",
@@ -1134,9 +1204,15 @@ export const en: Record<TranslationKey, string> = {
"site.contourSet": "Contour set", "site.contourSet": "Contour set",
"site.empty": "No context loaded. Import a DXF file (contour lines or mesh).", "site.empty": "No context loaded. Import a DXF file (contour lines or mesh).",
"site.delete": "Remove", "site.delete": "Remove",
"site.deleteLayer": "Remove entire layer",
"site.group.imported": "Buildings",
"site.group.terrain": "Terrain",
"site.group.contours": "Contours",
"site.type.mesh": "Mesh", "site.type.mesh": "Mesh",
"site.type.contours": "Contours", "site.type.contours": "Contours",
"site.type.terrain": "Terrain", "site.type.terrain": "Terrain",
"site.type.image": "Aerial",
"site.group.image": "Aerial imagery",
"site.dwgHint": "DWG? Convert it to DXF first with the free ODA File Converter.", "site.dwgHint": "DWG? Convert it to DXF first with the free ODA File Converter.",
"site.terrainFailed": "No terrain could be generated (too few/collinear contour points).", "site.terrainFailed": "No terrain could be generated (too few/collinear contour points).",
@@ -1242,6 +1318,9 @@ export const en: Record<TranslationKey, string> = {
"settings.title": "Settings", "settings.title": "Settings",
"settings.section.workspace": "Workspace", "settings.section.workspace": "Workspace",
"settings.section.display": "Display", "settings.section.display": "Display",
"settings.theme": "Appearance",
"settings.theme.light": "Light",
"settings.theme.dark": "Dark",
"settings.marqueeColor": "Selection box color", "settings.marqueeColor": "Selection box color",
"settings.marqueeColor.hint": "settings.marqueeColor.hint":
"Color of the marquee selection box drawn while dragging a selection in the plan view.", "Color of the marquee selection box drawn while dragging a selection in the plan view.",
@@ -1269,8 +1348,10 @@ export const en: Record<TranslationKey, string> = {
"ctxImport.title": "Import location", "ctxImport.title": "Import location",
"ctxImport.close": "Close", "ctxImport.close": "Close",
"ctxImport.geoAnchor": "Georeferenced from: {label}", "ctxImport.geoAnchor": "Georeferenced from: {label}",
"ctxImport.geoAnchor.coords": "E {e} / N {n} / H {h}",
"ctxImport.geoAnchor.reset": "Reset", "ctxImport.geoAnchor.reset": "Reset",
"ctxImport.geoAnchor.reset.hint": "Clears the location reference — the next import sets it again.", "ctxImport.geoAnchor.reset.hint": "Clears the location reference — the next import sets it again.",
"ctxImport.rawHint": "Imported at its real LV95 coordinates (far from the origin). Then use \"New reference point\" to bring it to a convenient model point — the IFC/DXF export still writes the real coordinates.",
"ctxImport.location": "Place / address", "ctxImport.location": "Place / address",
"ctxImport.searchPlaceholder": "e.g. Bundesplatz Bern", "ctxImport.searchPlaceholder": "e.g. Bundesplatz Bern",
"ctxImport.search": "Search", "ctxImport.search": "Search",
@@ -1278,6 +1359,12 @@ export const en: Record<TranslationKey, string> = {
"ctxImport.noResults": "No matches.", "ctxImport.noResults": "No matches.",
"ctxImport.searchError": "Search failed.", "ctxImport.searchError": "Search failed.",
"ctxImport.radius": "Radius", "ctxImport.radius": "Radius",
"ctxImport.refPoint": "Reference point (model origin)",
"ctxImport.refPoint.e": "LV95 East (E)",
"ctxImport.refPoint.n": "LV95 North (N)",
"ctxImport.refPoint.h": "Elevation (m a.s.l.)",
"ctxImport.refPoint.hint": "Optional: real coordinate of the reference point (like ArchiCAD's survey point). East/North (LV95) set which model point maps to (0,0); the elevation (m a.s.l.) is anchored informationally and written as the site datum elevation on IFC export. Empty = the search centre becomes the origin.",
"ctxImport.refPoint.anchorLabel": "Reference point {e} / {n}",
"ctxImport.sources": "Sources", "ctxImport.sources": "Sources",
"ctxImport.src.swissBuildings": "Swisstopo buildings", "ctxImport.src.swissBuildings": "Swisstopo buildings",
"ctxImport.buildings.off": "Off", "ctxImport.buildings.off": "Off",
@@ -1285,6 +1372,8 @@ export const en: Record<TranslationKey, string> = {
"ctxImport.buildings.v2": "Real 2.0 (stable)", "ctxImport.buildings.v2": "Real 2.0 (stable)",
"ctxImport.buildings.v3": "Real 3.0 (Beta)", "ctxImport.buildings.v3": "Real 3.0 (Beta)",
"ctxImport.src.swissTerrain": "Swisstopo terrain", "ctxImport.src.swissTerrain": "Swisstopo terrain",
"ctxImport.src.swissImage": "SWISSIMAGE aerial (2D backdrop)",
"ctxImport.src.swissImageDrape": "Drape aerial onto terrain (3D)",
"ctxImport.terrain.resolution": "Resolution", "ctxImport.terrain.resolution": "Resolution",
"ctxImport.terrain.res2": "2 m (fast)", "ctxImport.terrain.res2": "2 m (fast)",
"ctxImport.terrain.res05": "0.5 m (fine, larger tiles)", "ctxImport.terrain.res05": "0.5 m (fine, larger tiles)",
@@ -1367,6 +1456,7 @@ export const en: Record<TranslationKey, string> = {
"viewport3d.section.label": "Section plane", "viewport3d.section.label": "Section plane",
"viewport3d.section.show": "Section plane on", "viewport3d.section.show": "Section plane on",
"viewport3d.section.hide": "Section plane off", "viewport3d.section.hide": "Section plane off",
"viewport3d.section.bound": "Clear 3D section (bound to section line)",
"viewport3d.grip.vertex": "Drag wall endpoint", "viewport3d.grip.vertex": "Drag wall endpoint",
"viewport3d.grip.edge": "Drag edge", "viewport3d.grip.edge": "Drag edge",
"viewport3d.grip.height": "Drag height", "viewport3d.grip.height": "Drag height",
@@ -1384,6 +1474,8 @@ export const en: Record<TranslationKey, string> = {
"elements.kind.opening": "Opening", "elements.kind.opening": "Opening",
"elements.kind.stair": "Stair", "elements.kind.stair": "Stair",
"elements.kind.extrusion": "Extrusion", "elements.kind.extrusion": "Extrusion",
"elements.kind.roof": "Roof",
"elements.kind.column": "Column",
// Views / view snapshots (DOSSIER A2). // Views / view snapshots (DOSSIER A2).
"viewsnap.title": "Views", "viewsnap.title": "Views",
+141 -1
View File
@@ -5,7 +5,13 @@
// `swissTopo.ts::fetchBuildings`/`geocode` (kein Fetch-Mocking im Repo). // `swissTopo.ts::fetchBuildings`/`geocode` (kein Fetch-Mocking im Repo).
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { shiftMeshToOrigin, clipMeshToBbox } from "./swissBuildings3d"; import {
shiftMeshToOrigin,
clipMeshToBbox,
splitMeshComponents,
stripOverhangSoffits,
BUILDING_LAYER,
} from "./swissBuildings3d";
import type { ImportedMesh } from "../model/types"; import type { ImportedMesh } from "../model/types";
describe("shiftMeshToOrigin", () => { describe("shiftMeshToOrigin", () => {
@@ -79,3 +85,137 @@ describe("clipMeshToBbox", () => {
expect(clipped.indices).toEqual([0, 1, 2]); expect(clipped.indices).toEqual([0, 1, 2]);
}); });
}); });
describe("splitMeshComponents", () => {
// Zwei räumlich getrennte Dreiecke (kein gemeinsamer Vertex) = zwei Gebäude.
const twoBuildings: ImportedMesh = {
id: "m1",
type: "importedMesh",
name: "Kachel",
positions: [
0, 0, 0, 1, 0, 0, 0, 1, 0, // Gebäude A
100, 100, 0, 101, 100, 0, 100, 101, 0, // Gebäude B (weit weg)
],
indices: [0, 1, 2, 3, 4, 5],
};
it("trennt zwei unverbundene Dreiecke in zwei Meshes mit eigener Id + Ebene", () => {
const parts = splitMeshComponents(twoBuildings);
expect(parts.length).toBe(2);
expect(parts[0].id).not.toBe(parts[1].id);
for (const p of parts) {
expect(p.layer).toBe(BUILDING_LAYER);
expect(p.indices.length).toBe(3); // je ein Dreieck
expect(p.positions.length).toBe(9); // kompaktiert: nur die 3 eigenen Vertices
// Indizes sind neu gemappt (0..2), zeigen nicht mehr auf die alten Globalindizes.
expect(Math.max(...p.indices)).toBeLessThan(3);
}
});
it("verschweisst geteilte Eckpunkte: zwei Dreiecke mit gemeinsamer Kante bleiben EIN Gebäude", () => {
// Zwei Dreiecke teilen die Kante (1,0)-(0,1), aber über DUPLIZIERTE Vertices
// (getrennte Indizes, gleiche Koordinaten) — das Welding muss sie verbinden.
const shared: ImportedMesh = {
id: "m2",
type: "importedMesh",
name: "Haus",
positions: [
0, 0, 0, 1, 0, 0, 0, 1, 0, // Dreieck 1: (0,0),(1,0),(0,1)
1, 0, 0, 0, 1, 0, 1, 1, 0, // Dreieck 2: (1,0),(0,1),(1,1) — dupl. Ecken
],
indices: [0, 1, 2, 3, 4, 5],
};
const parts = splitMeshComponents(shared);
expect(parts.length).toBe(1);
expect(parts[0].indices.length).toBe(6);
});
it("ein einzelnes zusammenhängendes Mesh bleibt ein Mesh (bekommt aber die Ebene)", () => {
const one: ImportedMesh = {
id: "m3",
type: "importedMesh",
name: "Solo",
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
indices: [0, 1, 2],
};
const parts = splitMeshComponents(one);
expect(parts.length).toBe(1);
expect(parts[0].layer).toBe(BUILDING_LAYER);
});
it("leeres/entartetes Mesh -> leeres Ergebnis (kein Crash)", () => {
const empty: ImportedMesh = { id: "e", type: "importedMesh", name: "x", positions: [], indices: [] };
expect(splitMeshComponents(empty)).toEqual([]);
});
});
describe("stripOverhangSoffits", () => {
// Sockel (z=0) + Soffit/Wandkopf-Ring (z=2.5, EINE Dreieck-Fläche zeigt nach
// unten = Überstand-Untersicht) + Dach (z=3, nach oben) + zwei Wanddreiecke
// (senkrecht, nz≈0). Simuliert einen minimalen swissBUILDINGS3D-Baukörper mit
// einer sichtbaren Dachüberstand-Untersicht (Nutzer-Report: "man sieht die
// Wände von unten").
it("entfernt eine nach unten zeigende Überstand-Untersicht oberhalb der Grundmauer", () => {
const mesh: ImportedMesh = {
id: "m1",
type: "importedMesh",
name: "Haus",
positions: [
0, 0, 0, // 0: Sockel
1, 0, 0, // 1: Sockel
0, 1, 0, // 2: Sockel
0, 0, 2.5, // 3: Wandkopf
1, 0, 2.5, // 4: Wandkopf
0, 1, 2.5, // 5: Wandkopf (nur vom Soffit referenziert)
0, 0, 3, // 6: Dach
1, 0, 3, // 7: Dach
0, 1, 3, // 8: Dach
],
indices: [
0, 1, 2, // Sockel-Deckel (bleibt immer)
3, 5, 4, // Soffit (Normale zeigt nach unten, nz=-1) -> entfernt
6, 7, 8, // Dach (Normale zeigt nach oben, nz=+1) -> bleibt
0, 1, 4, // Wand (senkrecht, nz≈0) -> bleibt
0, 4, 3, // Wand (senkrecht, nz≈0) -> bleibt
],
};
const out = stripOverhangSoffits(mesh);
// 5 Dreiecke -> 4 (Soffit weg) = 12 Indizes.
expect(out.indices.length).toBe(12);
// Vertex 5 wird nur vom Soffit referenziert -> nach Kompaktierung weg
// (8 statt 9 Vertices).
expect(out.positions.length).toBe(24);
});
it("lässt ein Mesh ohne Soffiten unverändert", () => {
const mesh: ImportedMesh = {
id: "m1",
type: "importedMesh",
name: "Dreieck",
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
indices: [0, 1, 2],
};
const out = stripOverhangSoffits(mesh);
expect(out.indices).toEqual([0, 1, 2]);
expect(out.positions).toEqual(mesh.positions);
});
it("behält den Boden-/Sockel-Deckel unabhängig von seiner Windung", () => {
// Ein Dreieck exakt auf der Grundmauer-Höhe mit nach-unten-Windung -> die
// Boden-Ausnahme greift VOR der Normalen-Prüfung, bleibt also erhalten.
const mesh: ImportedMesh = {
id: "m1",
type: "importedMesh",
name: "Sockel",
positions: [0, 0, 0, 0, 1, 0, 1, 0, 0], // a,c,b-Reihenfolge -> nz=-1
indices: [0, 1, 2],
};
const out = stripOverhangSoffits(mesh);
expect(out.indices.length).toBe(3);
});
it("leeres/entartetes Mesh -> unverändert (kein Crash)", () => {
const empty: ImportedMesh = { id: "e", type: "importedMesh", name: "x", positions: [], indices: [] };
expect(stripOverhangSoffits(empty)).toBe(empty);
});
});
+191 -1
View File
@@ -105,6 +105,189 @@ export function clipMeshToBbox(
return { ...mesh, positions, indices }; return { ...mesh, positions, indices };
} }
/** Sichtbarer Ebenen-/Gruppierungsname der importierten Gebäude (SitePanel). */
export const BUILDING_LAYER = "Gebäude";
/**
* Zerlegt ein Mesh in seine räumlich ZUSAMMENHÄNGENDEN Komponenten (Union-Find
* über verschweisste Vertices) je Komponente ein eigenes {@link ImportedMesh}.
* So wird eine swissBUILDINGS3D-Kachel (ein Mesh mit ALLEN Gebäuden, s.
* `dxfParser.ts::parseDxf`) in EINZELNE, getrennt anwähl-/löschbare Gebäude
* aufgeteilt (Nutzer-Wunsch). Vertices werden per quantisierter Position
* (`weldMm`) verschweisst, damit Dreiecke, die sich einen Eckpunkt teilen, aber
* getrennte Indizes tragen (typisch für 3DFACE-DXF), als verbunden gelten. Jede
* Komponente bekommt eine eigene Id + fortlaufenden Namen + `layer`. Rein (kein
* Netzwerk), exportiert für Unit-Tests.
*/
export function splitMeshComponents(mesh: ImportedMesh, weldMm = 1): ImportedMesh[] {
const pos = mesh.positions;
const vcount = Math.floor(pos.length / 3);
const idx = mesh.indices;
if (vcount < 3 || idx.length < 3) return idx.length >= 3 ? [mesh] : [];
// 1) Weld: gleiche (quantisierte) Position -> gemeinsamer kanonischer Index.
const q = weldMm / 1000; // Meter
const weldMap = new Map<string, number>();
const canonical = new Int32Array(vcount);
for (let i = 0; i < vcount; i++) {
const key = `${Math.round(pos[i * 3] / q)},${Math.round(pos[i * 3 + 1] / q)},${Math.round(pos[i * 3 + 2] / q)}`;
let c = weldMap.get(key);
if (c === undefined) {
c = i;
weldMap.set(key, i);
}
canonical[i] = c;
}
// 2) Union-Find über die kanonischen Vertices via Dreieckskanten.
const parent = new Int32Array(vcount);
for (let i = 0; i < vcount; i++) parent[i] = i;
const find = (x: number): number => {
let r = x;
while (parent[r] !== r) r = parent[r];
while (parent[x] !== r) {
const next = parent[x];
parent[x] = r;
x = next;
}
return r;
};
const union = (a: number, b: number) => {
const ra = find(a);
const rb = find(b);
if (ra !== rb) parent[ra] = rb;
};
for (let i = 0; i + 2 < idx.length; i += 3) {
const a = idx[i], b = idx[i + 1], c = idx[i + 2];
if (a >= vcount || b >= vcount || c >= vcount) continue;
union(canonical[a], canonical[b]);
union(canonical[b], canonical[c]);
}
// 3) Dreiecke nach Komponenten-Wurzel gruppieren.
const groups = new Map<number, number[]>();
for (let i = 0; i + 2 < idx.length; i += 3) {
const a = idx[i], b = idx[i + 1], c = idx[i + 2];
if (a >= vcount || b >= vcount || c >= vcount) continue;
const root = find(canonical[a]);
let g = groups.get(root);
if (!g) {
g = [];
groups.set(root, g);
}
g.push(a, b, c);
}
if (groups.size <= 1) return [{ ...mesh, layer: mesh.layer ?? BUILDING_LAYER }];
// 4) Je Komponente ein eigenes Mesh (Positions kompaktiert, Indizes neu gemappt).
const out: ImportedMesh[] = [];
let n = 0;
for (const tri of groups.values()) {
const remap = new Map<number, number>();
const positions: number[] = [];
const indices: number[] = [];
for (const old of tri) {
let ni = remap.get(old);
if (ni === undefined) {
ni = positions.length / 3;
positions.push(pos[old * 3], pos[old * 3 + 1], pos[old * 3 + 2]);
remap.set(old, ni);
}
indices.push(ni);
}
out.push({
...mesh,
id: `${mesh.id}-b${n}`,
name: `${mesh.name} #${n + 1}`,
layer: mesh.layer ?? BUILDING_LAYER,
positions,
indices,
});
n++;
}
return out;
}
/** Ab welcher Höhe (Meter) über der Grundmauer ein Dreieck NICHT mehr als
* Boden-/Sockel-Deckel gilt (siehe {@link stripOverhangSoffits}). */
const BASE_CAP_TOL = 0.05;
/** Normalen-Höhenkomponente (z, Modell-Hoch-Achse): darunter gilt ein Dreieck
* als zeigt deutlich nach unten" (Soffit-Kandidat). cos(70°) 0.34 nur
* weitgehend WAAGRECHTE Unterseiten, keine steilen Dachflächen. */
const SOFFIT_DOWN_THRESHOLD = -0.35;
/**
* Entfernt Dachüberstand-Untersichten (Soffiten) aus einem swissBUILDINGS3D-
* Mesh: Dreiecke, deren Fläche deutlich nach UNTEN zeigt UND nicht auf
* Grundmauer-Höhe liegt (kein Boden-/Sockel-Deckel). Solche Flächen sind reine
* LOD2-Detailtreue (die Untersicht eines Dachüberstands) im Konturen-
* Anzeigemodus fallen sie als zusätzliche man sieht die Wand von unten"-
* Silhouettenkanten auf (Nutzer-Report), für den gewünschten Gipsmodell-/
* Massenmodell-Look unerwünscht.
*
* ANNAHME: die DXF-Quelle liefert pro Gebäude ein KONSISTENT (aussen = positive
* Hoch-Komponente) gewundenes Solid (Standard bei swisstopo-LOD2 die
* doppelseitige Darstellung in render3d ist eine reine GPU-Vorsichtsmassnahme
* gegen Einzelfälle, keine Aussage über zufällige Windung). Bei inkonsistenter
* Windung könnte dies vereinzelt eine echte Dachfläche statt einer Soffite
* entfernen visuell in Tauri zu verifizieren.
*
* `positions` wird KOMPAKTIERT (analog `clipMeshToBbox`). Rein (kein Netzwerk),
* exportiert für Unit-Tests.
*/
export function stripOverhangSoffits(mesh: ImportedMesh): ImportedMesh {
const pos = mesh.positions;
const idx = mesh.indices;
if (idx.length < 3 || pos.length < 9) return mesh;
let minZ = Infinity;
for (let i = 2; i < pos.length; i += 3) {
if (pos[i] < minZ) minZ = pos[i];
}
const keptTris: [number, number, number][] = [];
for (let i = 0; i + 2 < idx.length; i += 3) {
const a = idx[i];
const b = idx[i + 1];
const c = idx[i + 2];
const ax = pos[a * 3], ay = pos[a * 3 + 1], az = pos[a * 3 + 2];
const bx = pos[b * 3], by = pos[b * 3 + 1], bz = pos[b * 3 + 2];
const cx = pos[c * 3], cy = pos[c * 3 + 1], cz = pos[c * 3 + 2];
// Boden-/Sockel-Deckel (alle drei Ecken nahe der Grundmauer-Höhe) bleibt
// in jedem Fall erhalten — kein Soffit-Kandidat.
const isBaseCap =
az <= minZ + BASE_CAP_TOL && bz <= minZ + BASE_CAP_TOL && cz <= minZ + BASE_CAP_TOL;
if (isBaseCap) {
keptTris.push([a, b, c]);
continue;
}
// Flächennormale (Modell x,y,z=Höhe): z-Komponente = "nach oben/unten".
const ux = bx - ax, uy = by - ay, uz = bz - az;
const vx = cx - ax, vy = cy - ay, vz = cz - az;
const nx = uy * vz - uz * vy;
const ny = uz * vx - ux * vz;
const nz = ux * vy - uy * vx;
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (len > 1e-9 && nz / len < SOFFIT_DOWN_THRESHOLD) continue; // Soffit: weg
keptTris.push([a, b, c]);
}
const remap = new Map<number, number>();
const positions: number[] = [];
const indices: number[] = [];
const mapVertex = (old: number): number => {
let ni = remap.get(old);
if (ni === undefined) {
ni = positions.length / 3;
positions.push(pos[old * 3], pos[old * 3 + 1], pos[old * 3 + 2]);
remap.set(old, ni);
}
return ni;
};
for (const [a, b, c] of keptTris) indices.push(mapVertex(a), mapVertex(b), mapVertex(c));
return { ...mesh, positions, indices };
}
export interface FetchBuildings3dResult { export interface FetchBuildings3dResult {
origin: GeoOrigin; origin: GeoOrigin;
meshes: ImportedMesh[]; meshes: ImportedMesh[];
@@ -169,7 +352,14 @@ export async function fetchBuildings3d(
const parsed = parseDxf(text); const parsed = parseDxf(text);
for (const m of parsed.meshes) { for (const m of parsed.meshes) {
const clipped = clipMeshToBbox(shiftMeshToOrigin(m, origin, item.id), clipBoxLocal); const clipped = clipMeshToBbox(shiftMeshToOrigin(m, origin, item.id), clipBoxLocal);
if (clipped.indices.length > 0) meshes.push(clipped); if (clipped.indices.length < 3) continue;
// In EINZELNE Gebäude aufteilen (eine Kachel = ein Mesh mit allen
// Häusern) — so ist jedes Haus getrennt anwähl-/löschbar. Je Gebäude
// die Dachüberstand-Untersichten (Soffiten) entfernen (Gipsmodell-Look,
// s. stripOverhangSoffits-Moduldoc).
for (const b of splitMeshComponents(clipped)) {
meshes.push(stripOverhangSoffits(b));
}
} }
} }
return { meshes, skipped }; return { meshes, skipped };
+73
View File
@@ -0,0 +1,73 @@
// Reine (netzwerkfreie) Bausteine des SWISSIMAGE-Imports: WMS-URL-Bau +
// Pixelgrössen-Deckelung. Der Fetch selbst bleibt ungetestet (kein Fetch-
// Mocking im Repo, analog swissTopo/swissAlti3d).
import { describe, it, expect } from "vitest";
import {
buildSwissImageUrl,
imagePixelSize,
sampleColorsFromImageData,
} from "./swissImage";
describe("buildSwissImageUrl", () => {
it("baut eine WMS-1.1.1-GetMap-URL mit EPSG:2056 und E,N-bbox-Reihenfolge", () => {
const url = buildSwissImageUrl([2600000, 1200000, 2600400, 1200400], 2000, 2000);
expect(url).toContain("SERVICE=WMS");
expect(url).toContain("VERSION=1.1.1");
expect(url).toContain("REQUEST=GetMap");
expect(url).toContain("LAYERS=ch.swisstopo.swissimage");
expect(url).toContain("SRS=EPSG%3A2056");
// bbox in E,N: eMin,nMin,eMax,nMax.
expect(url).toContain("BBOX=2600000%2C1200000%2C2600400%2C1200400");
expect(url).toContain("WIDTH=2000");
expect(url).toContain("HEIGHT=2000");
expect(url).toContain("FORMAT=image%2Fjpeg");
});
});
describe("imagePixelSize", () => {
it("skaliert mit der Spannweite (~0.2 m/px)", () => {
expect(imagePixelSize(400)).toBe(2000); // 400 m / 0.2
});
it("deckelt bei der Maximalgrösse", () => {
expect(imagePixelSize(100000)).toBe(2500);
});
it("hat eine sinnvolle Untergrenze", () => {
expect(imagePixelSize(1)).toBe(64);
});
});
describe("sampleColorsFromImageData (Luftbild-Drape)", () => {
// 2×2-Bild: oben-links rot, oben-rechts grün, unten-links blau, unten-rechts weiss.
// Reihenfolge im RGBA-Puffer ist zeilenweise von OBEN (Zeile 0 = Nord/+Y).
const W = 2;
const H = 2;
const px = new Uint8ClampedArray([
255, 0, 0, 255, /* (0,0) rot */ 0, 255, 0, 255, /* (1,0) grün */
0, 0, 255, 255, /* (0,1) blau */ 255, 255, 255, 255, /* (1,1) weiss */
]);
const min = { x: 0, y: 0 };
const max = { x: 10, y: 10 };
it("mappt die Modell-Ecken auf die richtigen Bildpixel (Nord = Bild-oben)", () => {
// NW (x=0,y=10) -> Bild oben-links = rot; NE (10,10) -> oben-rechts = grün;
// SW (0,0) -> unten-links = blau; SE (10,0) -> unten-rechts = weiss.
const positions = [
0, 10, 0, // NW
10, 10, 0, // NE
0, 0, 0, // SW
10, 0, 0, // SE
];
const c = sampleColorsFromImageData(px, W, H, positions, min, max);
expect(c.slice(0, 3)).toEqual([1, 0, 0]); // rot
expect(c.slice(3, 6)).toEqual([0, 1, 0]); // grün
expect(c.slice(6, 9)).toEqual([0, 0, 1]); // blau
expect(c.slice(9, 12)).toEqual([1, 1, 1]); // weiss
});
it("klemmt Vertices ausserhalb der Bildbox auf den Randpixel", () => {
const c = sampleColorsFromImageData(px, W, H, [-100, 100, 0], min, max);
// x<min, y>max -> u=0, v=1 -> Bild oben-links = rot.
expect(c).toEqual([1, 0, 0]);
});
});
+216
View File
@@ -0,0 +1,216 @@
// SWISSIMAGE-Luftbild (swisstopo WMS) für eine LV95-bbox → georeferenziertes
// Rasterbild (Data-URL + Modell-Meter-Bounding-Box). Wird als Hintergrund-
// Ebene in den Grundriss gelegt (Kontext-Schicht, `ContextImage`).
//
// Bezug via WMS 1.1.1 GetMap (EPSG:2056, eindeutige E,N-Achsreihenfolge) auf
// `ch.swisstopo.swissimage` — dieselbe offene geo.admin-Infrastruktur wie die
// übrigen Importe. Das Bild wird als Blob geladen und in eine Data-URL gewandelt,
// damit es (wie die Bild-Schraffuren) ohne externen Host im Projekt/Export lebt.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
import { bboxAround, makeOrigin } from "./lv95";
import type { GeoOrigin, LV95 } from "./lv95";
import { viaProxy } from "./geoContext";
const WMS = "https://wms.geo.admin.ch/";
const LAYER = "ch.swisstopo.swissimage";
/** Ziel-Bodenauflösung (Meter/Pixel) — SWISSIMAGE liegt bei ~0.10.25 m/px. */
const TARGET_MPP = 0.2;
/** Max. Kantenlänge des angeforderten Bildes (Pixel) — WMS-Server-Limit + Speicher. */
const MAX_PX = 2500;
/** Ergebnis eines SWISSIMAGE-Abrufs. */
export interface FetchSwissImageResult {
origin: GeoOrigin;
/** Data-URL (JPEG) oder null (kein Bild / Fehler). */
dataUrl: string | null;
/** Modell-Meter-Bounding-Box, die das Bild ausfüllt (aus `origin` abgeleitet). */
min: { x: number; y: number };
max: { x: number; y: number };
}
/**
* Baut die WMS-1.1.1-GetMap-URL für eine LV95-bbox (Reihenfolge E,N) rein,
* für Unit-Tests exportiert.
*/
export function buildSwissImageUrl(
bboxLv95: [number, number, number, number],
widthPx: number,
heightPx: number,
): string {
const [eMin, nMin, eMax, nMax] = bboxLv95;
const params = new URLSearchParams({
SERVICE: "WMS",
VERSION: "1.1.1",
REQUEST: "GetMap",
LAYERS: LAYER,
STYLES: "",
SRS: "EPSG:2056",
BBOX: `${eMin},${nMin},${eMax},${nMax}`,
WIDTH: String(widthPx),
HEIGHT: String(heightPx),
FORMAT: "image/jpeg",
});
return `${WMS}?${params.toString()}`;
}
/** Pixelmaße für eine bbox-Spannweite (Meter) bei {@link TARGET_MPP}, gedeckelt. */
export function imagePixelSize(spanMeters: number): number {
const px = Math.round(spanMeters / TARGET_MPP);
return Math.max(64, Math.min(MAX_PX, px));
}
/**
* Bytes base64 (chunked `btoa`) umgeht `FileReader` (nicht in jeder
* Umgebung vorhanden) und die Argument-Längengrenze von `String.fromCharCode`.
*/
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
/** ArrayBuffer → Data-URL des Bildes. */
function bufferToDataUrl(buf: ArrayBuffer, mime: string): string {
return `data:${mime};base64,${bytesToBase64(new Uint8Array(buf))}`;
}
/**
* Sampelt für jeden Mesh-Vertex die Bildfarbe an seiner Lage die reine
* (testbare) Kernrechnung des Luftbild-Drapierens" aufs Gelände-TIN. `positions`
* ist das flache Modell-Array `[x,y,z, …]` (z = Höhe, ignoriert); `min`/`max` die
* Modell-Meter-Bounding-Box, die das Bild ausfüllt (Bild-Oben = +Y/Nord). Liefert
* eine flache Pro-Vertex-Albedo `[r,g,b, …]` (0..1). Vertices ausserhalb der Box
* klemmen auf den Randpixel.
*/
export function sampleColorsFromImageData(
data: Uint8ClampedArray | Uint8Array,
width: number,
height: number,
positions: readonly number[],
min: { x: number; y: number },
max: { x: number; y: number },
): number[] {
const spanX = max.x - min.x || 1;
const spanY = max.y - min.y || 1;
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const out: number[] = [];
for (let i = 0; i + 2 < positions.length; i += 3) {
const u = clamp01((positions[i] - min.x) / spanX);
// Bildzeile 0 (oben) entspricht Nord (+Y) -> v=1; daher (1 - v) spiegeln.
const v = clamp01((positions[i + 1] - min.y) / spanY);
const px = Math.min(width - 1, Math.max(0, Math.round(u * (width - 1))));
const py = Math.min(height - 1, Math.max(0, Math.round((1 - v) * (height - 1))));
const idx = (py * width + px) * 4;
out.push(data[idx] / 255, data[idx + 1] / 255, data[idx + 2] / 255);
}
return out;
}
/**
* Dekodiert eine Bild-Data-URL und sampelt daraus die Pro-Vertex-Albedo für ein
* Gelände-TIN (siehe {@link sampleColorsFromImageData}). Nutzt `createImageBitmap`
* + `OffscreenCanvas` (im App-/WebView-Kontext vorhanden; in Node/Tests fehlen sie
* dann `null`, kein Wurf). So bleibt der Render-Pfad synchron: gesampelt wird
* EINMAL beim Import, das Ergebnis lebt auf `TerrainMesh.vertexColors`.
*/
export async function sampleTerrainColors(
dataUrl: string,
positions: readonly number[],
min: { x: number; y: number },
max: { x: number; y: number },
): Promise<number[] | null> {
if (
typeof createImageBitmap !== "function" ||
typeof OffscreenCanvas !== "function"
) {
return null;
}
try {
const res = await fetch(dataUrl);
const blob = await res.blob();
const bmp = await createImageBitmap(blob);
const canvas = new OffscreenCanvas(bmp.width, bmp.height);
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(bmp, 0, 0);
const img = ctx.getImageData(0, 0, bmp.width, bmp.height);
return sampleColorsFromImageData(img.data, bmp.width, bmp.height, positions, min, max);
} catch {
return null;
}
}
/** Dekodiertes Bild: dichte RGBA8-Bytes + Maße (zeilenweise von oben). */
export interface DecodedImage {
data: Uint8Array;
width: number;
height: number;
}
/**
* Dekodiert eine Bild-Data-URL zu dichten RGBA8-Bytes (für den WASM-Textur-Upload
* beim 3D-Luftbild-Drapieren, `WebModelRenderer.set_aerial_texture`). Nutzt
* `createImageBitmap` + `OffscreenCanvas` (App-/WebView-Kontext; in Node/Tests
* fehlen sie `null`, kein Wurf).
*/
export async function decodeImageToRgba(dataUrl: string): Promise<DecodedImage | null> {
if (
typeof createImageBitmap !== "function" ||
typeof OffscreenCanvas !== "function"
) {
return null;
}
try {
const res = await fetch(dataUrl);
const blob = await res.blob();
const bmp = await createImageBitmap(blob);
const canvas = new OffscreenCanvas(bmp.width, bmp.height);
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(bmp, 0, 0);
const img = ctx.getImageData(0, 0, bmp.width, bmp.height);
return {
data: new Uint8Array(img.data.buffer.slice(0)),
width: bmp.width,
height: bmp.height,
};
} catch {
return null;
}
}
/**
* Lädt ein SWISSIMAGE-Luftbild für eine LV95-bbox (Zentrum + Radius) und liefert
* es als Data-URL samt Modell-Meter-Bounding-Box. `originIn` wiederverwenden
* (wie die übrigen Importe), damit das Bild lagerichtig zu Gebäuden/Terrain
* liegt. Wirft nicht bei Netz-/Serverfehler ist `dataUrl` null.
*/
export async function fetchSwissImage(
center: LV95,
radius: number,
originIn?: GeoOrigin,
): Promise<FetchSwissImageResult> {
const origin = originIn ?? makeOrigin(center);
const bboxLv95 = bboxAround(center, radius);
const px = imagePixelSize(2 * radius);
const min = { x: bboxLv95[0] - origin.e, y: bboxLv95[1] - origin.n };
const max = { x: bboxLv95[2] - origin.e, y: bboxLv95[3] - origin.n };
try {
const res = await fetch(viaProxy(buildSwissImageUrl(bboxLv95, px, px)));
if (!res.ok) return { origin, dataUrl: null, min, max };
// WMS liefert bei Fehlern manchmal eine XML-Fehlermeldung statt eines Bildes.
const mime = res.headers.get("content-type") ?? "";
if (!mime.startsWith("image/")) return { origin, dataUrl: null, min, max };
const buf = await res.arrayBuffer();
if (buf.byteLength === 0) return { origin, dataUrl: null, min, max };
return { origin, dataUrl: bufferToDataUrl(buf, mime.split(";")[0]), min, max };
} catch {
return { origin, dataUrl: null, min, max };
}
}
+28 -1
View File
@@ -1,12 +1,30 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import { ResourceWindowApp } from "./native/ResourceWindowApp";
import { SettingsWindowApp } from "./native/SettingsWindowApp";
import { DrawingLevelsWindowApp } from "./native/DrawingLevelsWindowApp";
import { LayerSettingsWindowApp } from "./native/LayerSettingsWindowApp";
import { ContextImportWindowApp } from "./native/ContextImportWindowApp";
import { ErrorBoundary } from "./ui/ErrorBoundary"; import { ErrorBoundary } from "./ui/ErrorBoundary";
import { getLanguage, setLanguage } from "./i18n"; import { getLanguage, setLanguage } from "./i18n";
import type { Language } from "./i18n"; import type { Language } from "./i18n";
import { applyThemeMode, getStoredThemeMode } from "./theme/themeMode";
import "./styles.css"; import "./styles.css";
import "./text/fonts.css"; import "./text/fonts.css";
// Hell/Dunkel VOR dem ersten Render setzen (jedes native Fenster ist ein
// eigenes Dokument und mountet über dieselbe main.tsx) — verhindert einen
// Hell/Dunkel-Flackerer beim Laden.
applyThemeMode(getStoredThemeMode());
/** Eigenes natives Nebenfenster statt der Haupt-App? Steuert per URL-Query,
* welcher Root gemountet wird (siehe native/resourceWindow.ts bzw.
* native/settingsWindow.ts, die die URL so setzen). `?window=resources`/
* `?window=settings` nur der jeweilige Manager/Dialog, ohne den ganzen
* App-Baum (eigener Prozesskontext, kein gemeinsamer Store). */
const windowKind = new URLSearchParams(window.location.search).get("window");
// Sprach-Naht für Werkzeuge/Tests (und einen späteren Umschalter): die i18n- // Sprach-Naht für Werkzeuge/Tests (und einen späteren Umschalter): die i18n-
// Steuerung am window verfügbar machen. Identifier englisch; Werte übersetzbar. // Steuerung am window verfügbar machen. Identifier englisch; Werte übersetzbar.
declare global { declare global {
@@ -52,10 +70,19 @@ document.addEventListener("dragstart", (e) => {
if (!isTextEntry(e.target)) e.preventDefault(); if (!isTextEntry(e.target)) e.preventDefault();
}); });
function RootByWindowKind() {
if (windowKind === "resources") return <ResourceWindowApp />;
if (windowKind === "settings") return <SettingsWindowApp />;
if (windowKind === "drawing-levels") return <DrawingLevelsWindowApp />;
if (windowKind === "layer-settings") return <LayerSettingsWindowApp />;
if (windowKind === "context-import") return <ContextImportWindowApp />;
return <App />;
}
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ErrorBoundary> <ErrorBoundary>
<App /> <RootByWindowKind />
</ErrorBoundary> </ErrorBoundary>
</StrictMode>, </StrictMode>,
); );
+18 -3
View File
@@ -1,5 +1,8 @@
// Auto-generated built-in material library (ambientCG, CC0 1.0). // Eingebaute Material-Bibliothek (ambientCG, CC0 1.0) — 13 fest gebündelte
// Static asset paths under /public; loadable directly via three.js TextureLoader. // Starter-Materialien (Bilddateien liegen unter public/assets/materials/).
// Alles darüber hinaus läuft über die Live-Suche (src/materials/ambientcg.ts,
// Tab "Bibliothek durchsuchen"), die die komplette ambientCG-Bibliothek
// online durchsucht statt Texturen zu bündeln.
// Source data mirrors public/assets/materials/manifest.json. // Source data mirrors public/assets/materials/manifest.json.
export type MaterialMapKind = export type MaterialMapKind =
@@ -23,7 +26,7 @@ export interface MaterialAsset {
export const MATERIAL_LIBRARY_SOURCE = "ambientCG (ambientcg.com)"; export const MATERIAL_LIBRARY_SOURCE = "ambientCG (ambientcg.com)";
export const MATERIAL_LIBRARY_LICENSE = "CC0 1.0 Universal (Public Domain)"; export const MATERIAL_LIBRARY_LICENSE = "CC0 1.0 Universal (Public Domain)";
export const MATERIAL_LIBRARY_RESOLUTION = "1K"; export const MATERIAL_LIBRARY_RESOLUTION = "2K";
export const MATERIAL_LIBRARY: MaterialAsset[] = [ export const MATERIAL_LIBRARY: MaterialAsset[] = [
{ {
@@ -84,6 +87,18 @@ export const MATERIAL_LIBRARY: MaterialAsset[] = [
ao: "/assets/materials/Bricks104/ao.jpg", ao: "/assets/materials/Bricks104/ao.jpg",
}, },
}, },
{
id: "RoofingTiles013A",
name: "Dachziegel",
category: "Roofing Tiles",
maps: {
color: "/assets/materials/RoofingTiles013A/color.jpg",
normal: "/assets/materials/RoofingTiles013A/normal.jpg",
roughness: "/assets/materials/RoofingTiles013A/roughness.jpg",
displacement: "/assets/materials/RoofingTiles013A/displacement.jpg",
ao: "/assets/materials/RoofingTiles013A/ao.jpg",
},
},
{ {
id: "Tiles141", id: "Tiles141",
name: "Bodenfliesen", name: "Bodenfliesen",
+322
View File
@@ -0,0 +1,322 @@
// Kontextmenü-Builder — baut die Menü-Einträge je Quelle (Ebene / Zeichnungs-
// ebene / Grundriss / 3D-Viewport). 1:1 nach docs/design/context-menu.md.
// JEDER Eintrag ist verdrahtet ODER klar deaktiviert mit Tooltip (`title`, nennt
// den Grund) — kein stiller No-Op.
//
// Extrahiert aus App.tsx (reine Funktionen, keine Hooks/kein Store-Zugriff — die
// Aktionen kommen als `MenuActions` von App herein). Bezeichner englisch,
// Kommentare deutsch (CONVENTIONS.md).
import { t } from "../i18n";
import type { LayerCategory, Project } from "../model/types";
import { flattenCategories } from "../model/types";
import { findCategory } from "../state/projectSlice";
import { MenuIcon } from "../ui/ContextMenu";
import type { ContextMenuItem } from "../ui/ContextMenu";
/** Offenes Kontextmenü (genau eines). `x/y` = Anker in Viewport-Px. */
export type MenuState =
| { kind: "layer"; code: string; x: number; y: number }
| { kind: "level"; id: string; x: number; y: number }
| { kind: "plan"; wallId: string | null; x: number; y: number }
| { kind: "viewport"; wallId: string | null; 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) {
case "layer": {
const c = findCategory(project.layers, menu.code);
return c ? `${c.code} · ${c.name}` : menu.code;
}
case "level": {
const l = project.drawingLevels.find((z) => z.id === menu.id);
return l ? l.name : t("ctx.title.level");
}
case "plan":
case "viewport":
return menu.wallId ? t("ctx.title.wall") : t("ctx.title.view");
}
}
/** Aktionen, die der Kontextmenü-Builder von App braucht (alle verdrahtet). */
export interface MenuActions {
project: Project;
selectedWallId: string | null;
layerClipboard: { color: string; lw: number } | null;
openLayerEditor: (code: string) => void;
openLevelEditor: (id: string) => void;
addSubCategory: (parentCode: string) => void;
assignWallCategory: (wallId: string, code: string) => void;
duplicateCategory: (code: string) => void;
deleteCategory: (code: string) => void;
setLayerClipboard: (clip: { color: string; lw: number } | null) => void;
patchCategory: (code: string, patch: Partial<LayerCategory>) => void;
duplicateLevel: (id: string) => void;
deleteLevel: (id: string) => void;
clearSelection: () => void;
fit: () => void;
fitSelection: () => void;
/** Anzahl geschlossener Flächen in der aktuellen 2D-Auswahl (Boolean-Aktivierung). */
closedSelectionCount: number;
union: () => void;
difference: () => void;
intersect: () => void;
/** Anzahl aktuell selektierter 2D-Zeichnungselemente (z-Anordnen-Menü). */
drawingSelectionCount: number;
bringToFront: () => void;
bringForward: () => void;
sendBackward: () => void;
sendToBack: () => void;
}
/**
* Baut die Kontextmenü-Einträge je Quelle 1:1 nach docs/design/context-menu.md.
* JEDER Eintrag ist verdrahtet ODER klar deaktiviert mit Tooltip (`title`,
* nennt den Grund) kein stiller No-Op.
*/
export function buildMenuItems(
menu: NonNullable<MenuState>,
a: MenuActions,
): ContextMenuItem[] {
switch (menu.kind) {
case "layer":
return layerMenuItems(menu.code, a);
case "level":
return levelMenuItems(menu.id, a);
case "plan":
return planMenuItems(menu.wallId, a);
case "viewport":
return viewportMenuItems(menu.wallId, a);
}
}
/** Ebenen-Kontextmenü (Rechtsklick auf Ebenen-Zeile). */
function layerMenuItems(code: string, a: MenuActions): ContextMenuItem[] {
const total = flattenCategories(a.project.layers).length;
const hasSelection = a.selectedWallId !== null;
const hasClipboard = a.layerClipboard !== null;
return [
{
label: t("ctx.layerSettings"),
icon: <MenuIcon name="settings" />,
onClick: () => a.openLayerEditor(code),
},
{ divider: true },
{
label: t("ctx.addSubLayer"),
icon: <MenuIcon name="add" />,
onClick: () => a.addSubCategory(code),
},
{
label: t("ctx.assignSelection"),
icon: <MenuIcon name="move_down" />,
disabled: !hasSelection,
title: hasSelection ? undefined : t("ctx.assignSelection.disabled"),
onClick: () => {
if (a.selectedWallId) a.assignWallCategory(a.selectedWallId, code);
},
},
{ divider: true },
{
label: t("ctx.duplicate"),
icon: <MenuIcon name="content_copy" />,
onClick: () => a.duplicateCategory(code),
},
{
label: t("ctx.copyProps"),
icon: <MenuIcon name="colorize" />,
onClick: () => {
const c = findCategory(a.project.layers, code);
if (c) a.setLayerClipboard({ color: c.color, lw: c.lw });
},
},
{
label: t("ctx.pasteProps"),
icon: <MenuIcon name="format_paint" />,
disabled: !hasClipboard,
title: hasClipboard ? undefined : t("ctx.pasteProps.disabled"),
onClick: () => {
if (a.layerClipboard)
a.patchCategory(code, {
color: a.layerClipboard.color,
lw: a.layerClipboard.lw,
});
},
},
{ divider: true },
{
label: t("ctx.delete"),
icon: <MenuIcon name="delete" />,
danger: true,
disabled: total <= 1,
title: total <= 1 ? t("ctx.delete.lastLayer") : undefined,
onClick: () => a.deleteCategory(code),
},
];
}
/** Zeichnungsebenen-Kontextmenü (Rechtsklick auf Geschoss/Schnitt/Zeichnung). */
function levelMenuItems(id: string, a: MenuActions): ContextMenuItem[] {
const total = a.project.drawingLevels.length;
return [
{
label: t("ctx.levelSettings"),
icon: <MenuIcon name="settings" />,
onClick: () => a.openLevelEditor(id),
},
{ divider: true },
{
label: t("ctx.duplicate"),
icon: <MenuIcon name="content_copy" />,
onClick: () => a.duplicateLevel(id),
},
{ divider: true },
{
label: t("ctx.delete"),
icon: <MenuIcon name="delete" />,
danger: true,
disabled: total <= 1,
title: total <= 1 ? t("ctx.delete.lastLevel") : undefined,
onClick: () => a.deleteLevel(id),
},
];
}
/**
* Grundriss-Kontextmenü. Bezug ist die getroffene/gewählte Wand. Einträge sind
* verdrahtet: Auf Auswahl einpassen" (PlanView-Handle) und „Auswahl aufheben".
* Ohne getroffene Wand bleibt nur Einpassen". (Eigenschaften-Panel/Bemaßung
* folgen später daher hier KEIN Schein-Eintrag.)
*/
function planMenuItems(
wallId: string | null,
a: MenuActions,
): ContextMenuItem[] {
// Boolean-Einträge (Vereinigen/Subtrahieren/Schneiden) auf die 2D-Auswahl.
// Aktiv ab 2 geschlossenen Flächen; sonst deaktiviert mit erklärendem Tooltip.
const boolEnabled = a.closedSelectionCount >= 2;
const boolTitle = boolEnabled ? undefined : t("ctx.boolean.disabled");
const booleanItems: ContextMenuItem[] = [
{
label: t("ctx.union"),
icon: <MenuIcon name="join_full" />,
disabled: !boolEnabled,
title: boolTitle,
onClick: a.union,
},
{
label: t("ctx.subtract"),
icon: <MenuIcon name="join_left" />,
disabled: !boolEnabled,
title: boolTitle,
onClick: a.difference,
},
{
label: t("ctx.intersect"),
icon: <MenuIcon name="join_inner" />,
disabled: !boolEnabled,
title: boolTitle,
onClick: a.intersect,
},
];
// Z-Anordnen (nach vorn/hinten holen) — wirkt auf die aktuelle 2D-Auswahl,
// sortiert `drawings2d` um (Array-Reihenfolge = Zeichenreihenfolge). Aktiv
// ab 1 selektiertem Element; sonst deaktiviert mit erklärendem Tooltip.
const arrangeEnabled = a.drawingSelectionCount >= 1;
const arrangeTitle = arrangeEnabled ? undefined : t("ctx.arrange.disabled");
const arrangeItems: ContextMenuItem[] = [
{
label: t("ctx.arrange.front"),
icon: <MenuIcon name="flip_to_front" />,
disabled: !arrangeEnabled,
title: arrangeTitle,
onClick: a.bringToFront,
},
{
label: t("ctx.arrange.forward"),
icon: <MenuIcon name="arrow_upward" />,
disabled: !arrangeEnabled,
title: arrangeTitle,
onClick: a.bringForward,
},
{
label: t("ctx.arrange.backward"),
icon: <MenuIcon name="arrow_downward" />,
disabled: !arrangeEnabled,
title: arrangeTitle,
onClick: a.sendBackward,
},
{
label: t("ctx.arrange.back"),
icon: <MenuIcon name="flip_to_back" />,
disabled: !arrangeEnabled,
title: arrangeTitle,
onClick: a.sendToBack,
},
];
if (wallId) {
return [
{
label: t("ctx.fitSelection"),
icon: <MenuIcon name="center_focus_strong" />,
onClick: a.fitSelection,
},
{
label: t("ctx.clearSelection"),
icon: <MenuIcon name="deselect" />,
onClick: a.clearSelection,
},
{ divider: true },
...booleanItems,
{ divider: true },
...arrangeItems,
{ divider: true },
{
label: t("ctx.fit"),
icon: <MenuIcon name="fit_screen" />,
onClick: a.fit,
},
];
}
return [
...booleanItems,
{ divider: true },
...arrangeItems,
{ divider: true },
{
label: t("ctx.fit"),
icon: <MenuIcon name="fit_screen" />,
onClick: a.fit,
},
];
}
/**
* 3D-Kontextmenü. Im 3D gibt es (noch) kein Einpassen-Handle wie im Grundriss;
* verdrahtet ist Auswahl aufheben" (mit getroffener Wand). Ohne Wand ist das
* Menü leer-sinnvoll: ein deaktivierter Hinweis, kein stiller No-Op.
*/
function viewportMenuItems(
wallId: string | null,
a: MenuActions,
): ContextMenuItem[] {
if (wallId) {
return [
{
label: t("ctx.clearSelection"),
icon: <MenuIcon name="deselect" />,
onClick: a.clearSelection,
},
];
}
return [
{
label: t("ctx.noWallHit"),
disabled: true,
title: t("ctx.noWallHit.hint"),
},
];
}
+47 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import type { Column, ExtrudedSolid, Project } from "./types"; import type { Column, ExtrudedSolid, Project } from "./types";
import { sampleProject } from "./sampleProject"; import { sampleProject } from "./sampleProject";
import { translateProject } from "./geoRebase"; import { translateProject, projectToWorldCoords, projectGeoOrigin } from "./geoRebase";
const DX = 100; const DX = 100;
const DY = -50; const DY = -50;
@@ -201,3 +201,49 @@ describe("translateProject", () => {
expect(out.geoAnchor).toBeUndefined(); expect(out.geoAnchor).toBeUndefined();
}); });
}); });
describe("projectGeoOrigin / projectToWorldCoords", () => {
it("ohne geoAnchor -> Origin null, projectToWorldCoords ist Identität", () => {
expect(projectGeoOrigin(sampleProject)).toBeNull();
expect(projectToWorldCoords(sampleProject)).toBe(sampleProject);
});
it("Origin = lv95 model", () => {
const p: Project = {
...sampleProject,
geoAnchor: { lv95: { e: 2600500, n: 1200300 }, model: { x: 500, y: 300 } },
};
expect(projectGeoOrigin(p)).toEqual({ e: 2600000, n: 1200000 });
});
it("Round-Trip: lokal bearbeitetes Modell exportiert an echten LV95-Koordinaten", () => {
// Rohimport (model == LV95): eine Wand steht an LV95 (2600010, 1200020).
// Danach per georef um (2600000, 1200000) lokalisiert -> Wand bei (10,20),
// geoAnchor.model wandert mit. Der Welt-Export muss (2600010,1200020) liefern.
const raw: Project = {
...sampleProject,
walls: [
{
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 2600010, y: 1200020 },
end: { x: 2600015, y: 1200020 },
wallTypeId: "aw",
height: 2.6,
},
],
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 2600000, y: 1200000 } },
};
// georef: klick auf (2600000,1200000) -> translate um (2600000,1200000).
const local = translateProject(raw, -2600000, -1200000);
expect(local.walls[0].start).toEqual({ x: 10, y: 20 });
// geoAnchor.model ist mitgewandert, lv95 fix.
expect(local.geoAnchor).toEqual({ lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 } });
// Welt-Export rechnet exakt auf die ursprünglichen LV95-Koordinaten zurück.
const world = projectToWorldCoords(local);
expect(world.walls[0].start).toEqual({ x: 2600010, y: 1200020 });
expect(world.walls[0].end).toEqual({ x: 2600015, y: 1200020 });
});
});
+28
View File
@@ -42,6 +42,9 @@ function shiftContextObject(obj: ContextObject, dx: number, dy: number): Context
if (obj.type === "contourSet") { if (obj.type === "contourSet") {
return { ...obj, contours: obj.contours.map((c) => shiftContour(c, dx, dy)) }; return { ...obj, contours: obj.contours.map((c) => shiftContour(c, dx, dy)) };
} }
if (obj.type === "image") {
return { ...obj, min: shift(obj.min, dx, dy), max: shift(obj.max, dx, dy) };
}
const positions = obj.positions.slice(); const positions = obj.positions.slice();
for (let i = 0; i + 2 < positions.length; i += 3) { for (let i = 0; i + 2 < positions.length; i += 3) {
positions[i] += dx; positions[i] += dx;
@@ -118,3 +121,28 @@ export function translateProject(project: Project, dx: number, dy: number): Proj
: undefined, : undefined,
}; };
} }
/**
* Der LV95-Ursprung eines Projekts: die reale Landeskoordinate, die auf den
* Modell-Nullpunkt (0,0) abgebildet wird. Aus `geoAnchor` abgeleitet
* (`origin = lv95 model`), oder `null`, wenn KEIN Standort-Bezug existiert.
*/
export function projectGeoOrigin(project: Project): { e: number; n: number } | null {
const a = project.geoAnchor;
if (!a) return null;
return { e: a.lv95.e - a.model.x, n: a.lv95.n - a.model.y };
}
/**
* Kopie des Projekts mit SÄMTLICHER Geometrie auf die REALEN Weltkoordinaten
* (LV95) verschoben für georeferenzierte MODELL-Exporte (IFC/DXF): das Modell
* wird lokal nahe (0,0) bearbeitet, der Export schreibt aber die wahren
* Landeskoordinaten, damit die Datei in anderer CAD-/BIM-Software lagerichtig
* sitzt. Ohne `geoAnchor` (kein Bezug) bleibt das Projekt unverändert (Identität).
* NICHT für Papier-/Layout-Exporte (die bleiben in lokalen Koordinaten).
*/
export function projectToWorldCoords(project: Project): Project {
const origin = projectGeoOrigin(project);
if (!origin) return project;
return translateProject(project, origin.e, origin.n);
}
+36 -1
View File
@@ -10,6 +10,7 @@ import { materialFromAsset } from "../materials/runtime";
const PLASTER_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Plaster001")!; const PLASTER_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Plaster001")!;
const BRICK_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Bricks104")!; const BRICK_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Bricks104")!;
const CONCRETE_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Concrete048")!; const CONCRETE_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Concrete048")!;
const ROOF_TILE_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "RoofingTiles013A")!;
// Demo-Haus mit zwei Geschossen: // Demo-Haus mit zwei Geschossen:
// EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand. // EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand.
@@ -148,6 +149,19 @@ export const sampleProject: Project = {
joinPriority: 10, joinPriority: 10,
material: materialFromAsset(PLASTER_ASSET), material: materialFromAsset(PLASTER_ASSET),
}, },
// Dacheindeckung (Tonziegel) — eigenes Bauteil statt des früheren
// "brick"-Platzhalters, damit Dächer eine ECHTE Ziegel-Textur zeigen
// (Stil „Texturiert") statt der Backstein-Fassadentextur. `color` (Flach-
// Fallback für „Schattiert") entspricht dem bisherigen `ROOF_RGB` in
// toWalls3d.ts, damit der Nicht-Textur-Look unverändert warm/terracotta bleibt.
{
id: "roof-tiles",
name: "Dachziegel",
color: "#b8735c",
hatchId: "sia-brick",
joinPriority: 15,
material: materialFromAsset(ROOF_TILE_ASSET),
},
// Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität). // Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität).
{ {
id: "concrete", id: "concrete",
@@ -208,7 +222,7 @@ export const sampleProject: Project = {
name: "Steildach gedämmt", name: "Steildach gedämmt",
// Aussen (Eindeckung) → innen (Verkleidung); Bauteile aus der Bibliothek. // Aussen (Eindeckung) → innen (Verkleidung); Bauteile aus der Bibliothek.
layers: [ layers: [
{ componentId: "brick", thickness: 0.04 }, // Eindeckung (Ziegel, Platzhalter) { componentId: "roof-tiles", thickness: 0.04 }, // Eindeckung (Tonziegel)
{ componentId: "insulation", thickness: 0.22, jointLineStyleId: "thin" }, // Sparren/Dämmung { componentId: "insulation", thickness: 0.22, jointLineStyleId: "thin" }, // Sparren/Dämmung
{ componentId: "render-int", thickness: 0.0125 }, // Innenverkleidung (GK) { componentId: "render-int", thickness: 0.0125 }, // Innenverkleidung (GK)
], ],
@@ -546,3 +560,24 @@ export const sampleProject: Project = {
// über den DXF-Import bzw. die Gelände-Generierung gefüllt. // über den DXF-Import bzw. die Gelände-Generierung gefüllt.
context: [], context: [],
}; };
/**
* Leeres Projekt (nur Bibliotheken, keine Geometrie) für manuelle Tests, die
* ohne das Demo-Haus starten wollen (z. B. Standort-Import auf leerer Fläche).
*/
export const emptyProject: Project = {
...sampleProject,
id: "empty",
name: "Leeres Projekt",
walls: [],
doors: [],
openings: [],
ceilings: [],
roofs: [],
stairs: [],
rooms: [],
columns: [],
extrudedSolids: [],
drawings2d: [],
context: [],
};
+5 -2
View File
@@ -13,6 +13,9 @@ import type { Contour, TerrainMesh } from "./types";
let terrainSeq = 0; let terrainSeq = 0;
/** Sichtbarer Ebenen-/Gruppierungsname des Geländes (SitePanel). */
export const TERRAIN_LAYER = "Terrain";
/** /**
* Erzeugt ein TIN aus Konturen. * Erzeugt ein TIN aus Konturen.
* *
@@ -96,7 +99,7 @@ export function generateTerrainFromContours(contours: Contour[]): TerrainMesh {
indices.push(a, b, c); indices.push(a, b, c);
} }
return { id, type: "terrainMesh", name, positions, indices }; return { id, type: "terrainMesh", name, layer: TERRAIN_LAYER, positions, indices };
} }
/** /**
@@ -153,5 +156,5 @@ export function terrainMeshFromGrid(grid: TerrainGrid): TerrainMesh {
if (positions.length < 9 || indices.length < 3) { if (positions.length < 9 || indices.length < 3) {
return { id, type: "terrainMesh", name, positions: [], indices: [] }; return { id, type: "terrainMesh", name, positions: [], indices: [] };
} }
return { id, type: "terrainMesh", name, positions, indices }; return { id, type: "terrainMesh", name, layer: TERRAIN_LAYER, positions, indices };
} }
+60 -2
View File
@@ -453,6 +453,14 @@ export interface WindowType {
insetFromFace?: number; insetFromFace?: number;
/** Von welcher Wandfläche der {@link insetFromFace} gemessen wird; Default "aussen". */ /** Von welcher Wandfläche der {@link insetFromFace} gemessen wird; Default "aussen". */
insetFace?: "aussen" | "innen"; insetFace?: "aussen" | "innen";
/**
* Schicht-Rücksprung an der Laibung: weicht die INNERSTE Wandschicht (meist
* Gipsplatte/Holzverkleidung) an diesem Fenster um ihre eigene Dicke von der
* Öffnung zurück, statt bündig mit den übrigen Schichten abzuschliessen
* anders als {@link insetFromFace} (der Rahmen-Position), ändert dies die
* WAND-GEOMETRIE selbst an der Laibung. Default false (bündig, Alt-Verhalten).
*/
innerLayerRecess?: boolean;
/** /**
* Oberlicht: Höhe eines festen, verglasten Oberlichts über dem Hauptflügel * Oberlicht: Höhe eines festen, verglasten Oberlichts über dem Hauptflügel
* (Meter), abgeteilt durch einen Kämpfer. 0/fehlt kein Oberlicht. * (Meter), abgeteilt durch einen Kämpfer. 0/fehlt kein Oberlicht.
@@ -1356,6 +1364,12 @@ export interface Stair {
* Kategorie-Farbe. * Kategorie-Farbe.
*/ */
color?: string; color?: string;
/**
* Darstellung des Auf-/Abpfeils an der Lauflinie: "line" (offener
* Winkelpfeil, Default = heutiges Verhalten) oder "filled" (gefülltes
* Dreieck als SIA-Vollpfeil). Fehlt sie, gilt "line".
*/
arrowStyle?: "line" | "filled";
} }
/** /**
@@ -1761,12 +1775,49 @@ export interface TerrainMesh {
id: string; id: string;
type: "terrainMesh"; type: "terrainMesh";
name: string; name: string;
/** Ebenen-/Gruppierungsname (SitePanel), z. B. „Terrain". Optional. */
layer?: string;
positions: number[]; positions: number[];
indices: number[]; indices: number[];
/**
* Optionale Pro-Vertex-Albedo (flach `[r,g,b, …]`, 0..1) ein Wert je
* Positions-Vertex, beim Import aus einem SWISSIMAGE-Luftbild gesampelt
* (Luftbild aufs Gelände drapieren"). render3d färbt das TIN dann pro Vertex
* statt mit der Grundfarbe. Fehlt es, bleibt das Gelände einfarbig.
*/
vertexColors?: number[];
} }
/** Ein Kontext-Objekt: importiertes Mesh, Konturen-Satz oder abgeleitetes TIN. */ /**
export type ContextObject = ImportedMesh | ContourSet | TerrainMesh; * Ein georeferenziertes Rasterbild (Luftbild/SWISSIMAGE, eingefügtes Bild)
* planar von oben auf die Modell-Ebene gelegt. `src` = Data-URL (PNG/JPEG),
* `min`/`max` = Modell-Meter-Bounding-Box, die das Bild ausfüllt (Bild-Oben =
* +Y). Wird im Grundriss als Hintergrund-Ebene gezeichnet (unter der Geometrie).
* Gelände-Drapierung im 3D ist Folge-Arbeit.
*/
export interface ContextImage {
id: string;
type: "image";
name: string;
/** Ebenen-/Gruppierungsname (SitePanel), z. B. „Luftbild". Optional. */
layer?: string;
/** Bildquelle als Data-URL (PNG/JPEG). */
src: string;
/** Modell-Meter Bounding-Box (das Bild füllt [min..max] planar von oben). */
min: Vec2;
max: Vec2;
/** Deckkraft 0..1 (Default 1). */
opacity?: number;
/**
* Aufs Gelände-TIN drapieren": im 3D-Viewer wird das Bild als Textur über das
* Terrain gelegt (pixelscharf, s. render3d `set_aerial_texture`). Unabhängig von
* der 2D-Hintergrund-Darstellung. Default false.
*/
drape?: boolean;
}
/** Ein Kontext-Objekt: importiertes Mesh, Konturen-Satz, TIN oder Rasterbild. */
export type ContextObject = ImportedMesh | ContourSet | TerrainMesh | ContextImage;
/** Das gesamte Projekt. */ /** Das gesamte Projekt. */
/** /**
@@ -2196,6 +2247,13 @@ export interface Project {
geoAnchor?: { geoAnchor?: {
/** Reale Landeskoordinate (LV95/EPSG:2056, Meter). */ /** Reale Landeskoordinate (LV95/EPSG:2056, Meter). */
lv95: { e: number; n: number }; lv95: { e: number; n: number };
/**
* Reale Höhe des Bezugspunkts in Metern über Meer (m ü. M.). Wie ArchiCADs
* Vermessungspunkt" (Ost/Nord/Höhe): rein informativ verankert, VERSCHIEBT
* das Modell NICHT. Wird beim IFC-Export als `IfcSite.RefElevation`
* (Standort-Datumshöhe) geschrieben. Optional.
*/
height?: number;
/** Zugehöriger Punkt im Modell (lokale Meter), Default (0,0). */ /** Zugehöriger Punkt im Modell (lokale Meter), Default (0,0). */
model: Vec2; model: Vec2;
/** Anzeige-Adresse/Bezeichnung des Bezugspunkts. */ /** Anzeige-Adresse/Bezeichnung des Bezugspunkts. */
+78
View File
@@ -0,0 +1,78 @@
// Eigenständiger React-Root für das native Standort-Import-Fenster (siehe
// contextImportWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
// main.tsx gerendert, wenn die URL `?window=context-import` trägt.
//
// Rendert `ContextImportDialog` UNVERÄNDERT (windowed=true) — der Dialog
// selbst braucht keinerlei Host-/Store-Zugriff (reine `io/*`-Netzabrufe),
// daher hier kein Proxy-Pattern nötig wie bei den anderen Fenstern: nur
// `onImport`/`onSetGeoAnchor` laufen als einzelne Events ans Hauptfenster.
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { useEffect, useState } from "react";
import { ContextImportDialog } from "../ui/ContextImportDialog";
import type { GeoAnchor } from "../ui/ContextImportDialog";
import type { ContextImportSyncState } from "./contextImportWindow";
import { t } from "../i18n";
export function ContextImportWindowApp() {
const [state, setState] = useState<ContextImportSyncState | null>(null);
useEffect(() => {
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<ContextImportSyncState>("context-import-sync-state", (e) =>
setState(e.payload),
),
);
await emitTo("main", "context-import-window-ready").catch(() => {});
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
const send = (name: string, ...args: unknown[]) => {
void import("@tauri-apps/api/event").then(({ emitTo }) =>
emitTo("main", "context-import-command", { name, args }).catch(() => {}),
);
};
const onClose = () => {
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
getCurrentWindow().close(),
);
};
if (!state) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
color: "var(--ink-dim, #8a8a8a)",
fontSize: 13,
}}
>
{t("resources.loading")}
</div>
);
}
return (
<ContextImportDialog
onImport={(objs) => send("onImport", objs)}
onClose={onClose}
geoAnchor={state.geoAnchor}
onSetGeoAnchor={(anchor: GeoAnchor | undefined) => send("onSetGeoAnchor", anchor)}
windowed
/>
);
}
+166
View File
@@ -0,0 +1,166 @@
// Eigenständiger React-Root für das native Zeichnungsebenen-Fenster (siehe
// drawingLevelsWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
// main.tsx gerendert, wenn die URL `?window=drawing-levels` trägt.
//
// Spiegelt die Kategorie-/Zeilen-Darstellung des Dock-Panels
// (panels/DrawingLevelsPanel.tsx) — nutzt dessen exportierte `LevelRow` +
// Kategorie-Konstanten direkt (keine Duplikation), aber NICHT `usePanelHost`
// (das ist an den riesigen, docked-Panel-spezifischen PanelHostContext
// gebunden); stattdessen der schmale Sync-/Command-Zustand von
// drawingLevelsWindow.ts, analog SettingsWindowApp.tsx.
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { useEffect, useMemo, useState } from "react";
import {
LevelRow,
CATEGORY_ORDER,
CATEGORY_TITLE_KEY,
CATEGORY_ADD_KEY,
} from "../panels/DrawingLevelsPanel";
import type { DrawingLevelsCommandHandlers, DrawingLevelsSyncState } from "./drawingLevelsWindow";
import type { DrawingLevelKind } from "../model/types";
import { t } from "../i18n";
type HandlerName = keyof DrawingLevelsCommandHandlers;
export function DrawingLevelsWindowApp() {
const [state, setState] = useState<DrawingLevelsSyncState | null>(null);
const [collapsed, setCollapsed] = useState<Record<DrawingLevelKind, boolean>>({
floor: false,
section: false,
elevation: false,
drawing: false,
});
const toggleCategory = (kind: DrawingLevelKind) =>
setCollapsed((c) => ({ ...c, [kind]: !c[kind] }));
useEffect(() => {
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<DrawingLevelsSyncState>("drawing-levels-sync-state", (e) =>
setState(e.payload),
),
);
await emitTo("main", "drawing-levels-window-ready").catch(() => {});
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
const handlers = useMemo<DrawingLevelsCommandHandlers>(
() =>
new Proxy({} as DrawingLevelsCommandHandlers, {
get(_target, prop: string) {
return (...args: unknown[]) => {
void import("@tauri-apps/api/event").then(({ emitTo }) =>
emitTo("main", "drawing-levels-command", { name: prop as HandlerName, args }).catch(
() => {},
),
);
};
},
}),
[],
);
if (!state) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
color: "var(--ink-dim, #8a8a8a)",
fontSize: 13,
}}
>
{t("resources.loading")}
</div>
);
}
const onAddByKind: Record<DrawingLevelKind, () => void> = {
floor: handlers.onAddFloor,
section: handlers.onAddSection,
elevation: handlers.onAddElevation,
drawing: handlers.onAddDrawing,
};
// Oberstes Geschoss zuoberst (wie das Dock-Panel/DOSSIER): Anzeige umkehren.
const levelsTopDown = [...state.drawingLevels].reverse();
// Klick aufs Auge in „Alle anzeigen"/„Nur aktive" schaltet automatisch auf
// „Ausgewählte" — dieselbe DOSSIER-Regel wie im Dock-Panel.
const toggle = (id: string) => {
if (state.displayMode === "all_force" || state.displayMode === "active") {
handlers.onSetDisplayMode("all");
}
handlers.onToggleLevel(id);
};
return (
<section
className="nav-group"
style={{ height: "100vh", overflowY: "auto" }}
>
{/* Selber Kopf wie die übrigen vier Fenster (.res-head/.imp-head):
44px, border-bottom, 76px Ampel-Abstand kein Dock-Panel-Look mehr. */}
<header className="res-head" data-tauri-drag-region style={{ paddingLeft: 88 }}>
<span className="res-head-title">{t("nav.drawingLevels")}</span>
</header>
{CATEGORY_ORDER.map((kind) => {
const levelsOfKind = levelsTopDown.filter((l) => l.kind === kind);
const isOpen = !collapsed[kind];
return (
<div className="level-cat" key={kind}>
<header className="level-cat-head" onClick={() => toggleCategory(kind)}>
<span className={`level-cat-chevron${isOpen ? " open" : ""}`} aria-hidden="true">
</span>
<span className="level-cat-title">{t(CATEGORY_TITLE_KEY[kind])}</span>
<span
className="level-cat-add"
role="button"
aria-label={t(CATEGORY_ADD_KEY[kind])}
title={t(CATEGORY_ADD_KEY[kind])}
onClick={(e) => {
e.stopPropagation();
onAddByKind[kind]();
}}
>
+
</span>
</header>
{isOpen && (
<div className="nav-list">
{levelsOfKind.map((level) => (
<LevelRow
key={level.id}
level={level}
active={level.id === state.activeLevelId}
mode={state.displayMode}
onSelect={() => handlers.onSelectLevel(level.id)}
onToggle={() => toggle(level.id)}
// Rechtsklick-Kontextmenü bewusst kein Bestandteil des
// Fensters (s. drawingLevelsWindow.ts Kopfkommentar).
onContext={(e) => e.preventDefault()}
/>
))}
</div>
)}
</div>
);
})}
</section>
);
}
+135
View File
@@ -0,0 +1,135 @@
// Eigenständiger React-Root für das native Ebeneneinstellungs-Fenster (siehe
// layerSettingsWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
// main.tsx gerendert, wenn die URL `?window=layer-settings` trägt.
//
// Dieselben Felder wie der bisherige `InlineEditor`-Popover in App.tsx
// (`editor?.kind === "layer"`): Name, Farbe, Linienstärke, Schraffur-Auswahl
// — siehe layerSettingsWindow.ts Kopfkommentar für den bewussten Scope-Schnitt
// gegenüber der Rhino-Vorlage.
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { useEffect, useState } from "react";
import { EditorField } from "../ui/EditorField";
import type { LayerSettingsCommandHandlers, LayerSettingsSyncState } from "./layerSettingsWindow";
import { t } from "../i18n";
function initialCodeFromUrl(): string {
return new URLSearchParams(window.location.search).get("code") ?? "";
}
export function LayerSettingsWindowApp() {
const [state, setState] = useState<LayerSettingsSyncState | null>(null);
useEffect(() => {
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<LayerSettingsSyncState | null>("layer-settings-sync-state", (e) =>
setState(e.payload),
),
);
// Beim Wechsel auf eine andere Ebene (erneutes Öffnen desselben
// Fensters aus dem Kontextmenü) — nur der Titel/URL-Code als Hinweis;
// der eigentliche State kommt weiterhin per Sync vom Hauptfenster.
unlisten.push(await listen<string>("layer-settings-set-code", () => {}));
await emitTo("main", "layer-settings-window-ready").catch(() => {});
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
const patch = (p: Partial<{ name: string; color: string; lw: number; hatch: string | undefined }>) => {
if (!state) return;
void import("@tauri-apps/api/event").then(({ emitTo }) =>
emitTo("main", "layer-settings-command", {
name: "onPatchCategory" as keyof LayerSettingsCommandHandlers,
args: [state.code, p],
}).catch(() => {}),
);
};
const code = state?.code ?? initialCodeFromUrl();
if (!state) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
color: "var(--ink-dim, #8a8a8a)",
fontSize: 13,
}}
>
{t("resources.loading")}
</div>
);
}
return (
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<header
className="imp-head"
data-tauri-drag-region
style={{ paddingLeft: 88 }}
>
<span className="imp-head-title">
{t("editor.layerTitle", { code })}
</span>
</header>
<div className="imp-body" style={{ overflowY: "auto" }}>
<section className="imp-section">
<EditorField label={t("editor.name")}>
<input
type="text"
value={state.name}
autoFocus
onChange={(e) => patch({ name: e.target.value })}
/>
</EditorField>
<EditorField label={t("editor.color")}>
<input
type="color"
value={state.color}
onChange={(e) => patch({ color: e.target.value })}
/>
</EditorField>
<EditorField label={t("editor.lineWeight")}>
<span className="editor-num">
<input
type="number"
step={0.01}
min={0.01}
value={state.lw}
onChange={(e) => patch({ lw: Number(e.target.value) })}
/>
<span className="editor-unit">{t("editor.unit.mm")}</span>
</span>
</EditorField>
<EditorField label={t("editor.hatch")}>
<span className="editor-hatch">
<select
value={state.hatchId ?? ""}
onChange={(e) => patch({ hatch: e.target.value || undefined })}
>
<option value="">{t("resources.viewHatch.none")}</option>
{state.hatches.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</span>
</EditorField>
</section>
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
// Eigenständiger React-Root für das native Ressourcen-Fenster (siehe
// resourceWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
// main.tsx gerendert, wenn die URL `?window=resources` trägt — KEIN Teil des
// normalen App-Baums (kein Zugriff auf den Haupt-Store, eigener Prozess).
//
// Projekt-Zustand kommt ausschliesslich per Tauri-Event vom Hauptfenster
// („resource-sync-project"); Bearbeitungen laufen NICHT lokal, sondern werden
// als „resource-command"-Events ans Hauptfenster geschickt, das die echten
// Handler aufruft und die Änderung über den nächsten Sync-Push zurückspiegelt
// — dasselbe Prinzip wie die native 2D/3D-Fensterspiegelung (nativeSync.ts),
// nur bidirektional statt nur lesend.
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { useEffect, useMemo, useState } from "react";
import { ResourceManager } from "../ui/ResourceManager";
import type { ResourceManagerHandlers, TabId } from "../ui/ResourceManager";
import type { Project } from "../model/types";
import { t } from "../i18n";
/** Alle Felder von ResourceManagerHandlers sind Funktionen — für den Proxy unten. */
type HandlerName = keyof ResourceManagerHandlers;
/** Liest `?tab=…` aus der Fenster-URL (initialer Tab beim Öffnen aus einem Kontext). */
function initialTabFromUrl(): TabId | undefined {
const v = new URLSearchParams(window.location.search).get("tab");
return (v as TabId) || undefined;
}
/**
* Eigenständige Seite des Ressourcen-Fensters. Zeigt einen Lade-Hinweis, bis
* der erste Projekt-Sync vom Hauptfenster eintrifft (i. d. R. Millisekunden).
*/
export function ResourceWindowApp() {
const [project, setProject] = useState<Project | null>(null);
const [tab, setTab] = useState<TabId | undefined>(() => initialTabFromUrl());
useEffect(() => {
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<Project>("resource-sync-project", (e) => setProject(e.payload)),
);
unlisten.push(await listen<TabId>("resource-set-tab", (e) => setTab(e.payload)));
// Hauptfenster um den aktuellen Stand bitten — deckt den Fall ab, dass
// dieses Fenster NACH dem letzten Projekt-Push gestartet ist.
await emitTo("main", "resource-window-ready").catch(() => {});
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
// Jeder Handler-Aufruf wird zu EINEM „resource-command"-Event ans
// Hauptfenster (Name + Argumente, beides JSON-serialisierbar — alle
// ResourceManagerHandlers-Signaturen sind reine Datenpatches). Per Proxy
// statt 27 einzeln ausgeschriebenen Weiterleitungen (Drift-Gefahr).
const handlers = useMemo<ResourceManagerHandlers>(
() =>
new Proxy({} as ResourceManagerHandlers, {
get(_target, prop: string) {
return (...args: unknown[]) => {
void import("@tauri-apps/api/event").then(({ emitTo }) =>
emitTo("main", "resource-command", { name: prop as HandlerName, args }).catch(
() => {},
),
);
};
},
}),
[],
);
const onClose = () => {
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
getCurrentWindow().close(),
);
};
if (!project) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
color: "var(--ink-dim, #8a8a8a)",
fontSize: 13,
}}
>
{t("resources.loading")}
</div>
);
}
return (
<ResourceManager
project={project}
handlers={handlers}
initialTab={tab}
onClose={onClose}
windowed
/>
);
}
+105
View File
@@ -0,0 +1,105 @@
// Eigenständiger React-Root für das native Einstellungs-Fenster (siehe
// settingsWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
// main.tsx gerendert, wenn die URL `?window=settings` trägt. Gleiches Prinzip
// wie ResourceWindowApp.tsx, nur mit dem schmaleren Settings-Zustand statt
// des ganzen Projekts.
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { useEffect, useMemo, useState } from "react";
import { SettingsDialog } from "../ui/SettingsDialog";
import type { SettingsCommandHandlers, SettingsSyncState } from "./settingsWindow";
import { t } from "../i18n";
import { applyThemeMode } from "../theme/themeMode";
type HandlerName = keyof SettingsCommandHandlers;
export function SettingsWindowApp() {
const [state, setState] = useState<SettingsSyncState | null>(null);
// Dieses Fenster ist ein eigenes Dokument — Hell/Dunkel muss hier separat
// angewendet werden, sobald der synchronisierte Zustand eintrifft.
useEffect(() => {
if (state) applyThemeMode(state.themeMode);
}, [state]);
useEffect(() => {
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<SettingsSyncState>("settings-sync-state", (e) => setState(e.payload)),
);
// Hauptfenster um den aktuellen Stand bitten (Race-Schutz: dieses
// Fenster könnte nach dem letzten Sync-Push gestartet sein).
await emitTo("main", "settings-window-ready").catch(() => {});
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
// Jeder Handler-Aufruf → EIN „settings-command"-Event ans Hauptfenster
// (Name + Argumente), analog ResourceWindowApp.tsx.
const handlers = useMemo<SettingsCommandHandlers>(
() =>
new Proxy({} as SettingsCommandHandlers, {
get(_target, prop: string) {
return (...args: unknown[]) => {
void import("@tauri-apps/api/event").then(({ emitTo }) =>
emitTo("main", "settings-command", { name: prop as HandlerName, args }).catch(
() => {},
),
);
};
},
}),
[],
);
const onClose = () => {
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
getCurrentWindow().close(),
);
};
if (!state) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
color: "var(--ink-dim, #8a8a8a)",
fontSize: 13,
}}
>
{t("resources.loading")}
</div>
);
}
return (
<SettingsDialog
themeMode={state.themeMode}
onThemeModeChange={handlers.onThemeModeChange}
marqueeColor={state.marqueeColor}
onMarqueeColorChange={handlers.onMarqueeColorChange}
snapColor={state.snapColor}
onSnapColorChange={handlers.onSnapColorChange}
referenceElevationMasl={state.referenceElevationMasl}
onReferenceElevationChange={handlers.onReferenceElevationChange}
rendererMode={state.rendererMode}
webGpuAvailable={state.webGpuAvailable}
onRendererModeChange={handlers.onRendererModeChange}
layout={state.layout}
onApplyLayout={handlers.onApplyLayout}
onClose={onClose}
windowed
/>
);
}
+379
View File
@@ -0,0 +1,379 @@
// Native macOS-Systemmenüleiste (oben am Bildschirm) — NICHT zu verwechseln mit
// AppMenuBar (die In-App-Menüleiste in der TopBar). Nutzt die Tauri-v2-Menu-API
// (@tauri-apps/api/menu), die einen echten macOS-App-Menü-Baum baut. No-op im
// reinen Browser (kein Tauri) — s. isTauriRuntime.
//
// Aufbau: EIN Menü-Baum wird beim Mount EINMAL erzeugt (MenuItem.new ist async
// und teuer genug, um es nicht bei jedem Render neu zu bauen); alle Aktionen
// laufen über eine Ref auf die jeweils aktuellen Handler (klassisches Ref-
// Pattern gegen veraltete Closures, wie `toolShortcutRef` in App.tsx). Enabled-
// /Checked-Zustand (Undo/Redo/Löschen/Ansicht) wird in einem separaten Effekt
// synchron zum React-State über `setEnabled`/`setChecked` nachgezogen.
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { useEffect, useRef } from "react";
import { isTauriRuntime } from "../io/projectFile";
import { t } from "../i18n";
import { APP_VERSION } from "../ui/TopBar";
import type { View3d, ViewType } from "../ui/TopBar";
/** Alle Aktionen, die die native Menüleiste auslösen kann. */
export interface NativeAppMenuHandlers {
onOpenProject: () => void;
onSaveProject: () => void;
onImport: () => void;
onExportPdf: () => void;
onExportDxf: () => void;
onExportSchedule: () => void;
onExportIfc: () => void;
onExportObj: () => void;
onExportStl: () => void;
onUndo: () => void;
onRedo: () => void;
onDeleteSelection: () => void;
onMove: () => void;
onMirror: () => void;
onRotate: () => void;
onViewType: (v: ViewType) => void;
onView3d: (v: View3d) => void;
onReferenceLines: (v: boolean) => void;
onToggleResources: () => void;
onOpenSettings: () => void;
/** Macht das Panel mit dieser ID sichtbar (siehe `revealPanel` in panels/layout.ts). */
onOpenPanel: (panelId: string) => void;
/** Öffnet den Zeichnungsebenen-Manager als eigenes Fenster (zusätzlich zum Dock-Panel). */
onOpenDrawingLevelsWindow: () => void;
/** Öffnet die Ebeneneinstellungen für die aktuell aktive Ebene (activeCategoryCode). */
onOpenLayerSettingsWindow: () => void;
/** Öffnet den Standort-Import (swisstopo/OSM). */
onOpenContextImportWindow: () => void;
}
/** Ein Eintrag für „Fenster > Panels" — id + fertiges i18n-Label. */
export interface NativeAppMenuPanel {
id: string;
label: string;
}
/** Ortsabhängiger Zustand, der Enabled-/Checked-Häkchen der Menüpunkte treibt. */
export interface NativeAppMenuState {
canUndo: boolean;
canRedo: boolean;
hasSelection: boolean;
viewType: ViewType;
view3d: View3d;
referenceLines: boolean;
resourcesOpen: boolean;
}
const VIEW3D_IDS: View3d[] = [
"front",
"back",
"side",
"left",
"top",
"iso",
"perspective",
];
/**
* Baut die native macOS-Menüleiste einmal auf und hält Enabled-/Checked-
* Zustand synchron zum übergebenen `state`. `handlers` darf sich bei jedem
* Render ändern (wird über eine Ref gelesen, kein Neu-Erzeugen der Menüpunkte).
*/
export function useNativeAppMenu(
handlers: NativeAppMenuHandlers,
state: NativeAppMenuState,
panels: NativeAppMenuPanel[],
): void {
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
// Panel-Liste ist beim Mount bekannt (Registry ist statisch) — als Ref nur,
// damit der Aufbau-Effekt (deps: []) sie ohne Lint-Warnung lesen darf.
const panelsRef = useRef(panels);
panelsRef.current = panels;
// Menüpunkte, die Enabled-/Checked-Zustand nachgezogen bekommen — als Ref,
// damit der Sync-Effekt unten sie ohne Neu-Aufbau erreicht.
const itemsRef = useRef<{
undo?: import("@tauri-apps/api/menu").MenuItem;
redo?: import("@tauri-apps/api/menu").MenuItem;
delete?: import("@tauri-apps/api/menu").MenuItem;
floorplan?: import("@tauri-apps/api/menu").CheckMenuItem;
perspective?: import("@tauri-apps/api/menu").CheckMenuItem;
view3d: Map<View3d, import("@tauri-apps/api/menu").CheckMenuItem>;
referenceLines?: import("@tauri-apps/api/menu").CheckMenuItem;
resources?: import("@tauri-apps/api/menu").CheckMenuItem;
}>({ view3d: new Map() });
// Baut den Menü-Baum EINMAL. Aktionen rufen ausschliesslich über
// `handlersRef.current` — so bleibt der Baum stabil, während die
// eigentlichen Callbacks (aus App.tsx, jeden Render neu) trotzdem aktuell
// bleiben.
useEffect(() => {
if (!isTauriRuntime()) return;
let disposed = false;
void (async () => {
const {
Menu,
Submenu,
MenuItem,
CheckMenuItem,
PredefinedMenuItem,
} = await import("@tauri-apps/api/menu");
if (disposed) return;
const fileMenu = await Submenu.new({
text: t("menu.native.file"),
items: [
await MenuItem.new({
text: t("menu.native.open"),
accelerator: "CmdOrCtrl+O",
action: () => handlersRef.current.onOpenProject(),
}),
await MenuItem.new({
text: t("menu.native.saveAs"),
accelerator: "CmdOrCtrl+S",
action: () => handlersRef.current.onSaveProject(),
}),
await MenuItem.new({
text: t("menu.native.import"),
action: () => handlersRef.current.onImport(),
}),
await Submenu.new({
text: t("menu.native.export"),
items: [
await MenuItem.new({
text: "PDF",
action: () => handlersRef.current.onExportPdf(),
}),
await MenuItem.new({
text: "DXF",
action: () => handlersRef.current.onExportDxf(),
}),
await MenuItem.new({
text: t("menu.native.exportSchedule"),
action: () => handlersRef.current.onExportSchedule(),
}),
await MenuItem.new({
text: "IFC",
action: () => handlersRef.current.onExportIfc(),
}),
await MenuItem.new({
text: "OBJ",
action: () => handlersRef.current.onExportObj(),
}),
await MenuItem.new({
text: "STL",
action: () => handlersRef.current.onExportStl(),
}),
],
}),
],
});
const undoItem = await MenuItem.new({
text: t("menu.edit.undo"),
accelerator: "CmdOrCtrl+Z",
action: () => handlersRef.current.onUndo(),
});
const redoItem = await MenuItem.new({
text: t("menu.edit.redo"),
accelerator: "CmdOrCtrl+Shift+Z",
action: () => handlersRef.current.onRedo(),
});
const deleteItem = await MenuItem.new({
text: t("menu.edit.delete"),
action: () => handlersRef.current.onDeleteSelection(),
});
itemsRef.current.undo = undoItem;
itemsRef.current.redo = redoItem;
itemsRef.current.delete = deleteItem;
const editMenu = await Submenu.new({
text: t("menu.native.edit"),
items: [
undoItem,
redoItem,
await PredefinedMenuItem.new({ item: "Separator" }),
deleteItem,
await PredefinedMenuItem.new({ item: "Separator" }),
await MenuItem.new({
text: t("menu.native.move"),
action: () => handlersRef.current.onMove(),
}),
await MenuItem.new({
text: t("menu.native.mirror"),
action: () => handlersRef.current.onMirror(),
}),
await MenuItem.new({
text: t("menu.native.rotate"),
action: () => handlersRef.current.onRotate(),
}),
],
});
const floorplanItem = await CheckMenuItem.new({
text: t("menu.view.floorplan"),
action: () => handlersRef.current.onViewType("grundriss"),
});
const perspectiveItem = await CheckMenuItem.new({
text: t("menu.view.perspective"),
action: () => handlersRef.current.onViewType("perspektive"),
});
itemsRef.current.floorplan = floorplanItem;
itemsRef.current.perspective = perspectiveItem;
const view3dLabels: Record<View3d, string> = {
front: t("view3d.front"),
back: t("view3d.back"),
side: t("view3d.side"),
left: t("view3d.left"),
top: t("view3d.top"),
iso: t("view3d.iso"),
isoFrontLeft: t("view3d.iso"),
isoBackRight: t("view3d.iso"),
isoBackLeft: t("view3d.iso"),
perspective: t("view3d.perspective"),
};
const view3dItems: import("@tauri-apps/api/menu").CheckMenuItem[] = [];
for (const id of VIEW3D_IDS) {
const item = await CheckMenuItem.new({
text: view3dLabels[id],
action: () => handlersRef.current.onView3d(id),
});
itemsRef.current.view3d.set(id, item);
view3dItems.push(item);
}
const referenceLinesItem = await CheckMenuItem.new({
text: t("menu.view.referenceLines"),
action: () => handlersRef.current.onReferenceLines(!state.referenceLines),
});
itemsRef.current.referenceLines = referenceLinesItem;
const viewMenu = await Submenu.new({
text: t("menu.native.view"),
items: [
floorplanItem,
perspectiveItem,
await PredefinedMenuItem.new({ item: "Separator" }),
...view3dItems,
await PredefinedMenuItem.new({ item: "Separator" }),
referenceLinesItem,
],
});
const resourcesItem = await CheckMenuItem.new({
text: t("menu.window.resources"),
action: () => handlersRef.current.onToggleResources(),
});
itemsRef.current.resources = resourcesItem;
// Ein Menüpunkt je registriertem Panel (ToolsPanel/Attribute/Objekt-Info/
// Zeichnungsebenen/Ebenen/Umgebung/Flächenbilanz/Element-Baum/Ausschnitte/
// Layouts) — macht das jeweilige Panel sichtbar (`revealPanel`), egal ob es
// gerade angedockt, schwebend oder gar nicht offen ist.
const panelItems = await Promise.all(
panelsRef.current.map((p) =>
MenuItem.new({
text: p.label,
action: () => handlersRef.current.onOpenPanel(p.id),
}),
),
);
const panelsMenu = await Submenu.new({
text: t("menu.native.panels"),
items: panelItems,
});
const windowMenu = await Submenu.new({
text: t("menu.native.window"),
items: [
resourcesItem,
await PredefinedMenuItem.new({ item: "Separator" }),
panelsMenu,
await MenuItem.new({
text: t("menu.window.drawingLevels"),
action: () => handlersRef.current.onOpenDrawingLevelsWindow(),
}),
await MenuItem.new({
text: t("menu.window.layerSettings"),
action: () => handlersRef.current.onOpenLayerSettingsWindow(),
}),
await MenuItem.new({
text: t("menu.window.contextImport"),
action: () => handlersRef.current.onOpenContextImportWindow(),
}),
await PredefinedMenuItem.new({ item: "Separator" }),
await MenuItem.new({
text: t("menu.window.settings"),
action: () => handlersRef.current.onOpenSettings(),
}),
],
});
// 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
// „About") statt des eigenen In-App-Dialogs (AboutDialog in TopBar.tsx) —
// an dieser Stelle erwartet macOS exakt diesen Eintrag, kein Custom-Dialog.
const appMenu = await Submenu.new({
text: "Dossier",
items: [
await PredefinedMenuItem.new({
item: { About: { name: "Dossier", version: APP_VERSION } },
text: t("menu.native.about"),
}),
await PredefinedMenuItem.new({ item: "Separator" }),
await PredefinedMenuItem.new({ item: "Quit", text: t("menu.native.quit") }),
],
});
if (disposed) return;
const menu = await Menu.new({
items: [appMenu, fileMenu, editMenu, viewMenu, windowMenu],
});
await menu.setAsAppMenu();
})().catch((e) => {
console.debug("useNativeAppMenu: Aufbau fehlgeschlagen (kein Tauri-Menu?)", e);
});
return () => {
disposed = true;
};
// Nur beim Mount aufbauen — Aktionen laufen über handlersRef, State-
// Synchronisation über den Effekt unten. Ein Neu-Aufbau bei jedem Render
// wäre teuer (mehrere async IPC-Aufrufe) und unnötig.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Enabled-/Checked-Zustand synchron zum React-State halten (billig, kein
// Neu-Aufbau der Menüpunkte).
useEffect(() => {
if (!isTauriRuntime()) return;
const items = itemsRef.current;
void items.undo?.setEnabled(state.canUndo);
void items.redo?.setEnabled(state.canRedo);
void items.delete?.setEnabled(state.hasSelection);
void items.floorplan?.setChecked(state.viewType === "grundriss");
void items.perspective?.setChecked(state.viewType === "perspektive");
for (const [id, item] of items.view3d) {
void item.setChecked(state.view3d === id);
void item.setEnabled(state.viewType === "perspektive");
}
void items.referenceLines?.setChecked(state.referenceLines);
void items.resources?.setChecked(state.resourcesOpen);
}, [
state.canUndo,
state.canRedo,
state.hasSelection,
state.viewType,
state.view3d,
state.referenceLines,
state.resourcesOpen,
]);
}
+99
View File
@@ -0,0 +1,99 @@
// Standort-Import ("Standort importieren…", geo.admin/swisstopo/OSM) als
// EIGENES natives Tauri-Fenster statt Overlay-Dialog im Hauptfenster.
//
// Anders als Ressourcen/Einstellungen/Ebenen braucht dieser Dialog fast
// keinen geteilten Zustand: der gesamte Netz-Abruf (Geocoding, swissBUILDINGS3D/
// ALTI3D/SWISSIMAGE, OSM) läuft komplett INNERHALB von ContextImportDialog
// selbst (reine `io/*`-Funktionen, kein Store-Zugriff) — ein eigenes Fenster
// ist hier sogar naheliegender als beim Rest: ein langer Netz-Abruf blockiert
// so nicht einmal gedanklich das Hauptfenster. Nur das ENDERGEBNIS
// (fertige Kontext-Objekte + optionaler Georeferenzierungs-Bezug) muss zurück.
//
// Hauptfenster → Fenster: „context-import-sync-state" mit `{ geoAnchor }`.
// Fenster → Hauptfenster: „context-import-command" mit `{name,args}` — wie
// die übrigen Brücken (resourceWindow.ts etc.).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useRef } from "react";
import { isTauriRuntime } from "../io/projectFile";
import type { GeoAnchor } from "../ui/ContextImportDialog";
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
const CONTEXT_IMPORT_WINDOW_LABEL = "context-import";
export interface ContextImportSyncState {
geoAnchor: GeoAnchor | undefined;
}
export interface ContextImportCommandHandlers {
onImport: (objs: unknown[]) => void;
onSetGeoAnchor: (anchor: GeoAnchor | undefined) => void;
}
/** Öffnet das native Standort-Import-Fenster (bzw. holt es nach vorne). */
export async function openContextImportWindowNative(): Promise<void> {
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const existing = await WebviewWindow.getByLabel(CONTEXT_IMPORT_WINDOW_LABEL);
if (existing) {
await existing.setFocus();
return;
}
new WebviewWindow(CONTEXT_IMPORT_WINDOW_LABEL, {
url: "index.html?window=context-import",
title: "Standort importieren — Dossier",
width: 480,
height: 680,
minWidth: 400,
minHeight: 480,
...BORDERLESS_WINDOW_CHROME,
});
}
/** Hauptfenster-Seite der Sync-Brücke. */
export function useContextImportWindowHost(
geoAnchor: GeoAnchor | undefined,
handlers: ContextImportCommandHandlers,
): void {
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
const geoAnchorRef = useRef(geoAnchor);
geoAnchorRef.current = geoAnchor;
useEffect(() => {
if (!isTauriRuntime()) return;
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<{ name: string; args: unknown[] }>("context-import-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);
}),
);
unlisten.push(
await listen("context-import-window-ready", () => {
void emitTo(CONTEXT_IMPORT_WINDOW_LABEL, "context-import-sync-state", {
geoAnchor: geoAnchorRef.current,
}).catch(() => {});
}),
);
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
useEffect(() => {
if (!isTauriRuntime()) return;
void (async () => {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(CONTEXT_IMPORT_WINDOW_LABEL, "context-import-sync-state", {
geoAnchor,
}).catch(() => {});
})();
}, [geoAnchor]);
}
+111
View File
@@ -0,0 +1,111 @@
// Zeichnungsebenen-Manager als EIGENES natives Tauri-Fenster — Referenz:
// DOSSIER-Rhino-Plugin (`git.kgva.ch/karim/DOSSIER`, `src/DrawingLevelsApp.jsx`
// + `rhino/layers_panel.py`), dort ein eigenständiges Rhino-Panel statt
// angedockt. Hier: dieselbe Liste wie das bestehende Dock-Panel
// (DrawingLevelsPanel.tsx), zusätzlich als Fenster öffenbar — das Dock-Panel
// bleibt bestehen (bewusst additiv, nicht ersetzt, anders als beim
// Ressourcen-Manager, der nie ein Dock-Panel war).
//
// Gleiches Sync-/Command-Prinzip wie settingsWindow.ts: ein expliziter, enger
// Zustand (nicht das ganze Project) + eine {name,args}-Command-RPC.
// **Bewusst NICHT unterstützt** (Scope-Entscheidung, s. PENDENZEN.md): das
// Rechtsklick-Kontextmenü einer Zeile (Duplizieren/Löschen/Einstellungen…) —
// das lebt heute als App-weites Overlay im HAUPTFENSTER; ein Klick im
// Zeichnungsebenen-FENSTER kann es dort nicht sinnvoll positionieren. Bis das
// eigens gelöst ist, bleibt Rechtsklick im Fenster ein No-op (Auswahl/
// Sichtbarkeit/Hinzufügen funktionieren voll).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useRef } from "react";
import { isTauriRuntime } from "../io/projectFile";
import type { DrawingLevel } from "../model/types";
import type { DisplayMode } from "../panels/types";
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
const DRAWING_LEVELS_WINDOW_LABEL = "drawing-levels";
export interface DrawingLevelsSyncState {
drawingLevels: DrawingLevel[];
activeLevelId: string;
displayMode: DisplayMode;
}
export interface DrawingLevelsCommandHandlers {
onSelectLevel: (id: string) => void;
onToggleLevel: (id: string) => void;
onAddFloor: () => void;
onAddSection: () => void;
onAddElevation: () => void;
onAddDrawing: () => void;
onSetDisplayMode: (mode: DisplayMode) => void;
}
/** Öffnet das native Zeichnungsebenen-Fenster (bzw. holt es nach vorne). */
export async function openDrawingLevelsWindowNative(): Promise<void> {
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const existing = await WebviewWindow.getByLabel(DRAWING_LEVELS_WINDOW_LABEL);
if (existing) {
await existing.setFocus();
return;
}
new WebviewWindow(DRAWING_LEVELS_WINDOW_LABEL, {
url: "index.html?window=drawing-levels",
title: "Zeichnungsebenen — Dossier",
width: 340,
height: 560,
minWidth: 260,
minHeight: 300,
...BORDERLESS_WINDOW_CHROME,
});
}
/** Hauptfenster-Seite der Sync-Brücke — Pendant zu useSettingsWindowHost. */
export function useDrawingLevelsWindowHost(
state: DrawingLevelsSyncState,
handlers: DrawingLevelsCommandHandlers,
): void {
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
if (!isTauriRuntime()) return;
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<{ name: string; args: unknown[] }>("drawing-levels-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);
}),
);
unlisten.push(
await listen("drawing-levels-window-ready", () => {
void emitTo(
DRAWING_LEVELS_WINDOW_LABEL,
"drawing-levels-sync-state",
stateRef.current,
).catch(() => {});
}),
);
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
useEffect(() => {
if (!isTauriRuntime()) return;
void (async () => {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(DRAWING_LEVELS_WINDOW_LABEL, "drawing-levels-sync-state", state).catch(
() => {},
);
})();
}, [state]);
}
+134
View File
@@ -0,0 +1,134 @@
// Ebeneneinstellungen als EIGENES natives Tauri-Fenster — Referenz: DOSSIER-
// Rhino-Plugin (`git.kgva.ch/karim/DOSSIER`, `rhino/layers_panel.py`
// `_open_ebenen_settings`), das dort exakt so als „Satelliten-Fenster"
// (Eto-Form, kein Docked-Panel) läuft. Ersetzt unter Tauri den bisherigen
// `InlineEditor`-Popover (App.tsx, `editor?.kind === "layer"`); im reinen
// Browser bleibt der Popover (kein OS-Fenster-Konzept dort).
//
// **Bewusst NUR die heute schon im Modell vorhandenen Felder** (Name/Farbe/
// Linienstärke/Schraffur-Auswahl) — die Rhino-Version kennt zusätzlich eine
// PRO-EBENE Schraffur-Skalierung/-Rotation/-Stiftstärke (`section.hatchScale`
// etc.), die es in `LayerCategory` (model/types.ts) schlicht nicht gibt
// (dort nur `hatch?: string`, eine Referenz auf die geteilte Hatch-Resource
// ohne Per-Kategorie-Override). Das nachzurüsten wäre eine eigene
// Modell-Erweiterung + generatePlan.ts-Verdrahtung, keine reine Fenster-
// Frage — bewusst nicht mitgemacht, um hier nichts zu erfinden, das der Rest
// der App nicht einlöst.
//
// Nur EIN Fenster gleichzeitig (Label fix "layer-settings", wie Rhinos
// Satellit): erneutes Öffnen für eine andere Ebene schiebt nur den neuen Code
// nach (kein Fenster-Zoo), analog dem Dropdown-Wechsel im Original.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useRef } from "react";
import { isTauriRuntime } from "../io/projectFile";
import { findCategory } from "../state/projectSlice";
import type { HatchStyle, LayerCategory, Project } from "../model/types";
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
const LAYER_SETTINGS_WINDOW_LABEL = "layer-settings";
export interface LayerSettingsSyncState {
code: string;
name: string;
color: string;
lw: number;
hatchId: string | undefined;
hatches: { id: string; name: string }[];
}
export interface LayerSettingsCommandHandlers {
onPatchCategory: (code: string, patch: Partial<LayerCategory>) => void;
}
/** Öffnet das native Ebeneneinstellungs-Fenster für `code` (bzw. schiebt den Code nach, falls schon offen). */
export async function openLayerSettingsWindowNative(code: string): Promise<void> {
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const existing = await WebviewWindow.getByLabel(LAYER_SETTINGS_WINDOW_LABEL);
if (existing) {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(LAYER_SETTINGS_WINDOW_LABEL, "layer-settings-set-code", code).catch(() => {});
await existing.setFocus();
return;
}
new WebviewWindow(LAYER_SETTINGS_WINDOW_LABEL, {
url: `index.html?window=layer-settings&code=${encodeURIComponent(code)}`,
title: "Ebeneneinstellungen — Dossier",
width: 360,
height: 420,
minWidth: 300,
minHeight: 340,
...BORDERLESS_WINDOW_CHROME,
});
}
/** Baut den Sync-Zustand für eine Kategorie (bzw. `null`, wenn der Code nicht mehr existiert — z. B. gelöscht). */
function stateFor(project: Project, code: string): LayerSettingsSyncState | null {
const cat = findCategory(project.layers, code);
if (!cat) return null;
return {
code: cat.code,
name: cat.name,
color: cat.color,
lw: cat.lw,
hatchId: cat.hatch,
hatches: project.hatches.map((h: HatchStyle) => ({ id: h.id, name: h.name })),
};
}
/** Hauptfenster-Seite der Sync-Brücke. `code` = aktuell im Fenster editierte Ebene (folgt `openLayerSettingsWindowNative`-Aufrufen). */
export function useLayerSettingsWindowHost(
project: Project,
code: string | null,
onPatchCategory: (code: string, patch: Partial<LayerCategory>) => void,
): void {
const stateRef = useRef<LayerSettingsSyncState | null>(code ? stateFor(project, code) : null);
stateRef.current = code ? stateFor(project, code) : null;
const onPatchRef = useRef(onPatchCategory);
onPatchRef.current = onPatchCategory;
useEffect(() => {
if (!isTauriRuntime()) return;
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<{ name: string; args: unknown[] }>("layer-settings-command", (e) => {
if (e.payload.name === "onPatchCategory") {
const [c, patch] = e.payload.args as [string, Partial<LayerCategory>];
onPatchRef.current(c, patch);
}
}),
);
unlisten.push(
await listen("layer-settings-window-ready", () => {
void emitTo(
LAYER_SETTINGS_WINDOW_LABEL,
"layer-settings-sync-state",
stateRef.current,
).catch(() => {});
}),
);
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
useEffect(() => {
if (!isTauriRuntime()) return;
void (async () => {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(
LAYER_SETTINGS_WINDOW_LABEL,
"layer-settings-sync-state",
stateRef.current,
).catch(() => {});
})();
// eslint-disable-next-line react-hooks/exhaustive-deps -- stateRef.current ist bereits die aktuelle Ableitung
}, [project, code]);
}
+114
View File
@@ -0,0 +1,114 @@
// Ressourcen-Manager als EIGENES natives Tauri-Fenster (statt schwebendes
// Overlay im Hauptfenster) — Hauptfenster-Seite der Brücke.
//
// Ein natives Zweitfenster ist ein separater Webview-Prozesskontext: KEIN
// gemeinsamer JS-Speicher, also kein direkter Store-Zugriff. Die Brücke
// synchronisiert daher explizit über Tauri-Events (@tauri-apps/api/event):
// • Hauptfenster → Ressourcen-Fenster: „resource-sync-project" mit dem
// kompletten (JSON-serialisierbaren) Project bei jeder Änderung + einmal
// sofort, sobald das Ressourcen-Fenster „resource-window-ready" meldet
// (Race-Schutz: das Fenster könnte später starten als der erste Push).
// • Ressourcen-Fenster → Hauptfenster: „resource-command" mit
// `{ name, args }` — der Name eines ResourceManagerHandlers-Feldes plus
// seine Argumente. Das Hauptfenster ruft den ECHTEN Handler auf (der
// Projekt-Patches über setProject anwendet); die Antwort kommt implizit
// über den nächsten „resource-sync-project"-Push zurück.
// Das Gegenstück (Fenster-Seite: Proxy-Handlers + Projekt-State) lebt in
// ResourceWindowApp.tsx.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useRef } from "react";
import { isTauriRuntime } from "../io/projectFile";
import type { Project } from "../model/types";
import type { ResourceManagerHandlers, TabId } from "../ui/ResourceManager";
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
/** Fenster-Label des Ressourcen-Fensters (für emitTo/getByLabel). */
const RESOURCE_WINDOW_LABEL = "resources";
/**
* Öffnet das native Ressourcen-Fenster (bzw. holt es nach vorne, falls schon
* offen). No-op-Fallback existiert NICHT hier Aufrufer prüfen
* `isTauriRuntime()` selbst und nutzen sonst das alte Overlay (App.tsx).
*/
export async function openResourceWindowNative(initialTab?: TabId): Promise<void> {
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const existing = await WebviewWindow.getByLabel(RESOURCE_WINDOW_LABEL);
if (existing) {
if (initialTab) {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(RESOURCE_WINDOW_LABEL, "resource-set-tab", initialTab).catch(() => {});
}
await existing.setFocus();
return;
}
const url =
"index.html?window=resources" + (initialTab ? `&tab=${encodeURIComponent(initialTab)}` : "");
new WebviewWindow(RESOURCE_WINDOW_LABEL, {
url,
title: "Ressourcen — Dossier",
width: 780,
height: 660,
minWidth: 480,
minHeight: 360,
...BORDERLESS_WINDOW_CHROME,
});
}
/**
* Hauptfenster-Seite der Sync-Brücke: hält das Ressourcen-Fenster (falls
* offen) mit dem aktuellen Projekt synchron und wendet dessen Befehle auf die
* ECHTEN Handler an. No-op ausserhalb von Tauri. In App.tsx neben der
* bestehenden `<ResourceManager>`-Einbettung aufrufen (dieselben
* `resourceHandlers`).
*/
export function useResourceWindowHost(
project: Project,
handlers: ResourceManagerHandlers,
): void {
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
const projectRef = useRef(project);
projectRef.current = project;
// Befehle vom Ressourcen-Fenster entgegennehmen + auf die echten Handler
// anwenden, UND bei „ready" (Fenster gerade erst gestartet) sofort das
// aktuelle Projekt nachschieben.
useEffect(() => {
if (!isTauriRuntime()) return;
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
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);
}),
);
unlisten.push(
await listen("resource-window-ready", () => {
void emitTo(RESOURCE_WINDOW_LABEL, "resource-sync-project", projectRef.current).catch(
() => {},
);
}),
);
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
// Bei jeder Projektänderung ans (evtl. offene) Ressourcen-Fenster pushen.
// `emitTo` auf ein nicht existierendes Fenster ist harmlos (kein Empfänger).
useEffect(() => {
if (!isTauriRuntime()) return;
void (async () => {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(RESOURCE_WINDOW_LABEL, "resource-sync-project", project).catch(() => {});
})();
}, [project]);
}
+112
View File
@@ -0,0 +1,112 @@
// Einstellungen als EIGENES natives Tauri-Fenster (statt Overlay-Dialog im
// Hauptfenster) — Hauptfenster-Seite der Brücke. Gleiches Prinzip wie
// resourceWindow.ts, aber schmaler: SettingsDialog braucht nur eine Handvoll
// einzelner Felder (nicht das ganze Project), daher ein explizites
// Sync-/Command-Paar statt eines generischen Proxys über 27 Handler.
//
// Hauptfenster → Einstellungs-Fenster: „settings-sync-state" mit
// { marqueeColor, snapColor, referenceElevationMasl, rendererMode,
// webGpuAvailable, layout } bei jeder Änderung + auf „settings-window-ready".
// Einstellungs-Fenster → Hauptfenster: „settings-command" mit
// { name, args } — dieselbe {name,args}-RPC-Form wie resource-command.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useRef } from "react";
import { isTauriRuntime } from "../io/projectFile";
import type { LayoutState } from "../panels/types";
import type { RendererMode } from "../ui/StatusBar";
import type { ThemeMode } from "../theme/themeMode";
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
const SETTINGS_WINDOW_LABEL = "settings";
/** Alles, was SettingsDialog zum Rendern braucht — wandert 1:1 rüber. */
export interface SettingsSyncState {
themeMode: ThemeMode;
marqueeColor: string;
snapColor: string;
referenceElevationMasl: number | undefined;
rendererMode: RendererMode;
webGpuAvailable: boolean;
layout: LayoutState;
}
/** Alle Aktionen, die das Einstellungs-Fenster auslösen kann. */
export interface SettingsCommandHandlers {
onThemeModeChange: (mode: ThemeMode) => void;
onMarqueeColorChange: (hex: string) => void;
onSnapColorChange: (hex: string) => void;
onReferenceElevationChange: (n: number | undefined) => void;
onRendererModeChange: (mode: RendererMode) => void;
onApplyLayout: (layout: LayoutState) => void;
}
/** Öffnet das native Einstellungs-Fenster (bzw. holt es nach vorne). */
export async function openSettingsWindowNative(): Promise<void> {
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const existing = await WebviewWindow.getByLabel(SETTINGS_WINDOW_LABEL);
if (existing) {
await existing.setFocus();
return;
}
new WebviewWindow(SETTINGS_WINDOW_LABEL, {
url: "index.html?window=settings",
title: "Einstellungen — Dossier",
width: 520,
height: 640,
minWidth: 420,
minHeight: 420,
...BORDERLESS_WINDOW_CHROME,
});
}
/**
* Hauptfenster-Seite der Sync-Brücke: hält das Einstellungs-Fenster (falls
* offen) synchron zu `state` und wendet dessen Befehle auf `handlers` an.
* No-op ausserhalb von Tauri.
*/
export function useSettingsWindowHost(
state: SettingsSyncState,
handlers: SettingsCommandHandlers,
): void {
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
if (!isTauriRuntime()) return;
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<{ name: string; args: unknown[] }>("settings-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);
}),
);
unlisten.push(
await listen("settings-window-ready", () => {
void emitTo(SETTINGS_WINDOW_LABEL, "settings-sync-state", stateRef.current).catch(
() => {},
);
}),
);
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
useEffect(() => {
if (!isTauriRuntime()) return;
void (async () => {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(SETTINGS_WINDOW_LABEL, "settings-sync-state", state).catch(() => {});
})();
}, [state]);
}
+19
View File
@@ -0,0 +1,19 @@
// Gemeinsame Fenster-Chrome für native Nebenfenster (Ressourcen, Einstellungen,
// künftige weitere) — spiegelt das Hauptfenster (tauri.conf.json: randlos, macOS-
// Overlay-Titelleiste, eigener Drag-Header) statt der nativen OS-Titelzeile samt
// Titeltext. Ohne das trägt jedes Nebenfenster ZWEI optisch übereinander
// liegende „Kopfzeilen" (OS-Titel + unser eigener `res-head`/`.win-head`) — genau
// das vom Nutzer bemängelte „wirkt am Header etwas weird".
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { LogicalPosition } from "@tauri-apps/api/dpi";
/** In `new WebviewWindow(label, { ...BORDERLESS_WINDOW_CHROME, ... })` mergen.
* `trafficLightPosition` zentriert die macOS-Ampeln vertikal in unserer 40px-
* Kopfzeile (sonst sitzen sie höher als der Titeltext). */
export const BORDERLESS_WINDOW_CHROME = {
titleBarStyle: "overlay" as const,
hiddenTitle: true,
trafficLightPosition: new LogicalPosition(20, 22),
};
+4 -2
View File
@@ -13,8 +13,9 @@ import { t } from "../i18n";
import { Dropdown } from "../ui/Dropdown"; import { Dropdown } from "../ui/Dropdown";
/** /**
* Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) exakt die * Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) die fünf
* fünf DOSSIER-Modi aus docs/design/context-menu.md. * DOSSIER-Modi aus docs/design/context-menu.md plus die eigene Ergänzung
* "locked" (aktiv farbig, andere gesperrt aber NICHT grau).
*/ */
const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [ const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [
{ value: "all_force", labelKey: "display.all_force" }, { value: "all_force", labelKey: "display.all_force" },
@@ -22,6 +23,7 @@ const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [
{ value: "active", labelKey: "display.active" }, { value: "active", labelKey: "display.active" },
{ value: "grey", labelKey: "display.grey" }, { value: "grey", labelKey: "display.grey" },
{ value: "grey_locked", labelKey: "display.grey_locked" }, { value: "grey_locked", labelKey: "display.grey_locked" },
{ value: "locked", labelKey: "display.locked" },
]; ];
/** /**
+35 -10
View File
@@ -20,6 +20,7 @@ import { itemDisplay } from "./displayMode";
import { usePanelHost } from "./host"; import { usePanelHost } from "./host";
import type { DrawingLevel } from "./host"; import type { DrawingLevel } from "./host";
import type { DisplayMode } from "./types"; import type { DisplayMode } from "./types";
import { DisplayModeSelect } from "./DisplayModeSelect";
import type { DrawingLevelKind } from "../model/types"; import type { DrawingLevelKind } from "../model/types";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -31,8 +32,9 @@ const KIND_BADGE_KEY: Record<DrawingLevelKind, string> = {
drawing: "badge.drawing", drawing: "badge.drawing",
}; };
/** Kategorien der Zeichnungsebenen, in Anzeigereihenfolge. */ /** Kategorien der Zeichnungsebenen, in Anzeigereihenfolge. Exportiert für das
const CATEGORY_ORDER: DrawingLevelKind[] = [ * native Zeichnungsebenen-Fenster (native/DrawingLevelsWindowApp.tsx). */
export const CATEGORY_ORDER: DrawingLevelKind[] = [
"floor", "floor",
"section", "section",
"elevation", "elevation",
@@ -40,7 +42,7 @@ const CATEGORY_ORDER: DrawingLevelKind[] = [
]; ];
/** i18n-Key des Kategorie-Titels. */ /** i18n-Key des Kategorie-Titels. */
const CATEGORY_TITLE_KEY: Record<DrawingLevelKind, string> = { export const CATEGORY_TITLE_KEY: Record<DrawingLevelKind, string> = {
floor: "levelCat.floor", floor: "levelCat.floor",
section: "levelCat.section", section: "levelCat.section",
elevation: "levelCat.elevation", elevation: "levelCat.elevation",
@@ -48,7 +50,7 @@ const CATEGORY_TITLE_KEY: Record<DrawingLevelKind, string> = {
}; };
/** i18n-Key der Beschriftung des „+"-Glyphs (Tooltip/aria-label). */ /** i18n-Key der Beschriftung des „+"-Glyphs (Tooltip/aria-label). */
const CATEGORY_ADD_KEY: Record<DrawingLevelKind, string> = { export const CATEGORY_ADD_KEY: Record<DrawingLevelKind, string> = {
floor: "levels.addFloor", floor: "levels.addFloor",
section: "levels.addSection", section: "levels.addSection",
elevation: "levels.addElevation", elevation: "levels.addElevation",
@@ -93,10 +95,13 @@ export function DrawingLevelsPanel() {
return ( return (
<section className="nav-group"> <section className="nav-group">
<header className="nav-group-head"> {/* Kein eigener Titel/Kopf mehr der PanelFrame liefert die EINE
<span className="nav-group-title">{t("nav.drawingLevels")}</span> Kopfzeile; die Fenster-Ausklinken-Aktion sitzt dort via headerActions
</header> (DrawingLevelsHeaderActions), kein doppelter Titel. */}
<div className="panel-vis">
<span className="panel-vis-label">{t("nav.visibility")}</span>
<DisplayModeSelect value={displayMode.mode} onChange={displayMode.setMode} />
</div>
{CATEGORY_ORDER.map((kind) => { {CATEGORY_ORDER.map((kind) => {
const levelsOfKind = levelsTopDown.filter((l) => l.kind === kind); const levelsOfKind = levelsTopDown.filter((l) => l.kind === kind);
const isOpen = !collapsed[kind]; const isOpen = !collapsed[kind];
@@ -151,8 +156,28 @@ export function DrawingLevelsPanel() {
); );
} }
/** Eine Zeile im Zeichnungsebenen-Bereich. */ /** Aktionen für die gemeinsame PanelFrame-Kopfzeile (statt eigener Titelzeile):
function LevelRow({ * Zahnrad Einstellungen (Darstellungsfarben/Projekt-Felder). */
export function DrawingLevelsHeaderActions() {
const host = usePanelHost();
return (
<button
className="nav-settings"
aria-label={t("topbar.settings")}
title={t("topbar.settings")}
onClick={host.onOpenSettings}
>
<span className="material-symbols-outlined" aria-hidden="true">
settings
</span>
</button>
);
}
/** Eine Zeile im Zeichnungsebenen-Bereich. Exportiert, damit das native
* Zeichnungsebenen-Fenster (native/DrawingLevelsWindowApp.tsx) dieselbe
* Zeilen-Darstellung wiederverwendet statt sie zu duplizieren. */
export function LevelRow({
level, level,
active, active,
mode, mode,
+4
View File
@@ -26,6 +26,8 @@ import { t } from "../i18n";
const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [ const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
"Wand", "Wand",
"Decke", "Decke",
"Dach",
"Stütze",
"Fenster", "Fenster",
"Öffnung", "Öffnung",
"Treppe", "Treppe",
@@ -36,6 +38,8 @@ const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
const CLASS_LABEL_KEY: Record<Exclude<ScheduleKind, "Tür">, string> = { const CLASS_LABEL_KEY: Record<Exclude<ScheduleKind, "Tür">, string> = {
Wand: "elements.kind.wall", Wand: "elements.kind.wall",
Decke: "elements.kind.ceiling", Decke: "elements.kind.ceiling",
Dach: "elements.kind.roof",
Stütze: "elements.kind.column",
Fenster: "elements.kind.window", Fenster: "elements.kind.window",
Öffnung: "elements.kind.opening", Öffnung: "elements.kind.opening",
Treppe: "elements.kind.stair", Treppe: "elements.kind.stair",
+64 -15
View File
@@ -4,14 +4,18 @@
// Navigator: Farb-Swatch, Code, Name, EyeIcon zum Schalten, eingerückte // Navigator: Farb-Swatch, Code, Name, EyeIcon zum Schalten, eingerückte
// Kinder, Kopfzeile mit + Ebene. Daten/Handler über usePanelHost. // Kinder, Kopfzeile mit + Ebene. Daten/Handler über usePanelHost.
// //
// Das Panel deklariert hasDisplayMode. Kategorien kennen keine „aktive" // Das Panel deklariert hasDisplayMode. „Aktiv" ist die per Klick gewählte
// Auswahl (anders als Zeichnungsebenen), daher ist isActive je Zeile false; // Kategorie (activeCategoryCode, Zeichen-Ziel für neue Elemente); dieselbe
// `itemDisplay` ergibt damit (fünf DOSSIER-Modi): // activeCategoryCode speist auch App.tsx' categoryDisplay-Resolver, der die
// tatsächliche Grundriss-/3D-Darstellung steuert. `itemDisplay` ergibt damit
// (fünf DOSSIER-Modi plus eigene Ergänzung "locked"):
// • "all_force" — alle erzwungen sichtbar; Augen gedimmt. // • "all_force" — alle erzwungen sichtbar; Augen gedimmt.
// • "all" — alle sichtbaren normal (Standard, wie bisher). // • "all" — alle sichtbaren normal (Standard, wie bisher).
// • "active" — keine Kategorie aktiv → alle gedimmt. // • "active" — nur die gewählte Kategorie sichtbar, Rest ausgeblendet.
// • "grey" — sichtbare Kategorien ausgegraut. // • "grey" — gewählte normal, übrige sichtbare ausgegraut.
// • "grey_locked" — wie „grey", zusätzlich gesperrt. // • "grey_locked" — wie „grey", übrige zusätzlich gesperrt.
// • "locked" — gewählte normal, übrige sichtbare UNVERÄNDERT farbig,
// nur gesperrt (nicht anwählbar/bearbeitbar).
// Wie im Zeichnungsebenen-Panel bleibt jede Zeile bedienbar (die Liste ist die // Wie im Zeichnungsebenen-Panel bleibt jede Zeile bedienbar (die Liste ist die
// Steuerung); statt auszublenden dimmen wir gedimmte Zeilen per Inline-Opacity // Steuerung); statt auszublenden dimmen wir gedimmte Zeilen per Inline-Opacity
// (styles.css bleibt unangetastet). // (styles.css bleibt unangetastet).
@@ -23,6 +27,7 @@ import { itemDisplay } from "./displayMode";
import { usePanelHost } from "./host"; import { usePanelHost } from "./host";
import type { LayerCategory } from "./host"; import type { LayerCategory } from "./host";
import type { DisplayMode } from "./types"; import type { DisplayMode } from "./types";
import { DisplayModeSelect } from "./DisplayModeSelect";
import { t } from "../i18n"; import { t } from "../i18n";
/** Inhalt des Panels „Ebenen". */ /** Inhalt des Panels „Ebenen". */
@@ -46,15 +51,13 @@ export function LayersPanel() {
return ( return (
<section className="nav-group"> <section className="nav-group">
<header className="nav-group-head"> {/* Kein eigener Titel/Kopf mehr der PanelFrame liefert die EINE
<span className="nav-group-title">{t("nav.layers")}</span> Kopfzeile; die + Kategorie"-Aktion sitzt dort via headerActions
<span className="nav-group-actions"> (LayersHeaderActions), kein doppelter Ebenen"-Titel. */}
<button className="nav-add" onClick={host.onAddCategory}> <div className="panel-vis">
{t("layers.addCategory")} <span className="panel-vis-label">{t("nav.visibility")}</span>
</button> <DisplayModeSelect value={displayMode.mode} onChange={displayMode.setMode} />
</span> </div>
</header>
<div className="nav-list"> <div className="nav-list">
{project.layers.map((cat) => ( {project.layers.map((cat) => (
<CategoryRow <CategoryRow
@@ -66,6 +69,7 @@ export function LayersPanel() {
onSelect={host.onSelectCategory} onSelect={host.onSelectCategory}
onToggle={toggle} onToggle={toggle}
onContext={onRowContext} onContext={onRowContext}
onSettings={host.onOpenLayerSettings}
/> />
))} ))}
</div> </div>
@@ -82,6 +86,7 @@ function CategoryRow({
onSelect, onSelect,
onToggle, onToggle,
onContext, onContext,
onSettings,
}: { }: {
category: LayerCategory; category: LayerCategory;
depth: number; depth: number;
@@ -90,6 +95,7 @@ function CategoryRow({
onSelect: (code: string) => void; onSelect: (code: string) => void;
onToggle: (code: string) => void; onToggle: (code: string) => void;
onContext: (code: string, e: React.MouseEvent) => void; onContext: (code: string, e: React.MouseEvent) => void;
onSettings: (code: string, clientX: number, clientY: number) => void;
}) { }) {
// „aktiv" = diese Kategorie ist das Zeichen-Ziel (per Klick gesetzt). // „aktiv" = diese Kategorie ist das Zeichen-Ziel (per Klick gesetzt).
const isActive = category.code === activeCode; const isActive = category.code === activeCode;
@@ -137,6 +143,19 @@ function CategoryRow({
<span className="cat-code">{category.code}</span> <span className="cat-code">{category.code}</span>
<span className="cat-swatch" style={{ background: category.color }} /> <span className="cat-swatch" style={{ background: category.color }} />
<span className="cat-name">{category.name}</span> <span className="cat-name">{category.name}</span>
<button
className="cat-settings"
aria-label={t("nav.categorySettings", { name: category.name })}
title={t("nav.categorySettings", { name: category.name })}
onClick={(e) => {
e.stopPropagation();
onSettings(category.code, e.clientX, e.clientY);
}}
>
<span className="material-symbols-outlined" aria-hidden="true">
settings
</span>
</button>
</div> </div>
{category.children?.map((child) => ( {category.children?.map((child) => (
<CategoryRow <CategoryRow
@@ -148,8 +167,38 @@ function CategoryRow({
onSelect={onSelect} onSelect={onSelect}
onToggle={onToggle} onToggle={onToggle}
onContext={onContext} onContext={onContext}
onSettings={onSettings}
/> />
))} ))}
</> </>
); );
} }
/** Aktionen für die gemeinsame PanelFrame-Kopfzeile (statt eigener Titelzeile):
* + Kategorie" + Zahnrad Einstellungen (wie im Zeichnungsebenen-Kopf).
* Läuft im PanelHostContext, nutzt daher usePanelHost. */
export function LayersHeaderActions() {
const host = usePanelHost();
return (
<>
<button
className="nav-settings"
onClick={host.onOpenSettings}
aria-label={t("topbar.settings")}
title={t("topbar.settings")}
>
<span className="material-symbols-outlined" aria-hidden="true">
settings
</span>
</button>
<button
className="nav-add"
onClick={host.onAddCategory}
aria-label={t("layers.addCategory")}
title={t("layers.addCategory")}
>
+
</button>
</>
);
}
+64
View File
@@ -242,6 +242,35 @@ export function ObjectInfoPanel() {
onCommit={(v) => host.onSetDrawingCircleRadius(v)} onCommit={(v) => host.onSetDrawingCircleRadius(v)}
/> />
)} )}
{/* Kontext-Objekt (importiertes Gebäude/Terrain/Konturen): reale LV95-
Koordinate des Bbox-Mittelpunkts direkt sichtbar, ob/wohin
georeferenziert importiert wurde (Nutzer-Wunsch zum Prüfen). */}
{sel.contextObject && (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.contextObject.section")}</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.contextObject.type")}</span>
<span className="objinfo-fval">{t(`objinfo.contextObject.type.${sel.contextObject.objType}`)}</span>
</div>
{sel.contextObject.lv95Center ? (
<>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.contextObject.lv95E")}</span>
<span className="objinfo-fval">{sel.contextObject.lv95Center.e.toFixed(1)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.contextObject.lv95N")}</span>
<span className="objinfo-fval">{sel.contextObject.lv95Center.n.toFixed(1)}</span>
</div>
</>
) : (
<div className="objinfo-field">
<span className="objinfo-fval objinfo-muted">{t("objinfo.contextObject.noAnchor")}</span>
</div>
)}
</>
)}
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) {/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
die exportierten *Section-Komponenten). */} die exportierten *Section-Komponenten). */}
@@ -393,6 +422,20 @@ export function SectionLineSection({
{t("objinfo.sectionLine.flip")} {t("objinfo.sectionLine.flip")}
</button> </button>
{/* 3D-Live-Schnitt an diese Linie binden (nur mit gesetzter Linie sinnvoll):
der Viewport clippt dann vertikal entlang dieser Ebene. */}
{line && (
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.sectionLine.cut3d")}</span>
<input
type="checkbox"
checked={host.section3dCutActive}
onChange={() => host.onToggleSection3dCut(level.id)}
title={t("objinfo.sectionLine.cut3d")}
/>
</div>
)}
{/* Endpunkt-Koordinaten (nur wenn eine Linie gesetzt ist). */} {/* Endpunkt-Koordinaten (nur wenn eine Linie gesetzt ist). */}
{line && ( {line && (
<> <>
@@ -854,6 +897,27 @@ export function StairSection({
</div> </div>
</div> </div>
{/* Pfeil-Darstellung (offener Winkel- oder gefüllter SIA-Vollpfeil). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.arrow")}</span>
<div className="objinfo-segment">
<button
type="button"
className={"objinfo-seg-btn" + ((stair.arrowStyle ?? "line") === "line" ? " active" : "")}
onClick={() => host.onSetStairArrowStyle("line")}
>
{t("objinfo.stair.arrow.line")}
</button>
<button
type="button"
className={"objinfo-seg-btn" + (stair.arrowStyle === "filled" ? " active" : "")}
onClick={() => host.onSetStairArrowStyle("filled")}
>
{t("objinfo.stair.arrow.filled")}
</button>
</div>
</div>
{/* Editierbare Maße. */} {/* Editierbare Maße. */}
<DimensionField <DimensionField
label={t("objinfo.stair.width")} label={t("objinfo.stair.width")}
+3
View File
@@ -54,12 +54,15 @@ export function PanelFrame({
<section className="panel-frame"> <section className="panel-frame">
<header className="panel-head"> <header className="panel-head">
<span className="panel-title">{t(def.title)}</span> <span className="panel-title">{t(def.title)}</span>
<span className="panel-head-actions">
{def.headerActions}
{showMode && ( {showMode && (
<DisplayModeSelect <DisplayModeSelect
value={displayMode as DisplayMode} value={displayMode as DisplayMode}
onChange={onDisplayModeChange as (m: DisplayMode) => void} onChange={onDisplayModeChange as (m: DisplayMode) => void}
/> />
)} )}
</span>
</header> </header>
<div className="panel-body">{def.render(ctx ?? {})}</div> <div className="panel-body">{def.render(ctx ?? {})}</div>
</section> </section>
+62 -3
View File
@@ -19,6 +19,8 @@ import type { ContextObject } from "../model/types";
import { ContextImportDialog } from "../ui/ContextImportDialog"; import { ContextImportDialog } from "../ui/ContextImportDialog";
import { Dropdown } from "../ui/Dropdown"; import { Dropdown } from "../ui/Dropdown";
import { useStore } from "../state/appStore"; import { useStore } from "../state/appStore";
import { isTauriRuntime } from "../io/projectFile";
import { openContextImportWindowNative } from "../native/contextImportWindow";
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */ /** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
function typeBadgeKey(obj: ContextObject): TranslationKey { function typeBadgeKey(obj: ContextObject): TranslationKey {
@@ -29,9 +31,43 @@ function typeBadgeKey(obj: ContextObject): TranslationKey {
return "site.type.contours"; return "site.type.contours";
case "terrainMesh": case "terrainMesh":
return "site.type.terrain"; return "site.type.terrain";
case "image":
return "site.type.image";
} }
} }
/** Ebenen-/Gruppenname eines Kontext-Objekts (aus `layer`, sonst Typ-Fallback). */
function layerOf(obj: ContextObject): string {
if (obj.layer && obj.layer.trim() !== "") return obj.layer;
switch (obj.type) {
case "importedMesh":
return t("site.group.imported");
case "terrainMesh":
return t("site.group.terrain");
case "contourSet":
return t("site.group.contours");
case "image":
return t("site.group.image");
}
}
/** Gruppiert Kontext-Objekte nach Ebene, Reihenfolge des ersten Auftretens. */
function groupByLayer(objects: ContextObject[]): { layer: string; items: ContextObject[] }[] {
const order: string[] = [];
const byLayer = new Map<string, ContextObject[]>();
for (const obj of objects) {
const layer = layerOf(obj);
let items = byLayer.get(layer);
if (!items) {
items = [];
byLayer.set(layer, items);
order.push(layer);
}
items.push(obj);
}
return order.map((layer) => ({ layer, items: byLayer.get(layer)! }));
}
export function SitePanel() { export function SitePanel() {
const host = usePanelHost(); const host = usePanelHost();
const notify = useStore((s) => s.notify); const notify = useStore((s) => s.notify);
@@ -72,7 +108,10 @@ export function SitePanel() {
<button <button
type="button" type="button"
className="site-btn" className="site-btn"
onClick={() => setCtxImportOpen(true)} onClick={() => {
if (isTauriRuntime()) void openContextImportWindowNative();
else setCtxImportOpen(true);
}}
> >
{t("site.importLocation")} {t("site.importLocation")}
</button> </button>
@@ -117,12 +156,30 @@ export function SitePanel() {
<div className="site-sep" /> <div className="site-sep" />
{/* Liste der Kontext-Objekte (Name + Typ-Badge + Entfernen). */} {/* Kontext-Objekte nach Ebene (`layer`) gruppiert: importierte Gebäude
liegen als EINZELNE, getrennt lösch-/anwählbare Elemente unter ihrer
Ebene (z. B. Gebäude"), Terrain unter „Terrain". Je Gruppe ein Kopf
mit Anzahl + ganze Ebene löschen". */}
{objects.length === 0 ? ( {objects.length === 0 ? (
<div className="site-empty">{t("site.empty")}</div> <div className="site-empty">{t("site.empty")}</div>
) : ( ) : (
groupByLayer(objects).map(({ layer, items }) => (
<div key={layer} className="site-group">
<div className="site-group-head">
<span className="site-group-name">{layer}</span>
<span className="site-group-count">{items.length}</span>
<button
type="button"
className="site-remove"
title={t("site.deleteLayer")}
aria-label={t("site.deleteLayer")}
onClick={() => host.onRemoveContextObjects(items.map((o) => o.id))}
>
×
</button>
</div>
<ul className="site-list"> <ul className="site-list">
{objects.map((obj) => ( {items.map((obj) => (
<li <li
key={obj.id} key={obj.id}
className={ className={
@@ -148,6 +205,8 @@ export function SitePanel() {
</li> </li>
))} ))}
</ul> </ul>
</div>
))
)} )}
<div className="site-hint">{t("site.dwgHint")}</div> <div className="site-hint">{t("site.dwgHint")}</div>
+146 -45
View File
@@ -1,18 +1,23 @@
// Inhalts-Panel „Werkzeuge" — die vertikale Werkzeug-Palette (ArchiCAD-/ // Inhalts-Panel „Werkzeuge" — die Werkzeug-Palette (ArchiCAD-/Vectorworks-Stil).
// Vectorworks-Stil): eine Zeile je Zeichenwerkzeug, jeweils Icon + Name, das // Oben ein Umschalter „Liste ↔ Symbole" (Icon+Name als Zeilen ODER kompaktes
// aktive Werkzeug hervorgehoben. Darunter — wenn das Wand-Werkzeug aktiv ist — // Icon-Raster). Darunter drei einheitlich gestylte Rubriken: 2D (Zeichnen),
// ein Wandtyp-Auswahlfeld, und ein kompakter Fang-Abschnitt mit Checkboxen. // 3D (BIM-Bauteile) und Bearbeiten (Ändern-Aktionen) — Werkzeuge UND Befehle
// teilen denselben Knopf-Look (`.tp-item`), damit „Ändern" nicht mehr optisch
// aus der Reihe fällt.
// //
// Daten/Handler kommen über usePanelHost (kein Prop-Drilling). Die Werkzeug- // Daten/Handler kommen über usePanelHost (kein Prop-Drilling). Werkzeug-Metadaten
// Liste und ihre Metadaten stammen aus der Tools-Registry (TOOL_ORDER/getTool). // aus der Tools-Registry (getTool), Befehls-Labels aus der Command-Registry.
// //
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren // Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
// Texte über t(...). // Texte über t(...).
import { useState } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { usePanelHost } from "./host"; import { usePanelHost } from "./host";
import type { ToolId } from "./host"; import type { ToolId } from "./host";
import { TOOL_ORDER, getTool } from "../tools/tools"; import { getTool } from "../tools/tools";
import { getCommand } from "../commands/registry";
import { CommandIcon } from "../ui/ribbon/CommandIcon";
// ── Icons ────────────────────────────────────────────────────────────────── // ── Icons ──────────────────────────────────────────────────────────────────
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug // Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
@@ -31,18 +36,17 @@ export function ToolIcon({ id }: { id: ToolId }) {
}; };
switch (id) { switch (id) {
case "select": case "select":
// Pfeil-Cursor. // Pfeil-Cursor (klassischer Zeiger mit Schweif).
return ( return (
<svg {...common} fill="currentColor" stroke="none"> <svg {...common} fill="currentColor" stroke="currentColor" strokeWidth={0.8}>
<path d="M3 2l9 5-3.6 1.1L10.4 13 8.5 13.7 6.6 9.4 3 11z" /> <path d="M4 2.5 L4 13.2 L6.9 10.4 L8.9 14.6 L10.7 13.8 L8.7 9.7 L12.6 9.6 Z" />
</svg> </svg>
); );
case "wall": case "wall":
// Zwei parallele Linien (Wandband). // Wandband: zwei parallele Kanten mit geschlossenen Enden (Wandstück).
return ( return (
<svg {...common}> <svg {...common}>
<line x1="2" y1="5.5" x2="14" y2="5.5" /> <rect x="2" y="5.5" width="12" height="5" />
<line x1="2" y1="10.5" x2="14" y2="10.5" />
</svg> </svg>
); );
case "ceiling": case "ceiling":
@@ -55,29 +59,37 @@ export function ToolIcon({ id }: { id: ToolId }) {
</svg> </svg>
); );
case "window": case "window":
// Fenster: Rahmen-Rechteck mit Mittelsprosse (Verglasung). // Fenster (SIA-Aufsicht): Wandöffnung mit doppelter Glaslinie.
return ( return (
<svg {...common}> <svg {...common}>
<rect x="2.5" y="4" width="11" height="8" /> <line x1="2" y1="5.5" x2="14" y2="5.5" />
<line x1="8" y1="4" x2="8" y2="12" /> <line x1="2" y1="10.5" x2="14" y2="10.5" />
<line x1="2" y1="8" x2="14" y2="8" />
<line x1="2" y1="5.5" x2="2" y2="10.5" />
<line x1="14" y1="5.5" x2="14" y2="10.5" />
</svg> </svg>
); );
case "door": case "door":
// Tür: Pfosten + Türblatt mit Schwenkbogen. // Tür (SIA-Aufsicht): Türblatt aufrecht + Viertelkreis-Schwenkbogen.
return ( return (
<svg {...common}> <svg {...common}>
<line x1="3" y1="13" x2="3" y2="4" /> <line x1="3.5" y1="13" x2="3.5" y2="3" />
<line x1="3" y1="4" x2="11" y2="4" /> <path d="M3.5 3 A9.5 9.5 0 0 1 13 12.5" strokeDasharray="1.4 1.4" />
<path d="M3 13 A9 9 0 0 0 11 4" /> <line x1="3.5" y1="13" x2="13" y2="13" />
</svg> </svg>
); );
case "stair": case "stair":
// Treppensymbol: gestufte Linie (Tritte) + Lauflinie mit Pfeil. // Treppe (SIA-Aufsicht): Wangen, gleichmäßige Tritte, Lauflinie mit
// Aufwärts-Pfeil (Idee: Vollbogen ersetzt durch eine echte Treppe).
return ( return (
<svg {...common}> <svg {...common}>
<polyline points="2,14 2,11 6,11 6,8 10,8 10,5 14,5 14,2" /> <rect x="3" y="2" width="10" height="12" />
<line x1="4" y1="12.5" x2="12" y2="3.5" /> <line x1="3" y1="4.4" x2="13" y2="4.4" />
<polyline points="10.5,3 12,3.5 11.5,5" /> <line x1="3" y1="6.8" x2="13" y2="6.8" />
<line x1="3" y1="9.2" x2="13" y2="9.2" />
<line x1="3" y1="11.6" x2="13" y2="11.6" />
<line x1="8" y1="13.2" x2="8" y2="3.4" />
<polyline points="6.3,5.1 8,3.2 9.7,5.1" />
</svg> </svg>
); );
case "room": case "room":
@@ -118,10 +130,12 @@ export function ToolIcon({ id }: { id: ToolId }) {
</svg> </svg>
); );
case "arc": case "arc":
// Offener Halbbogen. // Offener Bogen (gut 180°) mit Endpunkt-Markierungen.
return ( return (
<svg {...common}> <svg {...common}>
<path d="M 2.5 12 A 5.5 5.5 0 0 1 13.5 12" fill="none" /> <path d="M 2.5 11 A 5.6 5.6 0 0 1 13.5 11" fill="none" />
<circle cx="2.5" cy="11" r="0.9" fill="currentColor" stroke="none" />
<circle cx="13.5" cy="11" r="0.9" fill="currentColor" stroke="none" />
</svg> </svg>
); );
case "text": case "text":
@@ -147,44 +161,131 @@ export function ToolIcon({ id }: { id: ToolId }) {
} }
} }
// ── Kategorien ───────────────────────────────────────────────────────────────
// Werkzeuge/Befehle in drei Rubriken sortiert (Nutzerwunsch: „sortiere nach 2D
// und 3D und Bearbeiten"). Befehlsnamen spiegeln die Ribbon-Registry
// (ui/ribbon/ribbonItems.ts) — dort liegt die kanonische Liste; hier nur die
// Panel-Gruppierung (2D-Zeichnen · 3D/BIM-Bauteile · Bearbeiten-Aktionen).
const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "text", "textbox"];
const TOOLS_3D: ToolId[] = ["wall", "ceiling", "window", "door", "stair", "room"];
const COMMANDS_3D = ["column", "ceilingopening", "roof", "extrude", "sectionline", "viewline"];
const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "trim", "join", "measure"];
type ToolsView = "list" | "icons";
const VIEW_STORAGE_KEY = "dossier.toolsPanel.view";
function loadView(): ToolsView {
try {
return localStorage.getItem(VIEW_STORAGE_KEY) === "icons" ? "icons" : "list";
} catch {
return "list";
}
}
// ── Panel ──────────────────────────────────────────────────────────────────── // ── Panel ────────────────────────────────────────────────────────────────────
export function ToolsPanel() { export function ToolsPanel() {
const host = usePanelHost(); const host = usePanelHost();
const [view, setView] = useState<ToolsView>(loadView);
return ( const chooseView = (v: ToolsView) => {
<div className="tools-panel"> setView(v);
{/* Werkzeug-Zeilen: Icon + Name, aktives hervorgehoben. */} try {
{TOOL_ORDER.map((id) => { localStorage.setItem(VIEW_STORAGE_KEY, v);
} catch {
/* Persistenz ist Best-Effort (privater Modus o. ä.). */
}
};
// Ein Werkzeug-Knopf (Icon + Name). Boden-gebundene (`floorOnly`) Werkzeuge
// sind ohne aktives Geschoss gesperrt; freie 2D-Werkzeuge bleiben nutzbar.
const toolButton = (id: ToolId) => {
const tool = getTool(id); const tool = getTool(id);
const active = host.activeTool === id; const active = host.activeTool === id;
// Nur Boden-gebundene (`floorOnly`) Werkzeuge sind ohne aktives Geschoss
// gesperrt; freie 2D-Werkzeuge (Linie/Kreis/Text/…) bleiben auf
// Zeichnungsebenen nutzbar — Parität mit dem Ribbon (RibbonBar).
const disabled = !!tool.floorOnly && !host.toolsEnabled; const disabled = !!tool.floorOnly && !host.toolsEnabled;
const title = disabled const label = t(tool.labelKey);
? t("tool.floorOnlyDisabled") const title = disabled ? t("tool.floorOnlyDisabled") : t(tool.hintKey(tool.init()));
: t(tool.hintKey(tool.init()));
const cls =
"tool-row" + (active ? " active" : "") + (disabled ? " disabled" : "");
return ( return (
<button <button
key={id} key={id}
type="button" type="button"
className={cls} className={"tp-item" + (active ? " active" : "") + (disabled ? " disabled" : "")}
title={title} title={view === "icons" ? label : title}
disabled={disabled} disabled={disabled}
onClick={() => host.onSelectTool(id)} onClick={() => host.onSelectTool(id)}
> >
<ToolIcon id={id} /> <ToolIcon id={id} />
<span>{t(tool.labelKey)}</span> <span className="tp-label">{label}</span>
</button> </button>
); );
})} };
{/* Wandtyp-/Deckentyp-Auswahl ist ins Attribute-Panel gewandert (Ribbon-UI // Ein Befehls-Knopf (Ändern-/BIM-Kommando ohne eigenen Werkzeug-Modus) —
Phase 3: Werkzeug-Sidebar nicht mehr im Default-Layout). Der Picker // gleicher Look wie die Werkzeuge (Nutzerwunsch: nicht „ganz anders").
erscheint dort bei aktivem Wand-/Decken-Werkzeug. */} const commandButton = (name: string) => {
const cmd = getCommand(name);
const label = cmd ? t(cmd.labelKey) : t(`cmd.${name}.label`);
const active = host.activeCommand === name;
return (
<button
key={name}
type="button"
className={"tp-item" + (active ? " active" : "")}
title={label}
onClick={() => host.onRunCommand(name)}
>
<CommandIcon name={name} />
<span className="tp-label">{label}</span>
</button>
);
};
const bodyClass = "tp-body" + (view === "icons" ? " icons" : "");
return (
<div className="tools-panel">
{/* Anzeige-Umschalter: nur Symbole ODER Symbol + Name (Liste). */}
<div className="tp-head">
<div className="tp-viewtoggle" role="group" aria-label={t("tools.view.label")}>
<button
type="button"
className={"tp-viewbtn" + (view === "list" ? " active" : "")}
aria-pressed={view === "list"}
onClick={() => chooseView("list")}
>
{t("tools.view.list")}
</button>
<button
type="button"
className={"tp-viewbtn" + (view === "icons" ? " active" : "")}
aria-pressed={view === "icons"}
onClick={() => chooseView("icons")}
>
{t("tools.view.icons")}
</button>
</div>
</div>
{/* Auswahl-Werkzeug: immer als volle Zeile zuoberst (meistgenutzt). */}
<div className="tp-select">{toolButton("select")}</div>
<section className="tp-section">
<div className="tp-section-title">{t("tools.cat.2d")}</div>
<div className={bodyClass}>{TOOLS_2D.map(toolButton)}</div>
</section>
<section className="tp-section">
<div className="tp-section-title">{t("tools.cat.3d")}</div>
<div className={bodyClass}>
{TOOLS_3D.map(toolButton)}
{COMMANDS_3D.map(commandButton)}
</div>
</section>
<section className="tp-section">
<div className="tp-section-title">{t("tools.cat.edit")}</div>
<div className={bodyClass}>{COMMANDS_EDIT.map(commandButton)}</div>
</section>
</div> </div>
); );
} }
+8 -4
View File
@@ -15,8 +15,8 @@
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). // Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { registerPanel } from "./registry"; import { registerPanel } from "./registry";
import { DrawingLevelsPanel } from "./DrawingLevelsPanel"; import { DrawingLevelsPanel, DrawingLevelsHeaderActions } from "./DrawingLevelsPanel";
import { LayersPanel } from "./LayersPanel"; import { LayersPanel, LayersHeaderActions } from "./LayersPanel";
import { ToolsPanel } from "./ToolsPanel"; import { ToolsPanel } from "./ToolsPanel";
import { AttributesPanel } from "./AttributesPanel"; import { AttributesPanel } from "./AttributesPanel";
import { ObjectInfoPanel } from "./ObjectInfoPanel"; import { ObjectInfoPanel } from "./ObjectInfoPanel";
@@ -82,7 +82,10 @@ registerPanel({
// damit der Titel der gewählten Sprache folgt. // damit der Titel der gewählten Sprache folgt.
title: "nav.drawingLevels", title: "nav.drawingLevels",
icon: <DrawingLevelsTabIcon />, icon: <DrawingLevelsTabIcon />,
hasDisplayMode: true, // Der Sichtbarkeits-Umschalter sitzt jetzt IM Körper mit „Sichtbarkeit"-
// Beschriftung (nicht mehr im PanelFrame-Kopf) — hasDisplayMode: false.
hasDisplayMode: false,
headerActions: <DrawingLevelsHeaderActions />,
render: () => <DrawingLevelsPanel />, render: () => <DrawingLevelsPanel />,
}); });
@@ -90,7 +93,8 @@ registerPanel({
id: BUILTIN_PANEL_IDS.layers, id: BUILTIN_PANEL_IDS.layers,
title: "nav.layers", title: "nav.layers",
icon: <LayersTabIcon />, icon: <LayersTabIcon />,
hasDisplayMode: true, hasDisplayMode: false,
headerActions: <LayersHeaderActions />,
render: () => <LayersPanel />, render: () => <LayersPanel />,
}); });
+10 -1
View File
@@ -32,8 +32,10 @@ export interface ItemDisplay {
* ausgegraut. * ausgegraut.
* "grey_locked" wie grey", übrige (sichtbare, nicht aktive) zusätzlich * "grey_locked" wie grey", übrige (sichtbare, nicht aktive) zusätzlich
* gesperrt. * gesperrt.
* "locked" aktives normal; übrige sichtbare UNVERÄNDERT farbig,
* aber gesperrt (nicht anwählbar/bearbeitbar).
* *
* Das aktive Element wird in "active"/"grey"/"grey_locked" stets gezeichnet, * Das aktive Element wird in "active"/"grey"/"grey_locked"/"locked" stets gezeichnet,
* auch wenn seine Sichtbarkeit abgeschaltet ist es ist der Arbeitsfokus. Ein * auch wenn seine Sichtbarkeit abgeschaltet ist es ist der Arbeitsfokus. Ein
* nicht aktives, unsichtbares Element wird (außer in "all_force") nie * nicht aktives, unsichtbares Element wird (außer in "all_force") nie
* gezeichnet. * gezeichnet.
@@ -65,5 +67,12 @@ export function itemDisplay(
const other = isVisible && !isActive; const other = isVisible && !isActive;
return { render: isActive || isVisible, greyed: other, locked: other }; return { render: isActive || isVisible, greyed: other, locked: other };
} }
case "locked": {
// Wie „grey_locked", aber OHNE das Ausgrauen — übrige sichtbare bleiben
// farbig, sind nur nicht mehr anwählbar/bearbeitbar.
const other = isVisible && !isActive;
return { render: isActive || isVisible, greyed: false, locked: other };
}
} }
} }
+29 -1
View File
@@ -74,6 +74,11 @@ export interface PanelHostValue {
onSelectTool: (id: ToolId) => void; onSelectTool: (id: ToolId) => void;
/** Werkzeuge nur im Grundriss eines Geschosses sinnvoll. */ /** Werkzeuge nur im Grundriss eines Geschosses sinnvoll. */
toolsEnabled: boolean; toolsEnabled: boolean;
/** Laufender Engine-Befehl (Highlight der Befehls-Knöpfe im ToolsPanel). */
activeCommand: string | null;
/** Engine-Befehl starten (Ändern-/BIM-Befehle im ToolsPanel gleicher Pfad
* wie das Tippen des Befehlsnamens in der Befehlszeile). */
onRunCommand: (name: string) => void;
/** Aktiver Wandtyp (für das Wand-Werkzeug). */ /** Aktiver Wandtyp (für das Wand-Werkzeug). */
activeWallTypeId: string; activeWallTypeId: string;
onActiveWallTypeId: (id: string) => void; onActiveWallTypeId: (id: string) => void;
@@ -112,6 +117,14 @@ export interface PanelHostValue {
* Löschen) und hält das offene Menü. * Löschen) und hält das offene Menü.
*/ */
onLayerContextMenu: (code: string, clientX: number, clientY: number) => void; onLayerContextMenu: (code: string, clientX: number, clientY: number) => void;
/** Öffnet die Ebeneneinstellungen direkt (Zahnrad-Symbol je Zeile) unter
* 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 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". */
onOpenSettings: () => void;
// Ressourcen-Handler (Bauteile / Schraffuren / Linienstile). Spiegelt // Ressourcen-Handler (Bauteile / Schraffuren / Linienstile). Spiegelt
// ResourceManagerHandlers, hier flach getippt, damit dieses Modul nicht von // ResourceManagerHandlers, hier flach getippt, damit dieses Modul nicht von
@@ -351,6 +364,15 @@ export interface PanelHostValue {
* linePoints/depth) wirkt NUR auf die selektierte Linie. * linePoints/depth) wirkt NUR auf die selektierte Linie.
*/ */
onSetSectionLinePatch: (patch: Partial<DrawingLevel>) => void; onSetSectionLinePatch: (patch: Partial<DrawingLevel>) => void;
/**
* Ob die aktuell im Objekt-Info gezeigte Schnittlinie (`sectionLine`) den
* 3D-Live-Schnitt treibt der `Wasm3DViewport` clippt dann vertikal entlang
* dieser Ebene (`sectionPlaneFromLevel`). Reiner Ansichts-Zustand (nicht im
* Dokument).
*/
section3dCutActive: boolean;
/** Bindet den 3D-Live-Schnitt an diese Schnittlinie / hebt die Bindung auf. */
onToggleSection3dCut: (id: string) => void;
/** /**
* Löscht die Schnittlinie der gewählten Ebene (`linePoints` = undefined); die * Löscht die Schnittlinie der gewählten Ebene (`linePoints` = undefined); die
* Ebene selbst bleibt bestehen (der Schnitt zeigt dann wieder den Hinweis). * Ebene selbst bleibt bestehen (der Schnitt zeigt dann wieder den Hinweis).
@@ -368,6 +390,8 @@ export interface PanelHostValue {
onSetStairRise: (totalRise: number) => void; onSetStairRise: (totalRise: number) => void;
/** Setzt die Laufrichtung (aufwärts/abwärts). */ /** Setzt die Laufrichtung (aufwärts/abwärts). */
onSetStairUp: (up: boolean) => void; onSetStairUp: (up: boolean) => void;
/** Setzt die Pfeil-Darstellung (offener Winkel- vs. gefüllter Vollpfeil). */
onSetStairArrowStyle: (style: "line" | "filled") => void;
/** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */ /** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */
onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void; onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void;
/** Weist der Treppe einen Treppentyp (Bibliothek) zu; "" löst die Zuweisung. */ /** Weist der Treppe einen Treppentyp (Bibliothek) zu; "" löst die Zuweisung. */
@@ -410,6 +434,8 @@ export interface PanelHostValue {
onAddContextObjects: (objs: ContextObject[]) => void; onAddContextObjects: (objs: ContextObject[]) => void;
/** Entfernt ein Kontext-Objekt per ID. */ /** Entfernt ein Kontext-Objekt per ID. */
onRemoveContextObject: (id: string) => void; onRemoveContextObject: (id: string) => void;
/** Entfernt mehrere Kontext-Objekte in einem Schritt (ganze Ebene/Gruppe). */
onRemoveContextObjects: (ids: string[]) => void;
/** /**
* Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN. Liefert die neue * Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN. Liefert die neue
* TerrainMesh-ID oder null (unbekannte ID / kein sinnvolles TIN). * TerrainMesh-ID oder null (unbekannte ID / kein sinnvolles TIN).
@@ -421,7 +447,9 @@ export interface PanelHostValue {
* den Bezug (nächster Import setzt ihn automatisch neu). * den Bezug (nächster Import setzt ihn automatisch neu).
*/ */
onSetGeoAnchor: ( onSetGeoAnchor: (
anchor: { lv95: { e: number; n: number }; model: { x: number; y: number }; label?: string } | undefined, anchor:
| { lv95: { e: number; n: number }; height?: number; model: { x: number; y: number }; label?: string }
| undefined,
) => void; ) => void;
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */ /** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
onImportDxf: () => void; onImportDxf: () => void;
+28 -5
View File
@@ -40,7 +40,10 @@ import type {
// als weiterer Tab in der rechten unteren Gruppe. // als weiterer Tab in der rechten unteren Gruppe.
// v11: Layouts-Panel ("layouts", DOSSIER A3 / ROADMAP Paragraf 11) als weiterer // v11: Layouts-Panel ("layouts", DOSSIER A3 / ROADMAP Paragraf 11) als weiterer
// Tab in der rechten unteren Gruppe. // Tab in der rechten unteren Gruppe.
export const LAYOUT_VERSION = 11; // v12: Werkzeug-Palette ZURÜCK ins Default-Layout (Nutzer-Entscheid: Werkzeuge
// gehören in ein andockbares Panel statt ins Ribbon-Band) — wieder oben links,
// Attribute darunter (Vectorworks-Bild aus v6, vor dem Ribbon-UI-Phase-3-Bump).
export const LAYOUT_VERSION = 12;
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */ /** localStorage-Schlüssel des zuletzt benutzten Layouts. */
const CURRENT_KEY = "cad.layout"; const CURRENT_KEY = "cad.layout";
@@ -64,11 +67,11 @@ const NAMED_PREFIX = "cad.layouts.";
* OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein * OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein
* schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht. * schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht.
*/ */
// Ribbon-UI Phase 3: die Werkzeug-Sidebar ("tools") ist NICHT mehr im Default- // v12: Werkzeug-Palette ("tools") wieder im Default-Layout — oben links, mit
// Layout — die Werkzeuge liegen im Ribbon, der Wandtyp-/Deckentyp-Picker im // der Attribute-Palette darunter (Vectorworks-Bild). Das Ribbon-Band bleibt als
// Attribute-Panel. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar), // Reiter erreichbar, ist aber nicht mehr der primäre Weg zu den Werkzeugen.
// nur nicht mehr per Default sichtbar → Attribute bekommt die volle linke Höhe.
const DEFAULT_LEFT_GROUPS: DockGroup[] = [ const DEFAULT_LEFT_GROUPS: DockGroup[] = [
{ tabs: ["tools"], activeTab: "tools", weight: 1 },
{ tabs: ["attributes"], activeTab: "attributes", weight: 1 }, { tabs: ["attributes"], activeTab: "attributes", weight: 1 },
]; ];
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [ const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
@@ -376,6 +379,26 @@ export function setActiveTab(
return { ...layout, [dock]: { ...d, groups } }; return { ...layout, [dock]: { ...d, groups } };
} }
/**
* Macht ein Panel sichtbar, gleich wo es gerade steckt NIE destruktiv (schliesst/
* versteckt nie etwas anderes). Für Fenster > Panels" (native Menüleiste,
* appMenu.ts) und künftige ähnliche Einstiege:
* schon schwebend nach vorne holen (bringToFront).
* angedockt, inaktiver Tab als aktiven Tab seiner Gruppe setzen.
* nirgends vorhanden als neues schwebendes Fenster öffnen (Default-Rect).
*/
export function revealPanel(layout: LayoutState, panelId: string): LayoutState {
if (layout.floating.some((f) => f.panelId === panelId)) {
return bringToFront(layout, panelId);
}
for (const dock of ["left", "right"] as DockId[]) {
if (layout[dock].groups.some((g) => g.tabs.includes(panelId))) {
return setActiveTab(layout, dock, panelId);
}
}
return moveToFloat(layout, panelId, { x: 120, y: 120, w: 320, h: 420 });
}
// `otherDock` wird (noch) nicht extern gebraucht, ist aber Teil der Aufräum- // `otherDock` wird (noch) nicht extern gebraucht, ist aber Teil der Aufräum-
// Semantik; als Helfer exportiert, damit Wiring-Code symmetrisch arbeiten kann. // Semantik; als Helfer exportiert, damit Wiring-Code symmetrisch arbeiten kann.
export { otherDock }; export { otherDock };
+13 -3
View File
@@ -97,16 +97,18 @@ export interface LayoutState {
/** /**
* Darstellungsmodus für ortsabhängige Inhalte (Navigator-Listen, 3D, Grundriss). * Darstellungsmodus für ortsabhängige Inhalte (Navigator-Listen, 3D, Grundriss).
* Steuert, welche Elemente bezogen auf die aktive Auswahl gezeigt werden die * Steuert, welche Elemente bezogen auf die aktive Auswahl gezeigt werden die
* fünf DOSSIER-Modi (siehe docs/design/context-menu.md), für Ebenen UND * fünf DOSSIER-Modi (siehe docs/design/context-menu.md) plus eine eigene
* Zeichnungsebenen identisch: * Ergänzung ("locked"), für Ebenen UND Zeichnungsebenen identisch:
* "all_force" alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge * "all_force" alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge
* wechselt zu Ausgewählte". * wechselt zu Ausgewählte".
* "all" sichtbar nach per-Zeile-Flag (Standard). * "all" sichtbar nach per-Zeile-Flag (Standard).
* "active" nur das aktive Element; andere stark gedimmt. * "active" nur das aktive Element; andere stark gedimmt.
* "grey" aktives normal, andere 45 % (Sichtbarkeits-Flags gelten). * "grey" aktives normal, andere 45 % (Sichtbarkeits-Flags gelten).
* "grey_locked" wie grey", andere zusätzlich gesperrt. * "grey_locked" wie grey", andere zusätzlich gesperrt.
* "locked" aktives normal, andere sichtbare UNVERÄNDERT farbig,
* aber gesperrt (nicht anwählbar/bearbeitbar).
*/ */
export type DisplayMode = "all_force" | "all" | "active" | "grey" | "grey_locked"; export type DisplayMode = "all_force" | "all" | "active" | "grey" | "grey_locked" | "locked";
// ── Panel-Definition & Kontext ───────────────────────────────────────────── // ── Panel-Definition & Kontext ─────────────────────────────────────────────
@@ -151,6 +153,14 @@ export interface PanelDef {
* Kopfzeile anbieten soll. Default: kein Umschalter. * Kopfzeile anbieten soll. Default: kein Umschalter.
*/ */
hasDisplayMode?: boolean; hasDisplayMode?: boolean;
/**
* Optionale Aktions-Elemente rechts in der EINEN Kopfzeile des PanelFrame
* (z. B. + Kategorie", Fenster-Ausklinken). So bündeln Panels ihre Aktionen
* in der gemeinsamen Frame-Kopfzeile statt einer zweiten eigenen Titelzeile
* (kein doppelter Titel mehr). Wird INNERHALB des PanelHostContext gerendert,
* darf also `usePanelHost()` nutzen.
*/
headerActions?: ReactNode;
} }
// ── Host-Context (App-Zustand + Handler für Panels) ──────────────────────── // ── Host-Context (App-Zustand + Handler für Panels) ────────────────────────
+167 -21
View File
@@ -213,6 +213,12 @@ export interface PlanSelection {
* über allem ein Klick genau darauf meint die Linie). * über allem ein Klick genau darauf meint die Linie).
*/ */
sectionLineId: string | null; sectionLineId: string | null;
/**
* ID des getroffenen Kontext-Objekts (importiertes Gebäude/Terrain), wenn der
* Klick sein Grundriss-Pick-Band traf und KEIN Element höherer Priorität
* (alle anderen gehen vor, analog `roofId`); sonst `null`.
*/
contextObjectId: string | null;
/** /**
* War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die * War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die
* Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu * Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu
@@ -303,6 +309,12 @@ export interface PlanViewProps {
selectedRoomIds?: string[]; selectedRoomIds?: string[];
/** Aktuell ausgewählte Dach-IDs (von App); die Ansicht markiert die Traufe. */ /** Aktuell ausgewählte Dach-IDs (von App); die Ansicht markiert die Traufe. */
selectedRoofIds?: string[]; selectedRoofIds?: string[];
/**
* Aktuell ausgewählte Kontext-Objekt-IDs (importiertes Gebäude/Terrain; von
* App). Die Ansicht markiert das Grundriss-Pick-Band JEDES dieser Objekte mit
* einer Akzentkontur.
*/
selectedContextObjectIds?: string[];
/** /**
* Aktuell ausgewählte Schnitt-/Ansichtslinien-IDs (DrawingLevel; von App). Die * Aktuell ausgewählte Schnitt-/Ansichtslinien-IDs (DrawingLevel; von App). Die
* Ansicht markiert das Pick-Band JEDER dieser Linien mit einer Akzentkontur. * Ansicht markiert das Pick-Band JEDER dieser Linien mit einer Akzentkontur.
@@ -331,6 +343,9 @@ export interface PlanViewProps {
onStampEdit?: (roomId: string) => void; onStampEdit?: (roomId: string) => void;
/** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */ /** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */
onEditDrawingText?: (drawingId: string) => void; onEditDrawingText?: (drawingId: string) => void;
/** Doppelklick auf eine Schnittlinie: in deren 2D-Schnittansicht springen
* (Zeichnungsebene aktivieren). */
onOpenSectionLevel?: (levelId: string) => void;
/** /**
* Links-Drag (Marquee): meldet die im Auswahl-Rechteck getroffenen Wände nach * Links-Drag (Marquee): meldet die im Auswahl-Rechteck getroffenen Wände nach
* der CAD-Konvention (siehe {@link MarqueeSelection}). Optional. * der CAD-Konvention (siehe {@link MarqueeSelection}). Optional.
@@ -447,6 +462,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
selectedStairIds, selectedStairIds,
selectedRoomIds, selectedRoomIds,
selectedRoofIds, selectedRoofIds,
selectedContextObjectIds,
selectedSectionLineIds, selectedSectionLineIds,
selectedExtrudedSolidIds, selectedExtrudedSolidIds,
selectedColumnIds, selectedColumnIds,
@@ -454,6 +470,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
onStampMove, onStampMove,
onStampEdit, onStampEdit,
onEditDrawingText, onEditDrawingText,
onOpenSectionLevel,
activeTool, activeTool,
toolInputActive, toolInputActive,
toolHandlers, toolHandlers,
@@ -537,9 +554,15 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// „GPU-Canvas montieren?" — beide GPU-Pfade brauchen das Canvas (an der ABSICHT // „GPU-Canvas montieren?" — beide GPU-Pfade brauchen das Canvas (an der ABSICHT
// montiert, nicht am ready, sonst könnte die Engine mangels Canvas nie starten). // montiert, nicht am ready, sonst könnte die Engine mangels Canvas nie starten).
const wantGpu = wantGl || wantWasm; const wantGpu = wantGl || wantWasm;
// Georeferenzierte Hintergrund-Rasterbilder (Luftbild) vorhanden? Der GPU-
// Canvas löscht das Blatt UNDURCHSICHTIG (clearColor …,1) und würde ein
// dahinterliegendes Bild verdecken. Daher bei aktivem Luftbild bewusst den
// vollen SVG-Pfad nutzen — dort liegt das Bild als eigene Ebene ZWISCHEN
// Blatt-Hintergrund und Geometrie (sichtbar, ohne den GL-Renderer zu ändern).
const hasBgImages = (plan.backgroundImages?.length ?? 0) > 0;
// Engine WIRKLICH aktiv? Steuert das Umschalten SVG↔GPU: bei false rendert der // Engine WIRKLICH aktiv? Steuert das Umschalten SVG↔GPU: bei false rendert der
// volle SVG-Pfad (Fallback), bei true übernimmt die GPU-Ebene + Text-Overlay. // volle SVG-Pfad (Fallback), bei true übernimmt die GPU-Ebene + Text-Overlay.
const useGpuRenderer = wantGpu && glReady; const useGpuRenderer = wantGpu && glReady && !hasBgImages;
// Stabiler Papier-Massstab-Nenner N (1:N) als REFERENZ für die Print-Strich- // Stabiler Papier-Massstab-Nenner N (1:N) als REFERENZ für die Print-Strich-
// breiten. BEWUSST NICHT der live aus dem Zoom abgeleitete N (der sich beim // breiten. BEWUSST NICHT der live aus dem Zoom abgeleitete N (der sich beim
@@ -1060,6 +1083,22 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return null; return null;
}; };
// Getroffenes Kontext-Objekt (importiertes Gebäude/Terrain): sein unsichtbares
// Grundriss-Pick-Band (Bounding-Box) per Punkt-in-Polygon — macht es wie eine
// Wand irgendwo innerhalb seiner Fläche anklickbar, nicht nur exakt auf der
// dünnen Umrisslinie.
const pickContextObject = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.contextObjectId && pointInPolygon(m, p.pts)) {
return p.contextObjectId;
}
}
return null;
};
// Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss // Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss
// ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon // ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon
// trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie). // trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie).
@@ -1589,6 +1628,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
columnId: null, columnId: null,
roofId: null, roofId: null,
sectionLineId, sectionLineId,
contextObjectId: null,
shift: e.shiftKey, shift: e.shiftKey,
}); });
return; return;
@@ -1644,6 +1684,13 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId || roomId openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId || roomId
? null ? null
: pickRoof(e.clientX, e.clientY); : pickRoof(e.clientX, e.clientY);
// Kontext-Objekt (Gebäude/Terrain) als ALLERLETZTE Priorität — seine
// Bbox-Pick-Fläche kann grosszügig überlappen, daher erst wenn wirklich
// nichts anderes getroffen wurde.
const contextObjectId =
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId || roomId || roofId
? null
: pickContextObject(e.clientX, e.clientY);
onSelect({ onSelect({
model: viewToModel(v.x, v.y), model: viewToModel(v.x, v.y),
hitIndex, hitIndex,
@@ -1657,6 +1704,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
columnId, columnId,
roofId, roofId,
sectionLineId: null, sectionLineId: null,
contextObjectId,
shift: e.shiftKey, shift: e.shiftKey,
}); });
} }
@@ -1729,6 +1777,14 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return; return;
} }
} }
// Doppelklick auf eine Schnittlinie → in deren 2D-Schnittansicht springen.
if (onOpenSectionLevel) {
const sid = pickSectionLine(e.clientX, e.clientY);
if (sid) {
onOpenSectionLevel(sid);
return;
}
}
setView(defaultView); setView(defaultView);
}; };
@@ -1856,6 +1912,21 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
); );
}, [plan, selectedRoofIds]); }, [plan, selectedRoofIds]);
// Hervorhebung der selektierten Kontext-Objekte (Gebäude/Terrain): das
// (unsichtbare) Bbox-Pick-Band mit contextObjectId als Akzentkontur
// überzeichnen — analog zur Dach-/Raum-Auswahl.
const highlightContextObjectPolys = useMemo(() => {
const ids =
selectedContextObjectIds && selectedContextObjectIds.length
? new Set(selectedContextObjectIds)
: null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.contextObjectId != null && ids.has(p.contextObjectId),
);
}, [plan, selectedContextObjectIds]);
// Hervorhebung der selektierten Schnitt-/Ansichtslinie: das (unsichtbare) // Hervorhebung der selektierten Schnitt-/Ansichtslinie: das (unsichtbare)
// Pick-Band (Polygon mit sectionLineId) mit Akzentkontur überzeichnen. // Pick-Band (Polygon mit sectionLineId) mit Akzentkontur überzeichnen.
const highlightSectionLinePolys = useMemo(() => { const highlightSectionLinePolys = useMemo(() => {
@@ -1988,8 +2059,9 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
darüber erreichen. An `wantGpu` (Absicht) montiert NICHT an `useGpuRenderer` darüber erreichen. An `wantGpu` (Absicht) montiert NICHT an `useGpuRenderer`
(=wantGpu&&glReady), sonst könnte GL nie initialisieren (Canvas fehlt (=wantGpu&&glReady), sonst könnte GL nie initialisieren (Canvas fehlt
glReady bleibt false Deadlock). Bei WebGL-Fehler bleibt es leer hinten glReady bleibt false Deadlock). Bei WebGL-Fehler bleibt es leer hinten
und der volle SVG-Pfad übernimmt sichtbar. */} und der volle SVG-Pfad übernimmt sichtbar. NICHT montiert, wenn ein
{wantGpu && ( Luftbild aktiv ist dann rendert der SVG-Pfad alles inkl. Bild-Ebene. */}
{wantGpu && !hasBgImages && (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
style={{ style={{
@@ -2058,6 +2130,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
className={useGpuRenderer ? undefined : "plan-bg"} className={useGpuRenderer ? undefined : "plan-bg"}
fill={useGpuRenderer ? "transparent" : undefined} fill={useGpuRenderer ? "transparent" : undefined}
/> />
{/* Georeferenzierte Luftbilder ZWISCHEN Blatt-Hintergrund und Geometrie:
Modell-Meter × PX_PER_M mit negierter y-Achse (s. toScreen) Bild-Norden
(max.y) oben bei y=max.y·PX; das Bild zeichnet natürlich von oben (kein
Flip). Nur im SVG-Pfad relevant (bei Luftbild ist GPU deaktiviert). */}
{hasBgImages && plan.backgroundImages!.map((img) => {
const w = (img.max.x - img.min.x) * PX_PER_M;
const h = (img.max.y - img.min.y) * PX_PER_M;
if (w <= 0 || h <= 0) return null;
return (
<image
key={img.id}
href={img.src}
x={img.min.x * PX_PER_M}
y={-img.max.y * PX_PER_M}
width={w}
height={h}
opacity={img.opacity}
preserveAspectRatio="none"
pointerEvents="none"
/>
);
})}
{/* 2D-Zeichen-Linien mit drawingId werden gemergt (drawingRuns) als EIN Zug {/* 2D-Zeichen-Linien mit drawingId werden gemergt (drawingRuns) als EIN Zug
gezeichnet gehrte Ecken statt Butt-Cap-Stufe; im Memo übersprungen. */} gezeichnet gehrte Ecken statt Butt-Cap-Stufe; im Memo übersprungen. */}
{primitiveEls} {primitiveEls}
@@ -2114,6 +2208,18 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
pointerEvents="none" pointerEvents="none"
/> />
))} ))}
{/* Auswahl-Hervorhebung der gewählten Kontext-Objekte (Bbox-Pick-Band). */}
{highlightContextObjectPolys.map((poly, i) => (
<polygon
key={`selctx-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Schnitt-/Ansichtslinie (Pick-Band). */} {/* Auswahl-Hervorhebung der gewählten Schnitt-/Ansichtslinie (Pick-Band). */}
{highlightSectionLinePolys.map((poly, i) => ( {highlightSectionLinePolys.map((poly, i) => (
<polygon <polygon
@@ -2332,8 +2438,16 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
hudFields={hudFields ?? null} hudFields={hudFields ?? null}
/> />
)} )}
</svg>
{/* Lineale (oben + links, VW-Stil): Modell-Meter mit Ticks, Beschriftung {/* Lineale (oben + links, VW-Stil): Modell-Meter mit Ticks, Beschriftung
und Cursor-Marker screen-fix über den sichtbaren Ausschnitt gelegt. */} und Cursor-Marker screen-fix über den sichtbaren Ausschnitt gelegt.
BEWUSST ein eigenes Sibling-SVG (nicht mehr Kind von `svgRef`): beim
imperativen GL-Pan (`applySvgTransform`, s.o.) bekommt NUR `svgRef`
während der Geste einen CSS-transform verpasst läge das Lineal
darin, würde es sichtbar mit dem Inhalt mitwandern statt am Rand
fixiert zu bleiben. Als eigenständiges Element bleibt es während der
Geste stehen (aktualisiert erst beim Loslassen, wenn `view` neu
gesetzt wird) exakt das gewünschte statische" Verhalten. */}
{showRulers && (() => { {showRulers && (() => {
const m = measure(view); const m = measure(view);
if (!m.rect) return null; if (!m.rect) return null;
@@ -2345,15 +2459,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
h: (m.rect.height || 1) / scale, h: (m.rect.height || 1) / scale,
}; };
return ( return (
<svg
className="plan-ruler-overlay"
viewBox={`${vb.x0} ${vb.y0} ${vb.w} ${vb.h}`}
preserveAspectRatio="none"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
zIndex: 2,
pointerEvents: "none",
}}
>
<PlanRulers <PlanRulers
vb={vb} vb={vb}
vbPerPx={1 / scale} vbPerPx={1 / scale}
pxPerMeter={scale * PX_PER_M} pxPerMeter={scale * PX_PER_M}
cursor={rulerCursor} cursor={rulerCursor}
/> />
</svg>
); );
})()} })()}
</svg>
</div> </div>
); );
}); });
@@ -2385,7 +2512,7 @@ function DraftOverlay({
} | null; } | null;
}) { }) {
const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich) const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich)
const markSize = 6.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (grösser = besser sichtbar) const markSize = 4.75 * vbPerPx; // halbe Kantenlänge des Snap-Markers (klein + fein)
return ( return (
<g className="tool-overlay" pointerEvents="none"> <g className="tool-overlay" pointerEvents="none">
{/* Vorschau-Formen. */} {/* Vorschau-Formen. */}
@@ -3271,8 +3398,29 @@ function buildDrawingRuns(prims: Primitive[]): DrawingRun[] {
return runs; return runs;
} }
/** Opazität gedimmter („grau") Primitive im Darstellungsmodus „andere grau". */ /**
const GREYED_OPACITY = 0.3; * Fester Grauton gedimmter Primitive im Darstellungsmodus andere grau"
* ECHTE Umfärbung (nicht nur Deckkraft), da dünne Haarlinien auf dem hellen
* Zeichenblatt (--sheet: #f0f0f0) bei reiner Transparenz kaum erkennbar
* gedimmt wirken. Gleicher Ton in allen drei Render-Pfaden (SVG/WebGL/nativ,
* siehe glPlanCompile.ts `GREY_RGB` und toRenderScene.ts `GREY_RGB`).
*/
const GREYED_COLOR = "#8c8c8c";
/** Ersetzt Fill-/Stroke-/Farbfelder (inkl. Schraffur) eines Primitivs durch den festen Grauton. */
function greyColor(c: string | undefined): string | undefined {
return !c || c === "none" || c === "transparent" ? c : GREYED_COLOR;
}
/** Flache Kopie eines Primitivs mit allen Farbfeldern auf `GREYED_COLOR` umgefärbt. */
function greyedCopy<T extends Primitive>(p: T): T {
const q: any = { ...p };
if ("fill" in q) q.fill = greyColor(q.fill);
if ("stroke" in q) q.stroke = greyColor(q.stroke);
if ("color" in q) q.color = greyColor(q.color);
if ("hatch" in q && q.hatch) q.hatch = { ...q.hatch, color: greyColor(q.hatch.color) };
return q;
}
/** Zeichnet einen gebündelten 2D-Zeichen-Zug als EIN Element (gehrte Ecken). */ /** Zeichnet einen gebündelten 2D-Zeichen-Zug als EIN Element (gehrte Ecken). */
function DrawingRunShape({ function DrawingRunShape({
@@ -3315,12 +3463,13 @@ function DrawingRunShape({
// Punktmuster (dash enthält eine 0): runde Kappe, damit die 0-Längen-Segmente // Punktmuster (dash enthält eine 0): runde Kappe, damit die 0-Längen-Segmente
// als Dots erscheinen (Punktlinie / Strich-Punkt). // als Dots erscheinen (Punktlinie / Strich-Punkt).
const cap = dashHasDot(run.dash) ? ("round" as const) : undefined; const cap = dashHasDot(run.dash) ? ("round" as const) : undefined;
const stroke = run.greyed ? greyColor(run.color) : run.color;
const shape = run.closed ? ( const shape = run.closed ? (
<polygon <polygon
points={pts} points={pts}
className={run.cls} className={run.cls}
fill="none" fill="none"
stroke={run.color} stroke={stroke}
strokeWidth={sw} strokeWidth={sw}
strokeDasharray={dashStr} strokeDasharray={dashStr}
strokeLinecap={cap} strokeLinecap={cap}
@@ -3331,14 +3480,14 @@ function DrawingRunShape({
points={pts} points={pts}
className={run.cls} className={run.cls}
fill="none" fill="none"
stroke={run.color} stroke={stroke}
strokeWidth={sw} strokeWidth={sw}
strokeDasharray={dashStr} strokeDasharray={dashStr}
strokeLinecap={cap} strokeLinecap={cap}
vectorEffect={vfx} vectorEffect={vfx}
/> />
); );
return run.greyed ? <g opacity={GREYED_OPACITY}>{shape}</g> : shape; return shape;
} }
function PrimitiveShape({ function PrimitiveShape({
@@ -3354,12 +3503,9 @@ function PrimitiveShape({
hairline: boolean; hairline: boolean;
paperScale: number | null; paperScale: number | null;
}) { }) {
// Gedimmte Elemente bekommen eine niedrige Gruppen-Opazität, sodass Strich // Gedimmte Elemente werden auf den festen Grauton umgefärbt (Strich, Füllung
// UND Füllung gleichmäßig zurücktreten (best-effort, ohne Farbumrechnung). // UND Schraffur) — deutlich erkennbar unabhängig von Papierfarbe/Linienstärke.
const shape = renderPrimitive(p, index, toScreen, hairline, paperScale); const shape = renderPrimitive(p.greyed ? greyedCopy(p) : p, index, toScreen, hairline, paperScale);
if (p.greyed) {
return <g opacity={GREYED_OPACITY}>{shape}</g>;
}
return shape; return shape;
} }
@@ -3887,17 +4033,17 @@ function PlanRulers({
<> <>
<rect <rect
className="plan-ruler-cursor" className="plan-ruler-cursor"
x={cursor.x * PX_PER_M - 1.5 * vbPerPx} x={cursor.x * PX_PER_M - 1 * vbPerPx}
y={y0} y={y0}
width={3 * vbPerPx} width={2 * vbPerPx}
height={TH} height={TH}
/> />
<rect <rect
className="plan-ruler-cursor" className="plan-ruler-cursor"
x={x0} x={x0}
y={-cursor.y * PX_PER_M - 1.5 * vbPerPx} y={-cursor.y * PX_PER_M - 1 * vbPerPx}
width={TH} width={TH}
height={3 * vbPerPx} height={2 * vbPerPx}
/> />
</> </>
)} )}
+206
View File
@@ -0,0 +1,206 @@
// Kontext-Schicht im Grundriss (generatePlan → addContextContours):
// • ImportedMesh (Gebäude) -> Sockelkanten-Fussabdruck (context-line).
// • TerrainMesh -> nur 2D-Bounding-Box-Umriss (4 Linien).
// • Nutzer-Report: importierte Gebäude waren im Plan UNSICHTBAR (nur 3D).
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { Project } from "../model/types";
function projectWith(context: Project["context"]): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context,
} as unknown as Project;
}
const visible = new Set<string>();
function contextLines(p: Project) {
return generatePlan(p, "eg", visible).primitives.filter(
(pr) => pr.kind === "line" && (pr as { cls?: string }).cls === "context-line",
);
}
describe("generatePlan — Kontext-Fussabdruck (ImportedMesh)", () => {
it("zeichnet die Sockelkanten eines importierten Gebäudes als context-line", () => {
// Ein simpler Quader-„Bau": Grundfläche 0..2 × 0..3 bei z=0 (Sockel), Dach
// bei z=5. Zwei Sockel-Dreiecke bilden das Boden-Rechteck.
const p = projectWith([
{
id: "B1",
type: "importedMesh",
name: "Haus",
positions: [
0, 0, 0, 2, 0, 0, 2, 3, 0, 0, 3, 0, // 0..3: Boden (z=0)
1, 1, 5, // 4: Dachspitze (z=5, über der Toleranz)
],
indices: [
0, 1, 2, 0, 2, 3, // Boden (Sockel)
0, 1, 4, // Dachdreieck (nur eine Kante 0-1 am Sockel, 4 ist hoch)
],
},
]);
const lines = contextLines(p);
// Sockelkanten: Rechteck-Umriss 0-1,1-2,2-3,3-0 + Diagonale 0-2 = 5 eindeutige.
// Die hohen Dach-Kanten (0-4, 1-4) fallen weg (z=5 > Toleranz).
expect(lines.length).toBe(5);
// Keine Linie berührt die Dachspitze (1,1) auf z-Höhe — aber XY (1,1) könnte
// von der Diagonale getroffen werden; hier prüfen wir, dass keine Kante zu
// einem Punkt führt, dessen Original-z über der Toleranz lag: alle Endpunkte
// liegen im Boden-Rechteck [0..2]×[0..3].
for (const l of lines) {
if (l.kind !== "line") continue;
for (const pt of [l.a, l.b]) {
expect(pt.x).toBeGreaterThanOrEqual(0);
expect(pt.x).toBeLessThanOrEqual(2);
expect(pt.y).toBeGreaterThanOrEqual(0);
expect(pt.y).toBeLessThanOrEqual(3);
}
}
});
it("entdoppelt geteilte Kanten (kein doppeltes Zeichnen)", () => {
// Zwei Sockel-Dreiecke teilen sich die Kante 0-2 -> darf nur EINMAL erscheinen.
const p = projectWith([
{
id: "B1",
type: "importedMesh",
name: "Haus",
positions: [0, 0, 0, 2, 0, 0, 2, 3, 0, 0, 3, 0],
indices: [0, 1, 2, 0, 2, 3],
},
]);
const lines = contextLines(p);
// 4 Rand + 1 geteilte Diagonale = 5 (nicht 6).
expect(lines.length).toBe(5);
});
});
describe("generatePlan — Kontext-Objekte anklickbar (Pick-Region)", () => {
it("importedMesh bekommt ein unsichtbares Bbox-Pick-Band mit contextObjectId", () => {
const p = projectWith([
{
id: "B1",
type: "importedMesh",
name: "Haus",
positions: [0, 0, 0, 2, 0, 0, 2, 3, 0, 0, 3, 0],
indices: [0, 1, 2, 0, 2, 3],
},
]);
const plan = generatePlan(p, "eg", visible);
const pick = plan.primitives.find(
(pr) => pr.kind === "polygon" && pr.contextObjectId === "B1",
);
expect(pick).toBeDefined();
if (pick?.kind === "polygon") {
expect(pick.fill).toBe("none");
expect(pick.stroke).toBe("none");
// Bbox der Grundfläche: [0..2]×[0..3].
const xs = pick.pts.map((p) => p.x);
const ys = pick.pts.map((p) => p.y);
expect(Math.min(...xs)).toBe(0);
expect(Math.max(...xs)).toBe(2);
expect(Math.min(...ys)).toBe(0);
expect(Math.max(...ys)).toBe(3);
}
});
it("terrainMesh bekommt ebenfalls ein Pick-Band mit contextObjectId", () => {
const p = projectWith([
{
id: "T1",
type: "terrainMesh",
name: "Gelände",
positions: [0, 0, 400, 10, 0, 401, 5, 8, 402],
indices: [0, 1, 2],
},
]);
const plan = generatePlan(p, "eg", visible);
const pick = plan.primitives.find(
(pr) => pr.kind === "polygon" && pr.contextObjectId === "T1",
);
expect(pick).toBeDefined();
});
});
describe("generatePlan — Kontext-Umriss (TerrainMesh)", () => {
it("zeichnet nur die 2D-Bounding-Box (4 Linien), nicht das ganze TIN", () => {
// 3 Vertices spannen ein Dreieck; die Bbox-Ausdehnung ist [0..10]×[0..8].
const p = projectWith([
{
id: "T1",
type: "terrainMesh",
name: "Gelände",
positions: [0, 0, 400, 10, 0, 401, 5, 8, 402],
indices: [0, 1, 2],
},
]);
const lines = contextLines(p);
expect(lines.length).toBe(4);
const xs = lines.flatMap((l) => (l.kind === "line" ? [l.a.x, l.b.x] : []));
const ys = lines.flatMap((l) => (l.kind === "line" ? [l.a.y, l.b.y] : []));
expect(Math.min(...xs)).toBe(0);
expect(Math.max(...xs)).toBe(10);
expect(Math.min(...ys)).toBe(0);
expect(Math.max(...ys)).toBe(8);
});
});
describe("generatePlan — Luftbild (ContextImage)", () => {
it("gibt georeferenzierte Bilder als backgroundImages aus (nicht als Primitive)", () => {
const p = projectWith([
{
id: "IMG1",
type: "image",
name: "Luftbild",
layer: "Luftbild",
src: "data:image/jpeg;base64,AAAA",
min: { x: -100, y: -80 },
max: { x: 100, y: 80 },
},
]);
const plan = generatePlan(p, "eg", visible);
expect(plan.backgroundImages).toHaveLength(1);
expect(plan.backgroundImages![0]).toMatchObject({
id: "IMG1",
src: "data:image/jpeg;base64,AAAA",
min: { x: -100, y: -80 },
max: { x: 100, y: 80 },
opacity: 1,
});
// Kein Bild-Primitiv in den Vektor-Primitiven (DXF/PDF lassen es aus).
expect(plan.primitives.some((pr) => (pr as { kind?: string }).kind === "image")).toBe(false);
});
it("im Mono-Modus wird das Luftbild ausgeblendet (reiner S/W-Plan)", () => {
const p = projectWith([
{
id: "IMG1",
type: "image",
name: "Luftbild",
src: "data:image/jpeg;base64,AAAA",
min: { x: 0, y: 0 },
max: { x: 10, y: 10 },
},
]);
const plan = generatePlan(p, "eg", visible, undefined, "mittel", false, true);
expect(plan.backgroundImages).toHaveLength(0);
});
});
+18
View File
@@ -79,6 +79,24 @@ describe("generatePlan — Schnittführungs-Symbol", () => {
expect(prims.some((pr) => pr.kind === "line" && pr.cls === "section-line")).toBe(false); expect(prims.some((pr) => pr.kind === "line" && pr.cls === "section-line")).toBe(false);
}); });
it("zeichnet NICHTS für eine unsichtbare Schnittebene (visible=false)", () => {
const p = baseProject([
floor,
{
id: "s-hidden",
name: "Schnitt C",
kind: "section",
visible: false,
locked: false,
linePoints: [{ x: 0, y: 2 }, { x: 6, y: 2 }],
directionSign: 1,
},
]);
const prims = generatePlan(p, "eg", visible).primitives;
expect(prims.some((pr) => pr.kind === "polygon" && pr.sectionLineId === "s-hidden")).toBe(false);
expect(prims.some((pr) => pr.kind === "line" && pr.cls === "section-line")).toBe(false);
});
it("behandelt Ansichtsebenen (elevation) gleich", () => { it("behandelt Ansichtsebenen (elevation) gleich", () => {
const p = baseProject([ const p = baseProject([
floor, floor,
+85
View File
@@ -0,0 +1,85 @@
/**
* Treppen-Pfeil-Darstellung (generatePlan addStairSymbol): Default "line"
* zeichnet den Auf-/Abpfeil als zwei offene Linien (cls "stair-arrow");
* `arrowStyle:"filled"` ersetzt sie durch EIN gefülltes Dreieck-Polygon
* (SIA-Vollpfeil).
*/
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { DrawingLevel, Project, Stair } from "../model/types";
const visible = new Set(["40"]);
const floor: DrawingLevel = {
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
};
function baseStair(extra: Partial<Stair> = {}): Stair {
return {
id: "st-1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "gerade",
start: { x: 0, y: 0 },
dir: { x: 1, y: 0 },
runLength: 4,
width: 1.2,
stepCount: 16,
totalRise: 2.6,
...extra,
} as Stair;
}
function project(stair: Stair): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [floor],
layers: [{ code: "40", name: "Treppen", color: "#333", lw: 0.18, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [stair],
rooms: [],
drawings2d: [],
context: [],
} as unknown as Project;
}
describe("generatePlan — Treppen-Pfeil", () => {
it("Default: offener Winkelpfeil = zwei stair-arrow-Linien, kein gefülltes Polygon", () => {
const prims = generatePlan(project(baseStair()), "eg", visible).primitives;
const arrowLines = prims.filter((p) => p.kind === "line" && p.cls === "stair-arrow");
expect(arrowLines.length).toBe(2);
// Keine gefüllte Pfeil-Fläche (Tritt-Polygone tragen fill:"none").
const filled = prims.filter(
(p) => p.kind === "polygon" && p.stairId === "st-1" && p.fill !== "none",
);
expect(filled.length).toBe(0);
});
it("filled: ein gefülltes Dreieck-Polygon, keine stair-arrow-Linien", () => {
const prims = generatePlan(project(baseStair({ arrowStyle: "filled" })), "eg", visible)
.primitives;
expect(prims.some((p) => p.kind === "line" && p.cls === "stair-arrow")).toBe(false);
const filled = prims.filter(
(p) => p.kind === "polygon" && p.stairId === "st-1" && p.fill !== "none",
);
expect(filled.length).toBe(1);
expect(filled[0].kind === "polygon" && filled[0].pts.length).toBe(3);
});
});
+202 -11
View File
@@ -10,6 +10,7 @@ import type {
Ceiling, Ceiling,
Column, Column,
Component, Component,
ContextObject,
Drawing2D, Drawing2D,
ExtrudedSolid, ExtrudedSolid,
HatchPattern, HatchPattern,
@@ -191,19 +192,22 @@ export interface HatchRender {
/** /**
* Render-Entscheidung je Kategorie-Code für den Grundriss (vgl. ItemDisplay aus * Render-Entscheidung je Kategorie-Code für den Grundriss (vgl. ItemDisplay aus
* dem Panel-Darstellungsmodus). `render`=false blendet die Kategorie ganz aus, * dem Panel-Darstellungsmodus). `render`=false blendet die Kategorie ganz aus,
* `greyed`=true zeichnet sie gedimmt. Wird kein Resolver übergeben, gilt für * `greyed`=true zeichnet sie gedimmt, `locked`=true (nur Modus grey_locked")
* jede sichtbare Kategorie {render:true, greyed:false} (bisheriges Verhalten). * verbietet die Anwahl (siehe App.tsx onPlanSelect). Wird kein Resolver
* übergeben, gilt für jede sichtbare Kategorie {render:true, greyed:false,
* locked:false} (bisheriges Verhalten).
*/ */
export interface CategoryDisplay { export interface CategoryDisplay {
render: boolean; render: boolean;
greyed: boolean; greyed: boolean;
locked: boolean;
} }
/** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */ /** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */
export type CategoryDisplayResolver = (code: string) => CategoryDisplay; export type CategoryDisplayResolver = (code: string) => CategoryDisplay;
/** Standard-Resolver: jede (bereits gefilterte) Kategorie normal zeichnen. */ /** Standard-Resolver: jede (bereits gefilterte) Kategorie normal zeichnen. */
const SHOW_ALL: CategoryDisplayResolver = () => ({ render: true, greyed: false }); const SHOW_ALL: CategoryDisplayResolver = () => ({ render: true, greyed: false, locked: false });
export type Primitive = export type Primitive =
| { | {
@@ -257,6 +261,12 @@ export type Primitive =
* Auswahl heute nur zur Nachvollziehbarkeit getaggt, noch nicht verdrahtet). * Auswahl heute nur zur Nachvollziehbarkeit getaggt, noch nicht verdrahtet).
*/ */
extrudedSolidId?: string; extrudedSolidId?: string;
/**
* ID des Kontext-Objekts (importiertes Gebäude/Terrain, `project.context`),
* falls dieses (unsichtbare) Pick-Band dessen Grundriss-Bounding-Box
* umhüllt (Links-Klick-Auswahl analog `sectionLineId`, s. u.).
*/
contextObjectId?: string;
/** /**
* ID der Stütze (Column), falls dieses Polygon der Grundriss-Querschnitt * ID der Stütze (Column), falls dieses Polygon der Grundriss-Querschnitt
* (Poché) einer Stütze ist (für die Links-Klick-Auswahl der Fläche). * (Poché) einer Stütze ist (für die Links-Klick-Auswahl der Fläche).
@@ -628,9 +638,23 @@ export function resolveCeilingSectionStyle(
} }
} }
/** Ein georeferenziertes Hintergrund-Rasterbild (Luftbild) im Grundriss
* Modell-Meter-Rechteck [min..max], das die interaktive Ansicht UNTER der
* Geometrie zeichnet (Kontext-Schicht, `ContextImage`). NICHT Teil der
* Vektor-`primitives` (DXF/PDF-Export lassen es aus). */
export interface PlanImage {
id: string;
src: string;
min: Vec2;
max: Vec2;
opacity: number;
}
export interface Plan { export interface Plan {
primitives: Primitive[]; primitives: Primitive[];
bounds: { minX: number; minY: number; maxX: number; maxY: number }; bounds: { minX: number; minY: number; maxX: number; maxY: number };
/** Hintergrund-Rasterbilder (Luftbild) — nur für die interaktive Ansicht. */
backgroundImages?: PlanImage[];
} }
/** /**
@@ -908,9 +932,18 @@ export function generatePlan(
// Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan. // Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan.
if (mono) toMono(primitives); if (mono) toMono(primitives);
// Georeferenzierte Luftbilder als Hintergrund-Ebenen (nur interaktive Ansicht,
// nicht Teil der Vektor-Primitive) — im Mono-Modus ausgeblendet (reiner S/W-Plan).
const backgroundImages: PlanImage[] = mono
? []
: (project.context ?? [])
.filter((o): o is Extract<ContextObject, { type: "image" }> => o.type === "image")
.map((o) => ({ id: o.id, src: o.src, min: o.min, max: o.max, opacity: o.opacity ?? 1 }));
return { return {
primitives, primitives,
bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms, roofs), bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms, roofs),
backgroundImages,
}; };
} }
@@ -1059,16 +1092,27 @@ const CONTEXT_LINE_COLOR = "#9aa3ad";
/** Strichstärke der Kontext-Konturen (mm Papier, dünn). */ /** Strichstärke der Kontext-Konturen (mm Papier, dünn). */
const CONTEXT_LINE_MM = 0.1; const CONTEXT_LINE_MM = 0.1;
/** Höhen-Toleranz (Meter): Dreieckskanten, deren beide Endpunkte höchstens so
* weit über der Objekt-Unterkante (minZ) liegen, gelten als Sockelkante" und
* bilden den Grundriss-Fussabdruck eines importierten Gebäudes. */
const FOOTPRINT_Z_TOL = 0.6;
/** /**
* Zeichnet die Konturen (Höhenlinien) der Kontext-Schicht `project.context` als * Zeichnet die Kontext-Schicht `project.context` in den Grundriss:
* dünne, gedämpfte Referenz-Linien in den Grundriss. Die Z-Höhe wird ignoriert * ContourSet (Höhenlinien) dünne Referenz-Linien (Z ignoriert).
* (reine 2D-Projektion). TerrainMeshes/ImportedMeshes erscheinen im Plan NICHT * ImportedMesh (Gebäude) GRUNDRISS-FUSSABDRUCK: die Dreieckskanten nahe
* (3D genügt). Rein additiv Wand-/Drawing-Darstellung bleibt unberührt. * der Objekt-Unterkante (Sockel), auf die XY-Ebene projiziert und über
* quantisierte Endpunkte entdoppelt so erscheint das importierte Haus als
* erkennbarer Umriss im Plan (Nutzer-Wunsch: ich sehe es sonst nicht"),
* ohne das gesamte Dach-Drahtgitter dicht darüberzulegen.
* TerrainMesh nur der 2D-Umriss (Bounding-Box), da ein volles TIN-Gitter
* im Plan unbrauchbar dicht wäre.
* Rein additiv Wand-/Drawing-Darstellung bleibt unberührt.
*/ */
function addContextContours(out: Primitive[], project: Project): void { function addContextContours(out: Primitive[], project: Project): void {
const context = project.context ?? []; const context = project.context ?? [];
for (const obj of context) { for (const obj of context) {
if (obj.type !== "contourSet") continue; if (obj.type === "contourSet") {
for (const c of obj.contours) { for (const c of obj.contours) {
const pts = c.pts; const pts = c.pts;
if (pts.length < 2) continue; if (pts.length < 2) continue;
@@ -1084,8 +1128,127 @@ function addContextContours(out: Primitive[], project: Project): void {
}); });
} }
} }
} else if (obj.type === "importedMesh") {
addMeshFootprint(out, obj.positions, obj.indices);
addContextPickRegion(out, obj.positions, obj.id);
} else if (obj.type === "terrainMesh") {
addMeshBboxOutline(out, obj.positions);
addContextPickRegion(out, obj.positions, obj.id);
} }
} }
}
/**
* Zeichnet den Grundriss-Fussabdruck eines Meshes: Dreieckskanten nahe der
* Unterkante (minZ + {@link FOOTPRINT_Z_TOL}), auf XY projiziert, über
* quantisierte Endpunkt-Schlüssel entdoppelt. `positions` = flaches [x,y,z]-
* Array (Modell-Meter, z = Höhe), `indices` = Dreiecks-Indizes.
*/
function addMeshFootprint(out: Primitive[], positions: number[], indices: number[]): void {
const vcount = Math.floor(positions.length / 3);
if (vcount < 3 || indices.length < 3) return;
let minZ = Infinity;
for (let i = 2; i < positions.length; i += 3) if (positions[i] < minZ) minZ = positions[i];
if (!Number.isFinite(minZ)) return;
const zLimit = minZ + FOOTPRINT_Z_TOL;
const q = (v: number) => Math.round(v * 100); // 1-cm-Quantisierung (Entdopplung)
const seen = new Set<string>();
const edge = (ia: number, ib: number) => {
if (ia >= vcount || ib >= vcount) return;
const za = positions[ia * 3 + 2];
const zb = positions[ib * 3 + 2];
if (za > zLimit || zb > zLimit) return; // nur Sockelkanten
const ax = positions[ia * 3], ay = positions[ia * 3 + 1];
const bx = positions[ib * 3], by = positions[ib * 3 + 1];
// Kanten-Schlüssel richtungsunabhängig (kleinerer Endpunkt zuerst).
const ka = `${q(ax)},${q(ay)}`;
const kb = `${q(bx)},${q(by)}`;
if (ka === kb) return;
const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
if (seen.has(key)) return;
seen.add(key);
out.push({
kind: "line",
a: { x: ax, y: ay },
b: { x: bx, y: by },
cls: "context-line",
weightMm: CONTEXT_LINE_MM,
color: CONTEXT_LINE_COLOR,
});
};
for (let i = 0; i + 2 < indices.length; i += 3) {
const a = indices[i], b = indices[i + 1], c = indices[i + 2];
edge(a, b);
edge(b, c);
edge(c, a);
}
}
/** Zeichnet nur den achsenparallelen 2D-Umriss (Bounding-Box) eines Meshes
* für Terrain, dessen volles TIN-Gitter im Plan zu dicht wäre. */
function addMeshBboxOutline(out: Primitive[], positions: number[]): void {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i + 2 < positions.length; i += 3) {
const x = positions[i], y = positions[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
if (!Number.isFinite(minX)) return;
const corners = [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
];
for (let i = 0; i < 4; i++) {
out.push({
kind: "line",
a: corners[i],
b: corners[(i + 1) % 4],
cls: "context-line",
weightMm: CONTEXT_LINE_MM,
color: CONTEXT_LINE_COLOR,
});
}
}
/**
* Unsichtbares Pick-Band (Grundriss-Bounding-Box) für ein Kontext-Objekt
* (importiertes Gebäude/Terrain) klickbar per Punkt-in-Polygon in PlanView,
* trägt die `ContextObject.id` (analog `sectionLineId`, s. dort). Macht das
* Objekt IRGENDWO innerhalb seiner Grundfläche anklickbar (nicht nur exakt auf
* der dünnen Umriss-/Fussabdrucklinie) wie bei Wänden, deren Poché-Fläche
* ebenfalls als Ganzes klickbar ist (Nutzer-Wunsch: eigene Elemente wie Wände").
* `positions` = flaches [x,y,z]-Array (Modell-Meter); die Bbox nutzt NUR x/y,
* unabhängig von der Höhe.
*/
function addContextPickRegion(out: Primitive[], positions: number[], id: string): void {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i + 2 < positions.length; i += 3) {
const x = positions[i], y = positions[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
if (!Number.isFinite(minX)) return;
out.push({
kind: "polygon",
pts: [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
],
fill: "none",
stroke: "none",
strokeWidthMm: 0,
hatch: NO_HATCH,
contextObjectId: id,
});
}
/** /**
* Post-Pass für den Schwarz-Weiss-Modus: ersetzt alle Farb-Informationen der * Post-Pass für den Schwarz-Weiss-Modus: ersetzt alle Farb-Informationen der
@@ -2736,6 +2899,10 @@ function sectionLabelTag(name: string): string {
function addSectionLines(out: Primitive[], project: Project): void { function addSectionLines(out: Primitive[], project: Project): void {
for (const level of project.drawingLevels) { for (const level of project.drawingLevels) {
if (level.kind !== "section" && level.kind !== "elevation") continue; if (level.kind !== "section" && level.kind !== "elevation") continue;
// Schnittebene ist ein-/ausblendbar wie andere Ebenen: eine unsichtbare
// Schnitt-/Ansichtsebene zeigt ihre Linie im Grundriss nicht (Nutzer-Vision
// „eigene Ebene Schnittebenen"). `visible` fehlt = sichtbar (Alt-Projekte).
if (level.visible === false) continue;
const line = level.linePoints; const line = level.linePoints;
if (!line) continue; if (!line) continue;
const [p0, p1] = line; const [p0, p1] = line;
@@ -3053,10 +3220,24 @@ function addStairSymbol(
stairId, stairId,
}); });
} }
// Auf-/Abpfeil an der Spitze der Lauflinie. // Auf-/Abpfeil an der Spitze der Lauflinie. "filled" = gefülltes Dreieck
// (SIA-Vollpfeil), sonst der offene Winkelpfeil (zwei Linien, Default).
const [h1, tip, h2] = geo.arrow.head; const [h1, tip, h2] = geo.arrow.head;
if (stair.arrowStyle === "filled") {
out.push({
kind: "polygon",
pts: [h1, tip, h2],
fill: stroke,
stroke,
strokeWidthMm: lwMm,
hatch: NO_HATCH,
greyed,
stairId,
});
} else {
out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId }); out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId }); out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
}
// Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L) // Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L)
// bzw. Innen-/Aussenbogen + radiale Schliesslinien (Wendel). // bzw. Innen-/Aussenbogen + radiale Schliesslinien (Wendel).
@@ -3121,10 +3302,20 @@ function computeBounds(
acc({ x: g.center.x + g.r, y: g.center.y + g.r }); acc({ x: g.center.x + g.r, y: g.center.y + g.r });
} else if (g.shape === "text") acc(g.at); } else if (g.shape === "text") acc(g.at);
} }
// Kontext-Konturen mit einrahmen, damit „Einpassen" sie ebenfalls zeigt. // GESAMTE Kontext-Schicht (Konturen, importierte Gebäude/Terrain, Luftbild)
// mit einrahmen, damit „Einpassen"/die Standard-Ansicht sie zeigt — sonst
// sässe die Ansicht bei einem reinen Kontext-Import (keine Wände) leer am
// Ursprung und der Import wäre „unsichtbar" (Nutzer-Report).
for (const obj of project.context ?? []) { for (const obj of project.context ?? []) {
if (obj.type !== "contourSet") continue; if (obj.type === "contourSet") {
for (const c of obj.contours) for (const p of c.pts) acc(p); for (const c of obj.contours) for (const p of c.pts) acc(p);
} else if (obj.type === "image") {
acc(obj.min);
acc(obj.max);
} else {
const pos = obj.positions;
for (let i = 0; i + 1 < pos.length; i += 3) acc({ x: pos[i], y: pos[i + 1] });
}
} }
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 1, maxY: 1 }; if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 1, maxY: 1 };
return { minX, minY, maxX, maxY }; return { minX, minY, maxX, maxY };
+22 -7
View File
@@ -129,6 +129,18 @@ function parseColor(hex: string | undefined): Rgba | null {
return [r / 255, g / 255, b / 255, a]; return [r / 255, g / 255, b / 255, a];
} }
/**
* Fester Grauton gedimmter (greyed) Primitive ECHTE Umfärbung statt
* Deckkraft (dünne Haarlinien auf dem hellen Zeichenblatt wären bei reiner
* Transparenz kaum erkennbar gedimmt). Gleicher Ton wie GREYED_COLOR in
* PlanView.tsx / GREY_RGB in toRenderScene.ts (#8c8c8c 0.549).
*/
const GREY_RGB = 0.549;
/** Färbt auf den festen Grauton um, wenn das Primitiv im Darstellungsmodus „grau" ist. */
function dim(color: Rgba, greyed: boolean | undefined): Rgba {
return greyed ? [GREY_RGB, GREY_RGB, GREY_RGB, color[3]] : color;
}
/** /**
* Tessellliert Plan-Primitive zu GPU-Geometrie (MVP: gefüllte Polygone + * Tessellliert Plan-Primitive zu GPU-Geometrie (MVP: gefüllte Polygone +
@@ -387,7 +399,7 @@ export function compilePrimitivesToGpu(
track(pt.x, pt.y); track(pt.x, pt.y);
} }
for (const t of tris) fillIdx.push(base + t); for (const t of tris) fillIdx.push(base + t);
addFillBatch(tris.length, fill); addFillBatch(tris.length, dim(fill, prim.greyed));
} }
} }
// Schraffur (nach der Füllung, vor/passend zum Umriss — wie der SVG stapelt): // Schraffur (nach der Füllung, vor/passend zum Umriss — wie der SVG stapelt):
@@ -414,7 +426,7 @@ export function compilePrimitivesToGpu(
prim.hatch.pattern !== 'none' && prim.hatch.pattern !== 'none' &&
prim.hatch.pattern !== 'solid' prim.hatch.pattern !== 'solid'
) { ) {
const hatchColor = parseColor(prim.hatch.color) ?? [0.1, 0.1, 0.1, 1]; const hatchColor = dim(parseColor(prim.hatch.color) ?? [0.1, 0.1, 0.1, 1], prim.greyed);
const hatchMm = prim.hatch.lineWeight > 0 ? prim.hatch.lineWeight : 0.13; const hatchMm = prim.hatch.lineWeight > 0 ? prim.hatch.lineWeight : 0.13;
const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash); const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash);
for (const run of runs) strokePolyline(run, false, hatchColor, hatchMm, false); for (const run of runs) strokePolyline(run, false, hatchColor, hatchMm, false);
@@ -424,10 +436,10 @@ export function compilePrimitivesToGpu(
// zoomrichtig in px (wie SVG-printStrokeVb → GL == SVG). // zoomrichtig in px (wie SVG-printStrokeVb → GL == SVG).
const stroke = parseColor(prim.stroke); const stroke = parseColor(prim.stroke);
if (stroke && prim.strokeWidthMm > 0 && prim.pts.length >= 2) { if (stroke && prim.strokeWidthMm > 0 && prim.pts.length >= 2) {
strokePolygonOutline(prim.pts, stroke, prim.strokeWidthMm, prim.noStrokeEdges); strokePolygonOutline(prim.pts, dim(stroke, prim.greyed), prim.strokeWidthMm, prim.noStrokeEdges);
} }
} else if (prim.kind === 'line') { } else if (prim.kind === 'line') {
const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1]; const color = dim(parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1], prim.greyed);
if (prim.zigzag) { if (prim.zigzag) {
// Zickzack-Linie → gehrter Polylinienzug (amplitude/wavelength Papier-mm // Zickzack-Linie → gehrter Polylinienzug (amplitude/wavelength Papier-mm
// → Modell-Meter über dieselbe Kopplung wie Strichmuster). // → Modell-Meter über dieselbe Kopplung wie Strichmuster).
@@ -444,7 +456,10 @@ export function compilePrimitivesToGpu(
} }
} else if (prim.kind === 'arc') { } else if (prim.kind === 'arc') {
// Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen). // Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen).
const color = parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1]; const color = dim(
parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1],
prim.greyed,
);
const strokeMm = prim.weightMm || 0.18; const strokeMm = prim.weightMm || 0.18;
const c = prim.center; const c = prim.center;
const a0 = Math.atan2(prim.from.y - c.y, prim.from.x - c.x); const a0 = Math.atan2(prim.from.y - c.y, prim.from.x - c.x);
@@ -482,7 +497,7 @@ export function compilePrimitivesToGpu(
// kein Kreis-Primitiv → hier bildschirm-adaptiv tessellieren (wie 'arc'). // kein Kreis-Primitiv → hier bildschirm-adaptiv tessellieren (wie 'arc').
// Kreis = voller Umlauf (geschlossen, optional gefüllt), Bogen = a0..a1 (CCW, // Kreis = voller Umlauf (geschlossen, optional gefüllt), Bogen = a0..a1 (CCW,
// offen). OHNE diesen Zweig blieben gezeichnete Kreise/Bögen im GL unsichtbar. // offen). OHNE diesen Zweig blieben gezeichnete Kreise/Bögen im GL unsichtbar.
const stroke = parseColor(prim.stroke) ?? [0.1, 0.1, 0.1, 1]; const stroke = dim(parseColor(prim.stroke) ?? [0.1, 0.1, 0.1, 1], prim.greyed);
const strokeMm = prim.weightMm || 0.18; const strokeMm = prim.weightMm || 0.18;
const c = prim.center; const c = prim.center;
const TAU = Math.PI * 2; const TAU = Math.PI * 2;
@@ -519,7 +534,7 @@ export function compilePrimitivesToGpu(
track(pt.x, pt.y); track(pt.x, pt.y);
} }
for (const t of tris) fillIdx.push(base + t); for (const t of tris) fillIdx.push(base + t);
addFillBatch(tris.length, fill); addFillBatch(tris.length, dim(fill, prim.greyed));
} }
} }
} }
+12 -6
View File
@@ -132,20 +132,26 @@ const CLS_STROKE: Record<string, string> = {
"door-swing": "#1a1a1a", "door-swing": "#1a1a1a",
"door-frame": "#1a1a1a", "door-frame": "#1a1a1a",
"window-glass": "#1a1a1a", "window-glass": "#1a1a1a",
"wall-axis": "#2f5d54", // var(--accent) des hellen Standard-Themes "wall-axis": "#4a453e", // var(--accent) des hellen Standard-Themes
}; };
const CLS_OPACITY: Record<string, number> = { const CLS_OPACITY: Record<string, number> = {
"wall-axis": 0.85, "wall-axis": 0.85,
"context-line": 0.7, "context-line": 0.7,
}; };
/** Deckkraft gedimmter (greyed) Primitive — wie GREYED_OPACITY in PlanView. */ /**
const GREYED_ALPHA = 0.3; * Fester Grauton gedimmter (greyed) Primitive ECHTE Umfärbung statt
* Deckkraft (dünne Haarlinien auf dem hellen Zeichenblatt wären bei reiner
* Transparenz kaum erkennbar gedimmt). Gleicher Ton wie GREYED_COLOR in
* PlanView.tsx / GREY_RGB in glPlanCompile.ts (#8c8c8c 0.549).
*/
const GREY_RGB = 0.549;
/** Farbe mit Klassen-Deckkraft und Greyed-Dimmung multiplizieren. */ /** Farbe mit Klassen-Deckkraft multiplizieren, bei Greyed-Dimmung auf den festen Grauton umfärben. */
function withOpacity(col: RRgba, cls: string | undefined, greyed: boolean | undefined): RRgba { function withOpacity(col: RRgba, cls: string | undefined, greyed: boolean | undefined): RRgba {
const o = (cls ? CLS_OPACITY[cls] ?? 1 : 1) * (greyed ? GREYED_ALPHA : 1); const o = cls ? CLS_OPACITY[cls] ?? 1 : 1;
return o === 1 ? col : [col[0], col[1], col[2], col[3] * o]; const rgb: RRgba = greyed ? [GREY_RGB, GREY_RGB, GREY_RGB, col[3]] : col;
return o === 1 ? rgb : [rgb[0], rgb[1], rgb[2], rgb[3] * o];
} }
/** Strichfarbe einer line/arc-Primitive: CSS-Klassen-Farbe > Modell-Farbe > Tinte. */ /** Strichfarbe einer line/arc-Primitive: CSS-Klassen-Farbe > Modell-Farbe > Tinte. */
+110
View File
@@ -216,6 +216,69 @@ describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:t
} }
}); });
it("innerLayerRecess: nur die INNERSTE Schicht weicht rundum um ihre Dicke zurück, andere Schichten bündig", () => {
// Isolierte Wand W1 (wallTypeId "aw" = 4 Schichten aussen->innen: render-ext,
// insulation, brick, render-int) ohne Nachbarn (keine Gehrung), EIN Fenster
// mit `innerLayerRecess: true`. Die innerste Schicht (render-int, 0.015 m) muss
// an DIESEM Fenster ein rundum um 0.015 m grösseres Loch bekommen; die drei
// äusseren Schichten bleiben bei der bündigen (unveränderten) Öffnungsgrösse.
const w1 = sampleProject.walls.find((w) => w.id === "W1")!;
const p: Project = {
...sampleProject,
walls: [w1],
doors: [],
ceilings: [],
context: [],
windowTypes: [
...(sampleProject.windowTypes ?? []),
{
id: "win-recess-test",
name: "Test Schichteinzug",
kind: "fest",
wingCount: 1,
glazing: "einfach",
frameThickness: 0.06,
frameWidth: 0.06,
defaultSillHeight: 0.9,
defaultWidth: 1.0,
defaultHeight: 1.2,
innerLayerRecess: true,
},
],
openings: [
{
id: "OR",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
typeId: "win-recess-test",
position: 2.0,
width: 1.0,
height: 1.2,
sillHeight: 0.9,
},
],
};
const bodies = projectToWalls3d(p, true).filter((w) => w.wallId === "W1");
expect(bodies.length).toBe(4); // 4 Schicht-Bänder, keine Gehrung/Span-Brüche
const withHoles = bodies.filter((b) => b.holes && b.holes.length === 1);
expect(withHoles.length).toBe(4);
// Innerste Schicht = kleinster |offset| Richtung Innenseite -> hier einfach
// die mit der GRÖSSTEN Loch-Fläche identifizieren (der Rücksprung macht ihr
// Loch strikt grösser als die der anderen drei, bündigen Schichten).
const area = (h: { from: number; to: number; zBottom: number; zTop: number }) =>
(h.to - h.from) * (h.zTop - h.zBottom);
const holeAreas = withHoles.map((b) => area(b.holes![0]));
const sorted = [...holeAreas].sort((a, b) => a - b);
// 3 bündige Schichten: identische Lochfläche (1.0 × 1.2 = 1.2 m²).
expect(sorted[0]).toBeCloseTo(1.2, 6);
expect(sorted[1]).toBeCloseTo(1.2, 6);
expect(sorted[2]).toBeCloseTo(1.2, 6);
// Innerste Schicht: rundum +0.015 m (render-int-Dicke) -> (1.0+0.03)×(1.2+0.03).
expect(sorted[3]).toBeCloseTo(1.03 * 1.23, 6);
});
it("layered=false (Schnitt-Pfad): unverändert segmentiert, KEINE holes", () => { it("layered=false (Schnitt-Pfad): unverändert segmentiert, KEINE holes", () => {
const w1 = projectToWalls3d(twoOffsetWindows, false).filter((w) => w.wallId === "W1"); const w1 = projectToWalls3d(twoOffsetWindows, false).filter((w) => w.wallId === "W1");
// Der Schnitt-Pfad zerlegt weiter in Pfeiler/Brüstung/Sturz-Segmente (mehr // Der Schnitt-Pfad zerlegt weiter in Pfeiler/Brüstung/Sturz-Segmente (mehr
@@ -354,6 +417,26 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
expect(a.max).toBeLessThanOrEqual(b.min + 1e-9); expect(a.max).toBeLessThanOrEqual(b.min + 1e-9);
}); });
it("Legacy-Tür (project.doors, ohne Öffnungs-Typsystem) bekommt ein Blatt-Panel statt eines blanken Lochs", () => {
// D1 ist eine LEGACY-Tür (project.doors, kein typeId/Öffnungs-Typsystem) —
// vor der Behebung durchliefen emitOpeningGlass/emitOpeningFrames nur
// project.openings, D1 blieb im 3D ein reines Loch in der Wand (Nutzer-
// Report: "Türen fehlen im 3D komplett"). DOOR_LEAF_RGB = [0.93,0.9,0.85].
const { meshes } = projectToModel3d(sampleProject);
const leafMeshes = meshes.filter(
(m) => m.color[0] === 0.93 && m.color[1] === 0.9 && m.color[2] === 0.85,
);
expect(leafMeshes.length).toBe(1);
const leaf = leafMeshes[0];
expect(leaf.positions.length).toBe(24); // Quader: 8 Ecken
expect(leaf.indices.length).toBe(36); // 12 Dreiecke
// D1: height 2.1, sitzt am Wand-UK (0) -> Blatt UK 0 .. OK 2.1.
const zs: number[] = [];
for (let i = 2; i < leaf.positions.length; i += 3) zs.push(leaf.positions[i]);
expect(Math.min(...zs)).toBeCloseTo(0, 6);
expect(Math.max(...zs)).toBeCloseTo(2.1, 6);
});
it("Türen bekommen keine Scheibe; je Fenster genau 2 (window-standard = zweifach verglast)", () => { it("Türen bekommen keine Scheibe; je Fenster genau 2 (window-standard = zweifach verglast)", () => {
// Volles Sample-Projekt: 2 Fenster (O1, O2, beide Typ "window-standard" = // Volles Sample-Projekt: 2 Fenster (O1, O2, beide Typ "window-standard" =
// Zweifachverglasung) + 1 Tür (D1), Kontext leer. Neben den Glasscheiben // Zweifachverglasung) + 1 Tür (D1), Kontext leer. Neben den Glasscheiben
@@ -1825,6 +1908,33 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
expect(Math.max(...allHeights)).toBeCloseTo(expectedRidgeZ, 6); expect(Math.max(...allHeights)).toBeCloseTo(expectedRidgeZ, 6);
}); });
it("Dach mit Bauteil-Material (roofTypeId) bekommt eine Textur-Ebene (Stil 'Textured')", () => {
// Vorher waren Dächer im Textured-Modus IMMER auf das Fallback-Schachbrett
// geklemmt (Nutzer-Report: "Dach soll wie Dachziegel aussehen, nicht so
// billig"). Mehrschichtiges Dach über `roofTypeId: "roof-warm"` (sample-
// project), äusserste Schicht = "roof-tiles" (echtes Bibliotheks-Material).
const roof: Roof = { ...baseRoof, roofTypeId: "roof-warm" };
const project: Project = { ...sampleProject, roofs: [roof] };
const { meshes } = projectToModel3d(project);
// Die äusserste (Eindeckungs-)Schicht hat KEINE Roof.color-Übersteuerung ->
// trägt eine aufgelöste materialIndex (nicht mehr die ROOF_RGB-Testfarbe,
// deshalb hier nicht über isRoofMesh gefiltert, sondern über materialIndex).
const withMaterial = meshes.filter((m) => typeof m.materialIndex === "number");
expect(withMaterial.length).toBeGreaterThan(0);
expect(withMaterial.every((m) => (m.materialIndex ?? 0) >= 1)).toBe(true);
});
it("Roof.color-Übersteuerung unterdrückt die Material-Textur (bewusst einheitliche Farbe)", () => {
const roof: Roof = { ...baseRoof, roofTypeId: "roof-warm", color: "#ff0000" };
const project: Project = { ...sampleProject, roofs: [roof] };
const { meshes } = projectToModel3d(project);
const roofRedMeshes = meshes.filter(
(m) => m.color[0] === 1 && m.color[1] === 0 && m.color[2] === 0,
);
expect(roofRedMeshes.length).toBeGreaterThan(0);
expect(roofRedMeshes.every((m) => m.materialIndex === undefined)).toBe(true);
});
it("Walmdach: vier Dachflächen (2 Quader + 2 Dreiecke) korrekt trianguliert", () => { it("Walmdach: vier Dachflächen (2 Quader + 2 Dreiecke) korrekt trianguliert", () => {
const walmRoof: Roof = { ...baseRoof, id: "R2", shape: "walm" }; const walmRoof: Roof = { ...baseRoof, id: "R2", shape: "walm" };
const project: Project = { ...sampleProject, roofs: [walmRoof] }; const project: Project = { ...sampleProject, roofs: [walmRoof] };
+107 -9
View File
@@ -222,6 +222,11 @@ export function wallMaterialColorMaps(project: Project): string[] {
// Kein auflösbarer Deckentyp. // Kein auflösbarer Deckentyp.
} }
} }
for (const r of project.roofs ?? []) {
for (const l of roofLayers(project, r)) {
if (l.componentId) consider(l.componentId);
}
}
materialMapCache.set(project, list); materialMapCache.set(project, list);
return list; return list;
} }
@@ -367,6 +372,16 @@ export interface RMesh {
indices: number[]; indices: number[];
kind: RMeshKind; kind: RMeshKind;
color: RRgb; color: RRgb;
/** Optionale Pro-Vertex-Albedo (flach `[r,g,b, ]`, 0..1) ein Wert je
* Positions-Vertex. Damit wird z. B. ein SWISSIMAGE-Luftbild aufs Gelände-TIN
* drapiert" (jeder Vertex trägt seinen Bildpunkt). Fehlt/zu kurz -> `color`
* fürs ganze Mesh. Matcht `render3d::types::MeshInput.vertexColors`. */
vertexColors?: number[];
/** Material-Textur-Ebene (1-basiert, siehe {@link wallMaterialColorMaps}) für
* den Stil Textured" bislang nur von Dach-Schichten gesetzt. Fehlt sie,
* zeigt render3d das Fallback-Schachbrett. Matcht
* `render3d::types::MeshInput.materialIndex`. */
materialIndex?: number;
} }
/** Modell-Bündel für den nativen 3D-Renderer (Wände + Decken + Kontext-Meshes). */ /** Modell-Bündel für den nativen 3D-Renderer (Wände + Decken + Kontext-Meshes). */
@@ -1138,12 +1153,22 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
// • Öffnungen (project.openings, Fenster mit Brüstung/Sturz), // • Öffnungen (project.openings, Fenster mit Brüstung/Sturz),
// • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz). // • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz).
// Jeweils [from..to] auf die Achse geklemmt + vertikale Öffnungs-Ausdehnung. // Jeweils [from..to] auf die Achse geklemmt + vertikale Öffnungs-Ausdehnung.
const cutouts: Array<{ from: number; to: number; oBottom: number; oTop: number }> = []; const cutouts: Array<{
from: number;
to: number;
oBottom: number;
oTop: number;
/** Schicht-Rücksprung der innersten Schicht an DIESER Öffnung (nur Fenster
* mit `WindowType.innerLayerRecess`), s. {@link emitWall}-Moduldoc unten. */
recessInnerLayer: boolean;
}> = [];
for (const op of openingsOfWall(project, wall.id)) { for (const op of openingsOfWall(project, wall.id)) {
const iv = openingInterval(wall, op); const iv = openingInterval(wall, op);
if (!iv) continue; if (!iv) continue;
const v = openingVerticalExtent(project, wall, op); const v = openingVerticalExtent(project, wall, op);
cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop }); const recessInnerLayer =
op.kind === "window" && getWindowType(project, op)?.innerLayerRecess === true;
cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop, recessInnerLayer });
} }
for (const d of project.doors ?? []) { for (const d of project.doors ?? []) {
if (d.hostWallId !== wall.id) continue; if (d.hostWallId !== wall.id) continue;
@@ -1152,7 +1177,7 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
if (to - from < EPS) continue; if (to - from < EPS) continue;
// Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe. // Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe.
const oTop = Math.min(zTop, zBottom + d.height); const oTop = Math.min(zTop, zBottom + d.height);
cutouts.push({ from, to, oBottom: zBottom, oTop }); cutouts.push({ from, to, oBottom: zBottom, oTop, recessInnerLayer: false });
} }
cutouts.sort((a, b) => a.from - b.from); cutouts.sort((a, b) => a.from - b.from);
@@ -1174,11 +1199,12 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
// auf dessen Achsen-Start umgerechnet (rebased), damit render3d sie relativ zum // auf dessen Achsen-Start umgerechnet (rebased), damit render3d sie relativ zum
// jeweiligen Box-Start korrekt stanzt. // jeweiligen Box-Start korrekt stanzt.
if (layered) { if (layered) {
const holes: RHole[] = cutouts.map((c) => ({ const holes: (RHole & { recessInnerLayer: boolean })[] = cutouts.map((c) => ({
from: c.from, from: c.from,
to: c.to, to: c.to,
zBottom: c.oBottom, zBottom: c.oBottom,
zTop: c.oTop, zTop: c.oTop,
recessInnerLayer: c.recessInnerLayer,
})); }));
const startCut = cuts?.startCut ?? null; const startCut = cuts?.startCut ?? null;
const endCut = cuts?.endCut ?? null; const endCut = cuts?.endCut ?? null;
@@ -1240,11 +1266,26 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
} }
// Öffnungs-Löcher, die ein z-Teilstück vertikal berühren, auf den Box-Start // Öffnungs-Löcher, die ein z-Teilstück vertikal berühren, auf den Box-Start
// `a` umrechnen (render3d klemmt u/z selbst auf die Box). // `a` umrechnen (render3d klemmt u/z selbst auf die Box). An der INNERSTEN
// Schicht (li = letztes Band) weicht das Loch bei Fenstern mit
// `WindowType.innerLayerRecess` rundum um die eigene Schichtdicke zurück
// (Schicht-Rücksprung an der Laibung) — die anderen Schichten bleiben bündig.
const isInnermostBand = li === bands.length - 1;
const recessDepth = b.thickness;
const emitPiece = (a: number, bb: number, zb: number, zt: number): void => { const emitPiece = (a: number, bb: number, zb: number, zt: number): void => {
const segHoles = holes const segHoles = holes
.filter((h) => h.zTop > zb + EPS && h.zBottom < zt - EPS) .filter((h) => h.zTop > zb + EPS && h.zBottom < zt - EPS)
.map((h) => ({ from: h.from - a, to: h.to - a, zBottom: h.zBottom, zTop: h.zTop })); .map((h) => {
const widen = isInnermostBand && h.recessInnerLayer;
return widen
? {
from: h.from - a - recessDepth,
to: h.to - a + recessDepth,
zBottom: h.zBottom - recessDepth,
zTop: h.zTop + recessDepth,
}
: { from: h.from - a, to: h.to - a, zBottom: h.zBottom, zTop: h.zTop };
});
pushSegment(out, wall, a, bb, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, segHoles, bandCutMeta(b)); pushSegment(out, wall, a, bb, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, segHoles, bandCutMeta(b));
}; };
@@ -1868,6 +1909,13 @@ function emitRoofs(project: Project): RMesh[] {
const color = const color =
overrideColor ?? overrideColor ??
(layer.componentId ? hexToRgb(getComponent(project, layer.componentId).color, ROOF_RGB) : ROOF_RGB); (layer.componentId ? hexToRgb(getComponent(project, layer.componentId).color, ROOF_RGB) : ROOF_RGB);
// Material-Textur-Ebene fürs Textured-Rendering (z. B. echte Dachziegel-
// Textur der Eindeckung) — nur wenn KEIN Farb-Override greift (Override
// = bewusst eine einheitliche Farbe für alle Schichten, kein Bauteil-Bezug).
const materialIndex =
!overrideColor && layer.componentId
? resolveComponentMaterialLayer(project, layer.componentId)
: undefined;
const positions: number[] = []; const positions: number[] = [];
const indices: number[] = []; const indices: number[] = [];
for (const plane of g.planes) { for (const plane of g.planes) {
@@ -1881,7 +1929,7 @@ function emitRoofs(project: Project): RMesh[] {
]); ]);
pushRoofSlab(positions, indices, top, t); pushRoofSlab(positions, indices, top, t);
} }
if (indices.length > 0) out.push({ positions, indices, kind: "extrusion", color }); if (indices.length > 0) out.push({ positions, indices, kind: "extrusion", color, materialIndex });
return offsetAbove + t; return offsetAbove + t;
}, 0); }, 0);
// Giebel (Endfüllung) als dünne Fläche, äusserste Schichtfarbe/Override. // Giebel (Endfüllung) als dünne Fläche, äusserste Schichtfarbe/Override.
@@ -1919,6 +1967,13 @@ function emitMeshes(project: Project): RMesh[] {
indices: obj.indices, indices: obj.indices,
kind: obj.type === "terrainMesh" ? "terrain" : "imported", kind: obj.type === "terrainMesh" ? "terrain" : "imported",
color: obj.type === "terrainMesh" ? TERRAIN_RGB : IMPORTED_RGB, color: obj.type === "terrainMesh" ? TERRAIN_RGB : IMPORTED_RGB,
// Luftbild-Drape: das Gelände-TIN kann Pro-Vertex-Farben tragen (beim
// Import aus dem Luftbild gesampelt) — dann zeigt render3d das Ortho
// statt der Flächen-Grundfarbe.
vertexColors:
obj.type === "terrainMesh" && obj.vertexColors?.length
? obj.vertexColors
: undefined,
}); });
} }
} }
@@ -2645,6 +2700,48 @@ function emitOpeningGlass(project: Project): RMesh[] {
return out; return out;
} }
/**
* Emittiert je LEGACY-Tür (`project.doors` das alte flache Türfeld VOR dem
* Öffnungs-Typsystem, z. B. `Door.hostWallId/position/width/height` ohne
* `typeId`) EIN Blatt-Panel als rohen Quader. `emitWall` schneidet für diese
* Türen zwar bereits ein Loch in die Wand, aber `emitOpeningGlass`/
* `emitOpeningFrames` durchlaufen NUR `project.openings` (das neue Typsystem)
* ohne diese Funktion blieb eine Legacy-Tür ein blankes Loch, durch das man
* hindurchsieht (Nutzer-Report: "Türen fehlen im 3D komplett"). Analog zum
* Alt-Verhalten typloser Fenster ({@link emitOpeningGlass}: EINE Vollscheibe
* ohne Rahmen) hier EIN Vollblatt ohne Rahmen/Zarge (das Rahmen-System gehört
* exklusiv zu `project.openings`, s. {@link resolveOpeningFrame}). `positions`
* in MODELL-Metern wie {@link emitMeshes}.
*/
function emitLegacyDoorLeaves(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const wall of project.walls) {
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9) continue;
const { zBottom } = wallVerticalExtent(project, wall);
for (const d of project.doors ?? []) {
if (d.hostWallId !== wall.id) continue;
const from = Math.max(0, Math.min(d.position, axisLen));
const to = Math.max(from, Math.min(d.position + d.width, axisLen));
if (to - from <= EPS) continue;
const zTop = zBottom + d.height;
if (zTop - zBottom <= EPS) continue;
const mesh = openingAxisBox(
wall,
from,
to,
zBottom,
zTop,
-DOOR_LEAF_THICK / 2,
DOOR_LEAF_THICK / 2,
DOOR_LEAF_RGB,
);
if (mesh) out.push(mesh);
}
}
return out;
}
/** /**
* Emittiert je Öffnung MIT auflösbarem Typ ({@link getDoorType}/ * Emittiert je Öffnung MIT auflösbarem Typ ({@link getDoorType}/
* {@link getWindowType}) die Rahmen-/Sprossen-Riegel (Zarge/Blendrahmen, Sturz, * {@link getWindowType}) die Rahmen-/Sprossen-Riegel (Zarge/Blendrahmen, Sturz,
@@ -2803,6 +2900,7 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R
meshes: [ meshes: [
...emitMeshes(project), ...emitMeshes(project),
...emitOpeningGlass(project), ...emitOpeningGlass(project),
...emitLegacyDoorLeaves(project),
...emitOpeningFrames(project, detail), ...emitOpeningFrames(project, detail),
...emitOpeningShading(project, detail), ...emitOpeningShading(project, detail),
...emitStairMeshes(project), ...emitStairMeshes(project),
@@ -2888,7 +2986,7 @@ export function pickGeometry(
// Indizes robust überspringen statt abzustürzen (importierte Daten). // Indizes robust überspringen statt abzustürzen (importierte Daten).
const contextMeshes: PickContextMesh[] = []; const contextMeshes: PickContextMesh[] = [];
for (const obj of project.context ?? []) { for (const obj of project.context ?? []) {
if (obj.type === "contourSet") continue; if (obj.type === "contourSet" || obj.type === "image") continue;
const pos = obj.positions; const pos = obj.positions;
const vcount = pos.length / 3; const vcount = pos.length / 3;
const tris: [RoofPickVec3, RoofPickVec3, RoofPickVec3][] = []; const tris: [RoofPickVec3, RoofPickVec3, RoofPickVec3][] = [];
@@ -3045,7 +3143,7 @@ export function selectionHighlightLines(
// tausende Dreiecke, deren Kanten einzeln zu zeichnen wäre unbrauchbar dicht. // tausende Dreiecke, deren Kanten einzeln zu zeichnen wäre unbrauchbar dicht.
if (contextObjSet.size > 0) { if (contextObjSet.size > 0) {
for (const obj of project.context ?? []) { for (const obj of project.context ?? []) {
if (obj.type === "contourSet" || !contextObjSet.has(obj.id)) continue; if (obj.type === "contourSet" || obj.type === "image" || !contextObjSet.has(obj.id)) continue;
const pos = obj.positions; const pos = obj.positions;
let minX = Infinity, maxX = -Infinity; let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity; let minY = Infinity, maxY = -Infinity;
+109
View File
@@ -0,0 +1,109 @@
// Kontext-Objekt-Auswahl: deriveSelection liefert für ein selektiertes
// Kontext-Objekt (importiertes Mesh/Terrain/Konturen) Bbox + reale LV95-
// Koordinate (falls project.geoAnchor gesetzt) — s. Nutzer-Wunsch, direkt im
// Object-Info-Panel prüfen zu können, ob/wohin georeferenziert importiert wurde.
import { describe, it, expect } from "vitest";
import { deriveSelection } from "./selectionInfo";
import type { Project } from "../model/types";
const baseProject = {
id: "p",
name: "T",
layers: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, baseElevation: 0 },
],
walls: [],
drawings2d: [],
};
describe("deriveSelection — Kontext-Objekt", () => {
it("importedMesh: Bbox aus x/y (Höhe z ignoriert), ohne geoAnchor kein lv95Center", () => {
const project = {
...baseProject,
context: [
{
id: "CTX1",
type: "importedMesh",
name: "Test",
positions: [0, 0, 5, 10, 6, 8, 4, 3, 2],
indices: [0, 1, 2],
},
],
} as unknown as Project;
const sel = deriveSelection(project, [], null, null, null, null, null, null, null, null, "CTX1");
expect(sel?.kind).toBe("contextObject");
expect(sel?.id).toBe("CTX1");
expect(sel?.bbox).toEqual({ minX: 0, minY: 0, maxX: 10, maxY: 6 });
expect(sel?.contextObject?.objType).toBe("importedMesh");
expect(sel?.contextObject?.lv95Center).toBeUndefined();
});
it("mit geoAnchor: lv95Center = Bbox-Mittelpunkt zurückgerechnet auf LV95", () => {
const project = {
...baseProject,
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 } },
context: [
{
id: "CTX1",
type: "importedMesh",
name: "Test",
positions: [0, 0, 0, 100, 50, 0],
indices: [0, 1, 0],
},
],
} as unknown as Project;
const sel = deriveSelection(project, [], null, null, null, null, null, null, null, null, "CTX1");
// Bbox-Mittelpunkt (50,25) -> LV95 = origin + Mittelpunkt (origin = lv95 - model = lv95, model (0,0)).
expect(sel?.contextObject?.lv95Center).toEqual({ e: 2600050, n: 1200025 });
});
it("mit geoAnchor.model != (0,0): Origin korrekt berücksichtigt", () => {
const project = {
...baseProject,
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 500, y: 300 } },
context: [
{
id: "CTX1",
type: "importedMesh",
name: "Test",
positions: [500, 300, 0],
indices: [0, 0, 0],
},
],
} as unknown as Project;
const sel = deriveSelection(project, [], null, null, null, null, null, null, null, null, "CTX1");
// Der Modellpunkt (500,300) IST der Anker -> muss exakt auf lv95 (2600000,1200000) zurückrechnen.
expect(sel?.contextObject?.lv95Center).toEqual({ e: 2600000, n: 1200000 });
});
it("contourSet: Bbox aus contours[].pts statt positions", () => {
const project = {
...baseProject,
context: [
{
id: "CS1",
type: "contourSet",
name: "Höhenlinien",
contours: [
{ z: 400, pts: [{ x: -5, y: 2 }, { x: 8, y: -3 }], closed: false },
],
},
],
} as unknown as Project;
const sel = deriveSelection(project, [], null, null, null, null, null, null, null, null, "CS1");
expect(sel?.contextObject?.objType).toBe("contourSet");
expect(sel?.bbox).toEqual({ minX: -5, minY: -3, maxX: 8, maxY: 2 });
});
it("unbekannte Id -> null", () => {
const project = { ...baseProject, context: [] } as unknown as Project;
expect(deriveSelection(project, [], null, null, null, null, null, null, null, null, "does-not-exist")).toBeNull();
});
it("keine Kontext-Auswahl (null) -> fällt durch zu anderen Kanälen (hier auch leer -> null)", () => {
const project = { ...baseProject, context: [] } as unknown as Project;
expect(deriveSelection(project, [], null)).toBeNull();
});
});
+5 -2
View File
@@ -8,7 +8,7 @@
// laufen. Die Slice tippt daher nur ihre eigenen Felder; `get()` liefert // laufen. Die Slice tippt daher nur ihre eigenen Felder; `get()` liefert
// RootState (siehe appStore.ts), wir greifen punktuell auf View-Felder zu. // RootState (siehe appStore.ts), wir greifen punktuell auf View-Felder zu.
import { sampleProject } from "../model/sampleProject"; import { emptyProject } from "../model/sampleProject";
import { import {
flattenCategories, flattenCategories,
recomputeFloorElevations, recomputeFloorElevations,
@@ -363,7 +363,10 @@ export function createProjectSlice(
return { return {
...history, ...history,
project: sampleProject, // TEMPORÄR für manuellen Test (Nutzer-Wunsch): leeres Projekt statt Demo-
// Haus. NICHT committen — vor dem nächsten echten Commit zurück auf
// `sampleProject` stellen.
project: emptyProject,
setProject, setProject,
+73 -1
View File
@@ -24,6 +24,7 @@ import type {
Ceiling, Ceiling,
Column, Column,
ColumnProfile, ColumnProfile,
ContextObject,
Drawing2D, Drawing2D,
Drawing2DGeom, Drawing2DGeom,
ExtrudedSolid, ExtrudedSolid,
@@ -41,6 +42,7 @@ import type {
Wall, Wall,
WallReferenceLine, WallReferenceLine,
} from "../model/types"; } from "../model/types";
import { localToLv95 } from "../io/lv95";
import { columnVerticalExtent } from "../model/wall"; import { columnVerticalExtent } from "../model/wall";
import { columnFootprint } from "../geometry/column"; import { columnFootprint } from "../geometry/column";
import { evaluateRoom, polygonArea } from "../geometry/roomArea"; import { evaluateRoom, polygonArea } from "../geometry/roomArea";
@@ -258,6 +260,8 @@ export interface StairInfo {
treadDepth: number; treadDepth: number;
/** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */ /** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */
up: boolean; up: boolean;
/** Pfeil-Darstellung (offener Winkel- vs. gefüllter Vollpfeil). Fehlt → "line". */
arrowStyle?: "line" | "filled";
/** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */ /** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */
referenz?: "links" | "mitte" | "rechts"; referenz?: "links" | "mitte" | "rechts";
/** Zugewiesener Treppentyp (Bibliothek, `Stair.typeId`) oder undefined. */ /** Zugewiesener Treppentyp (Bibliothek, `Stair.typeId`) oder undefined. */
@@ -348,7 +352,8 @@ export interface Selection {
| "door" | "door"
| "extrudedSolid" | "extrudedSolid"
| "column" | "column"
| "roof"; | "roof"
| "contextObject";
id: string; id: string;
/** Grafik-Kategorie (Ebene) des Elements. */ /** Grafik-Kategorie (Ebene) des Elements. */
categoryCode: string; categoryCode: string;
@@ -424,6 +429,20 @@ export interface Selection {
extrudedSolid?: ExtrudedSolidInfo; extrudedSolid?: ExtrudedSolidInfo;
/** Stützen-Attribute (nur bei `kind === "column"`). */ /** Stützen-Attribute (nur bei `kind === "column"`). */
column?: ColumnInfo; column?: ColumnInfo;
/** Kontext-Objekt-Attribute (nur bei `kind === "contextObject"`). */
contextObject?: ContextObjectInfo;
}
/**
* Kontext-Objekt-spezifische Attribute (importiertes Mesh/Terrain/Konturen-
* Satz) für das Object-Info-Panel v. a. zum Prüfen, OB/WOHIN georeferenziert
* importiert wurde (Nutzer-Wunsch: reale Koordinaten direkt sichtbar).
*/
export interface ContextObjectInfo {
objType: ContextObject["type"];
/** Reale LV95-Koordinate des Bbox-Mittelpunkts, falls `Project.geoAnchor`
* gesetzt ist (sonst `undefined` kein Bezug bekannt). */
lv95Center?: { e: number; n: number };
} }
const WALL_FALLBACK_MM = 0.18; const WALL_FALLBACK_MM = 0.18;
@@ -765,6 +784,7 @@ function stairSelection(project: Project, s: Stair): Selection {
riserHeight: geo.riserHeight, riserHeight: geo.riserHeight,
treadDepth: geo.treadDepth, treadDepth: geo.treadDepth,
up: s.up !== false, up: s.up !== false,
arrowStyle: s.arrowStyle,
referenz: s.referenz, referenz: s.referenz,
typeId: s.typeId, typeId: s.typeId,
floorId: s.floorId, floorId: s.floorId,
@@ -908,6 +928,53 @@ function columnSelection(project: Project, col: Column): Selection {
}; };
} }
/**
* Selektion für ein Kontext-Objekt (importiertes Mesh/Terrain/Konturen-Satz)
* ableiten. Bbox aus den rohen x/y-Koordinaten (Meshes: flaches [x,y,z]-
* Array, nur x/y je Vertex; Konturen: `pts` je Kontur) die Höhe (z) bleibt
* unberücksichtigt (2D-Bbox wie bei allen anderen Elementarten). `lv95Center`
* nur, wenn `project.geoAnchor` gesetzt ist (sonst kein Rückrechnungs-Bezug).
*/
function contextObjectSelection(project: Project, obj: ContextObject): Selection {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
const consider = (x: number, y: number) => {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
};
if (obj.type === "contourSet") {
for (const c of obj.contours) for (const p of c.pts) consider(p.x, p.y);
} else if (obj.type === "image") {
consider(obj.min.x, obj.min.y);
consider(obj.max.x, obj.max.y);
} else {
const pos = obj.positions;
for (let i = 0; i + 2 < pos.length; i += 3) consider(pos[i], pos[i + 1]);
}
if (!isFinite(minX)) minX = minY = maxX = maxY = 0;
const anchor = project.geoAnchor;
const lv95Center = anchor
? localToLv95(
(minX + maxX) / 2,
(minY + maxY) / 2,
{ e: anchor.lv95.e - anchor.model.x, n: anchor.lv95.n - anchor.model.y },
)
: undefined;
return {
kind: "contextObject",
id: obj.id,
categoryCode: "",
color: "#888888",
weightMm: WALL_FALLBACK_MM,
bbox: { minX, minY, maxX, maxY },
contextObject: { objType: obj.type, lv95Center },
};
}
/** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */ /** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */
function drawingSelection(project: Project, d: Drawing2D): Selection { function drawingSelection(project: Project, d: Drawing2D): Selection {
const ls = d.lineStyleId const ls = d.lineStyleId
@@ -963,7 +1030,12 @@ export function deriveSelection(
selectedExtrudedSolidId: string | null = null, selectedExtrudedSolidId: string | null = null,
selectedColumnId: string | null = null, selectedColumnId: string | null = null,
selectedRoofId: string | null = null, selectedRoofId: string | null = null,
selectedContextObjectId: string | null = null,
): Selection | null { ): Selection | null {
if (selectedContextObjectId) {
const o = (project.context ?? []).find((x) => x.id === selectedContextObjectId);
if (o) return contextObjectSelection(project, o);
}
if (selectedDrawingId) { if (selectedDrawingId) {
const d = project.drawings2d.find((x) => x.id === selectedDrawingId); const d = project.drawings2d.find((x) => x.id === selectedDrawingId);
return d ? drawingSelection(project, d) : null; return d ? drawingSelection(project, d) : null;
+9
View File
@@ -22,6 +22,9 @@ export interface SiteSlice {
addContextObjects: (objs: ContextObject[]) => void; addContextObjects: (objs: ContextObject[]) => void;
/** Entfernt ein Kontext-Objekt per ID (No-op bei unbekannter ID). */ /** Entfernt ein Kontext-Objekt per ID (No-op bei unbekannter ID). */
removeContextObject: (id: string) => void; removeContextObject: (id: string) => void;
/** Entfernt mehrere Kontext-Objekte in EINEM Schritt (z. B. eine ganze
* Ebene/Gruppe importierter Gebäude) ein einziger Undo-Eintrag. */
removeContextObjects: (ids: string[]) => void;
/** /**
* Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN und fügt es als * Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN und fügt es als
* weiteres Kontext-Objekt hinzu. Liefert die ID des neuen TerrainMesh, oder * weiteres Kontext-Objekt hinzu. Liefert die ID des neuen TerrainMesh, oder
@@ -64,6 +67,12 @@ export function createSiteSlice(
removeContextObject: (id) => removeContextObject: (id) =>
setContext((ctx) => ctx.filter((o) => o.id !== id)), setContext((ctx) => ctx.filter((o) => o.id !== id)),
removeContextObjects: (ids) => {
if (ids.length === 0) return;
const drop = new Set(ids);
setContext((ctx) => ctx.filter((o) => !drop.has(o.id)));
},
generateTerrain: (contourSetId) => { generateTerrain: (contourSetId) => {
const ctx = get().project.context ?? []; const ctx = get().project.context ?? [];
const set = ctx.find( const set = ctx.find(
+78
View File
@@ -0,0 +1,78 @@
// Bündelt die transienten Dialog-/Overlay-Sichtbarkeits-States der App-Shell
// (Ressourcen-Fenster, Layout-Ansicht, Import-/Export-/Einstellungs-Dialoge,
// Drop-Feedback). Bewusst KEIN Store-Slice: dieser State ist rein lokal (keine
// Undo/Redo-Relevanz, kein Cross-Component-Lesezugriff nötig — TopBar/
// AppMenuBar bekommen ihn weiterhin als Props von App() gereicht, unverändert)
// — nur aus App() herausgezogen, um die Funktion zu entlasten. Reine
// useState-Bündelung, keine Verhaltensänderung.
//
// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useState } from "react";
import type { DxfImportResult } from "../io/dxfParser";
import type { ExportSaveFormat } from "../ui/ExportSaveDialog";
import type { TabId as ResourceTabId } from "../ui/ResourceManager";
export function useDialogState() {
// Schwebendes Ressourcen-Fenster (Overlay über allem). Transienter UI-State,
// bleibt lokal. Wird über die Oberleiste geöffnet und über X / Hintergrund-
// Klick / Esc geschlossen.
const [resourcesOpen, setResourcesOpen] = useState(false);
// Angeforderter Start-Tab des Ressourcen-Fensters (z. B. „doorStyles", wenn man
// von einer gewählten Tür in den Typeditor springt).
const [resourcesTab, setResourcesTab] = useState<ResourceTabId>("components");
// Offener Fenster-/Tür-Einstellungsdialog (⚙ der OpeningSection) — ID der
// editierten Öffnung, oder null wenn geschlossen.
const [openingEditorId, setOpeningEditorId] = useState<string | null>(null);
// Aktives Layout-Blatt im HAUPT-Viewport (DOSSIER A3, Phase 2b): Id des offenen
// Layouts oder null (= normale Modell-Ansicht). Transienter UI-State; das
// Layout selbst lebt im Projekt (project.layouts). Ist es gesetzt, zeigt der
// Haupt-Viewport das Blatt (LayoutSheetView) statt Grundriss/3D.
const [activeLayoutId, setActiveLayoutId] = useState<string | null>(null);
// Modaler Import-Dialog (DXF/DWG). Offen, sobald eine Datei gewählt/fallen-
// gelassen wurde; hält Dateiname, geparstes Ergebnis (null bei DWG) und das
// DWG-Flag (kein Parser). null = kein Dialog offen. Transienter UI-State.
const [importState, setImportState] = useState<{
fileName: string;
isDwg: boolean;
parsed: DxfImportResult | null;
// true, solange DWG noch geparst wird (WASM lädt/läuft).
loading?: boolean;
} | null>(null);
// Modaler PDF-Export-Dialog (Grundriss → Vektor-PDF). true = offen.
const [exportOpen, setExportOpen] = useState(false);
const [exportDxfOpen, setExportDxfOpen] = useState(false);
// Modales Einstellungs-Fenster (Darstellungsfarben + Projekt-Felder).
const [settingsOpen, setSettingsOpen] = useState(false);
// Offener Schnellexport-Dialog: Format (aus dem Menü) oder null = geschlossen.
const [exportSaveFormat, setExportSaveFormat] = useState<ExportSaveFormat | null>(null);
// Visuelles Drop-Feedback (dezenter Rahmen), solange eine Datei über die App
// gezogen wird. Zählt dragenter/dragleave aus, um Flackern bei Kind-Elementen
// zu vermeiden.
const [dropActive, setDropActive] = useState(false);
return {
resourcesOpen,
setResourcesOpen,
resourcesTab,
setResourcesTab,
openingEditorId,
setOpeningEditorId,
activeLayoutId,
setActiveLayoutId,
importState,
setImportState,
exportOpen,
setExportOpen,
exportDxfOpen,
setExportDxfOpen,
settingsOpen,
setSettingsOpen,
exportSaveFormat,
setExportSaveFormat,
dropActive,
setDropActive,
};
}
+785 -453
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
// Hell/Dunkel-Umschalter (Einstellungen → Darstellung). Hell ist Standard
// (siehe styles.css); Dunkel wird per `data-theme="dark"` auf <html> opt-in
// gesetzt und in localStorage persistiert, damit die Wahl einen Neustart
// übersteht. `applyThemeMode` wird SYNCHRON vor dem ersten Render aufgerufen
// (main.tsx), damit es keinen Hell/Dunkel-Flackerer beim Laden gibt.
export type ThemeMode = "light" | "dark";
const THEME_STORAGE_KEY = "cad.themeMode";
export function getStoredThemeMode(): ThemeMode {
if (typeof window === "undefined") return "light";
return window.localStorage.getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light";
}
export function applyThemeMode(mode: ThemeMode): void {
if (mode === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.removeAttribute("data-theme");
}
}
export function setStoredThemeMode(mode: ThemeMode): void {
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
applyThemeMode(mode);
}
+18
View File
@@ -0,0 +1,18 @@
// React-State-Wrapper um den Hell/Dunkel-Umschalter (Einstellungen →
// Darstellung). Standard Hell (styles.css); Dunkel ist Opt-in und
// persistiert in localStorage (themeMode.ts).
//
// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useState } from "react";
import { getStoredThemeMode, setStoredThemeMode } from "./themeMode";
import type { ThemeMode } from "./themeMode";
export function useThemeModeState() {
const [themeMode, setThemeModeState] = useState<ThemeMode>(() => getStoredThemeMode());
const setThemeMode = (mode: ThemeMode) => {
setThemeModeState(mode);
setStoredThemeMode(mode);
};
return { themeMode, setThemeMode };
}
+135
View File
@@ -0,0 +1,135 @@
// Klassische Menüleiste (Bearbeiten/Ansicht/Fenster) neben der Wortmarke in
// der TopBar — ergänzt die bestehende Quick-Access-Icon-Leiste um textuelle
// Menüs im Rhino-/Desktop-Stil. Reine Darstellung + Callbacks (voll gesteuert,
// wie die übrige TopBar); jeder Eintrag ruft eine bereits existierende
// App.tsx-Funktion auf (kein Dummy-/Platzhalter-Eintrag).
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { Dropdown } from "./Dropdown";
import type { DropdownItem } from "./Dropdown";
import { t } from "../i18n";
import type { View3d, ViewType } from "./TopBar";
export interface AppMenuBarProps {
/** Bearbeiten: Undo/Redo (Store) + Löschen der aktuellen Auswahl. */
onUndo: () => void;
onRedo: () => void;
canUndo: boolean;
canRedo: boolean;
onDeleteSelection: () => void;
hasSelection: boolean;
/** Ansicht: Grundriss/Perspektive-Umschalter, 3D-Blickwinkel-Presets,
* Referenzlinien-Toggle. */
viewType: ViewType;
onViewType: (v: ViewType) => void;
view3d: View3d;
onView3d: (v: View3d) => void;
referenceLines: boolean;
onReferenceLines: (v: boolean) => void;
/** Fenster: Ressourcen-Fenster + Einstellungen (bestehende Panels). */
resourcesOpen: boolean;
onToggleResources: () => void;
onOpenSettings: () => void;
}
/** Menüleiste (Bearbeiten/Ansicht/Fenster) — sitzt in der TopBar neben der Wortmarke. */
export function AppMenuBar({
onUndo,
onRedo,
canUndo,
canRedo,
onDeleteSelection,
hasSelection,
viewType,
onViewType,
view3d,
onView3d,
referenceLines,
onReferenceLines,
resourcesOpen,
onToggleResources,
onOpenSettings,
}: AppMenuBarProps) {
const editItems: DropdownItem[] = [
{ label: t("menu.edit.undo"), onSelect: onUndo, disabled: !canUndo },
{ label: t("menu.edit.redo"), onSelect: onRedo, disabled: !canRedo },
{ divider: true },
{
label: t("menu.edit.delete"),
onSelect: onDeleteSelection,
disabled: !hasSelection,
danger: true,
},
];
const VIEW3D_PRESETS: [View3d, string][] = [
["front", t("view3d.front")],
["back", t("view3d.back")],
["side", t("view3d.side")],
["left", t("view3d.left")],
["top", t("view3d.top")],
["iso", t("view3d.iso")],
["perspective", t("view3d.perspective")],
];
const viewItems: DropdownItem[] = [
{
label: t("menu.view.floorplan"),
onSelect: () => onViewType("grundriss"),
active: viewType === "grundriss",
},
{
label: t("menu.view.perspective"),
onSelect: () => onViewType("perspektive"),
active: viewType === "perspektive",
},
{ divider: true },
...VIEW3D_PRESETS.map(([id, label]) => ({
label,
onSelect: () => onView3d(id),
active: view3d === id,
disabled: viewType !== "perspektive",
})),
{ divider: true },
{
label: t("menu.view.referenceLines"),
onSelect: () => onReferenceLines(!referenceLines),
active: referenceLines,
},
];
const windowItems: DropdownItem[] = [
{
label: t("menu.window.resources"),
onSelect: onToggleResources,
active: resourcesOpen,
},
{ divider: true },
{ label: t("menu.window.settings"), onSelect: onOpenSettings },
];
return (
<div className="tb-menubar" role="menubar">
<Dropdown
items={editItems}
placeholder={t("menu.edit")}
title={t("menu.edit")}
triggerClassName="tb-menubar-item"
/>
<Dropdown
items={viewItems}
placeholder={t("menu.view")}
title={t("menu.view")}
triggerClassName="tb-menubar-item"
/>
<Dropdown
items={windowItems}
placeholder={t("menu.window")}
title={t("menu.window")}
triggerClassName="tb-menubar-item"
/>
</div>
);
}
+180 -24
View File
@@ -17,13 +17,15 @@ import { fetchBuildings3d } from "../io/swissBuildings3d";
import type { BuildingsVersion } from "../io/swissBuildings3d"; import type { BuildingsVersion } from "../io/swissBuildings3d";
import { fetchTerrainXyz } from "../io/swissAlti3d"; import { fetchTerrainXyz } from "../io/swissAlti3d";
import type { TerrainResolution } from "../io/swissAlti3d"; import type { TerrainResolution } from "../io/swissAlti3d";
import { fetchSwissImage } from "../io/swissImage";
import { fetchOsm } from "../io/osm"; import { fetchOsm } from "../io/osm";
import type { OsmSelection } from "../io/osm"; import type { OsmSelection } from "../io/osm";
import { featuresToContextObjects } from "../io/geoContext"; import { featuresToContextObjects } from "../io/geoContext";
import type { GeoFeature } from "../io/geoContext"; import type { GeoFeature } from "../io/geoContext";
import { terrainMeshFromGrid } from "../model/terrain"; import { terrainMeshFromGrid } from "../model/terrain";
import type { GeoOrigin } from "../io/lv95"; import { makeOrigin } from "../io/lv95";
import type { ContextObject } from "../model/types"; import type { GeoOrigin, LV95 } from "../io/lv95";
import type { ContextObject, TerrainMesh } from "../model/types";
/** Gebäude-Import-Modus: aus, vereinfachte Box (schnell, alt) oder echte /** Gebäude-Import-Modus: aus, vereinfachte Box (schnell, alt) oder echte
* swissBUILDINGS3D-Geometrie (Wände+Dach) in einer der zwei Generationen. */ * swissBUILDINGS3D-Geometrie (Wände+Dach) in einer der zwei Generationen. */
@@ -33,6 +35,8 @@ export type SwissBuildingsMode = "off" | "flach" | "v2" | "v3";
* LV95-Koordinate (s. `Project.geoAnchor`). */ * LV95-Koordinate (s. `Project.geoAnchor`). */
export interface GeoAnchor { export interface GeoAnchor {
lv95: { e: number; n: number }; lv95: { e: number; n: number };
/** Reale Höhe (m ü. M.) des Bezugspunkts — informativ, s. Project.geoAnchor. */
height?: number;
model: { x: number; y: number }; model: { x: number; y: number };
label?: string; label?: string;
} }
@@ -45,11 +49,21 @@ export interface ContextImportDialogProps {
geoAnchor?: GeoAnchor; geoAnchor?: GeoAnchor;
/** Setzt/ersetzt (`undefined` löscht) den Georeferenzierungs-Bezug. */ /** Setzt/ersetzt (`undefined` löscht) den Georeferenzierungs-Bezug. */
onSetGeoAnchor: (anchor: GeoAnchor | undefined) => void; onSetGeoAnchor: (anchor: GeoAnchor | undefined) => void;
/**
* Läuft in einem EIGENEN nativen Fenster (siehe native/contextImportWindow.ts)
* statt als abgedunkelter Overlay-Dialog im Hauptfenster kein Backdrop,
* füllt die Fensterfläche, Kopfzeile wird zum Ziehbereich statt Schliessen-
* Knopf (Abbrechen/Importieren im Fuss bleiben das sind echte Aktionen,
* kein reiner Schliessen-Button wie bei SettingsDialog).
*/
windowed?: boolean;
} }
type Sources = { type Sources = {
swissBuildings: SwissBuildingsMode; swissBuildings: SwissBuildingsMode;
swissTerrain: boolean; swissTerrain: boolean;
swissImage: boolean;
swissImageDrape: boolean;
osmBuildings: boolean; osmBuildings: boolean;
osmRoads: boolean; osmRoads: boolean;
osmWater: boolean; osmWater: boolean;
@@ -64,6 +78,8 @@ const DEFAULT_SOURCES: Sources = {
// Städten sehr grosse Kacheln liefern (s. swissBuildings3d.ts-Kommentar). // Städten sehr grosse Kacheln liefern (s. swissBuildings3d.ts-Kommentar).
swissBuildings: "v2", swissBuildings: "v2",
swissTerrain: true, swissTerrain: true,
swissImage: false,
swissImageDrape: false,
osmBuildings: false, osmBuildings: false,
osmRoads: true, osmRoads: true,
osmWater: true, osmWater: true,
@@ -78,6 +94,7 @@ export function ContextImportDialog({
onClose, onClose,
geoAnchor, geoAnchor,
onSetGeoAnchor, onSetGeoAnchor,
windowed = false,
}: ContextImportDialogProps) { }: ContextImportDialogProps) {
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -91,6 +108,14 @@ export function ContextImportDialog({
const [candidates, setCandidates] = useState<GeocodeCandidate[]>([]); const [candidates, setCandidates] = useState<GeocodeCandidate[]>([]);
const [selected, setSelected] = useState<GeocodeCandidate | null>(null); const [selected, setSelected] = useState<GeocodeCandidate | null>(null);
const [radius, setRadius] = useState(200); const [radius, setRadius] = useState(200);
// Optionaler Bezugspunkt: welche LV95-Koordinate soll im Modell auf (0,0)
// liegen (der „neue Nullpunkt", z. B. ein amtlicher Vermessungspunkt aus dem
// Kataster). Leer = das Suchzentrum wird Modell-(0,0) (bisheriges Verhalten).
const [refPoint, setRefPoint] = useState<{ e: string; n: string; h: string }>({
e: "",
n: "",
h: "",
});
const [sources, setSources] = useState<Sources>(DEFAULT_SOURCES); const [sources, setSources] = useState<Sources>(DEFAULT_SOURCES);
// 2 m = Default (schneller/kleinere Kacheln); 0.5 m für Detailarbeit. // 2 m = Default (schneller/kleinere Kacheln); 0.5 m für Detailarbeit.
const [terrainResolution, setTerrainResolution] = useState<TerrainResolution>("2"); const [terrainResolution, setTerrainResolution] = useState<TerrainResolution>("2");
@@ -129,6 +154,8 @@ export function ContextImportDialog({
const anySource = const anySource =
sources.swissBuildings !== "off" || sources.swissBuildings !== "off" ||
sources.swissTerrain || sources.swissTerrain ||
sources.swissImage ||
sources.swissImageDrape ||
sources.osmBuildings || sources.osmBuildings ||
sources.osmRoads || sources.osmRoads ||
sources.osmWater || sources.osmWater ||
@@ -144,13 +171,29 @@ export function ContextImportDialog({
setStatus(t("ctxImport.working")); setStatus(t("ctxImport.working"));
try { try {
const center = selected.lv95; const center = selected.lv95;
// Bestehenden Georeferenzierungs-Bezug wiederverwenden, statt bei jedem // Optionaler Bezugspunkt (LV95): dieser Punkt wird zu Modell-(0,0). Leer
// Import einen neuen (inkonsistenten) zu berechnen — der Bezugspunkt // -> das Suchzentrum wird der Nullpunkt (bisheriges Verhalten).
// bleibt fix im Modell, nur die GESUCHTE Stelle (center/radius) steuert, const refE = Number(refPoint.e);
// WOVON Daten geladen werden (s. Project.geoAnchor-Doc). const refN = Number(refPoint.n);
let origin: GeoOrigin | undefined = geoAnchor const hasRef =
refPoint.e.trim() !== "" &&
refPoint.n.trim() !== "" &&
Number.isFinite(refE) &&
Number.isFinite(refN);
const refLv95: LV95 = hasRef ? { e: refE, n: refN } : center;
// Optionale Höhe (m ü. M.) des Bezugspunkts — rein informativ (kein Shift).
const refH = Number(refPoint.h);
const hasRefH = refPoint.h.trim() !== "" && Number.isFinite(refH);
// Der gesuchte Ort landet nahe dem Modell-Nullpunkt (Modell rechnet in
// float32 — LV95-Koordinaten im Millionenbereich würden 2D/3D unbrauchbar
// ungenau darstellen). Der Bezug (geoAnchor) merkt sich „Modell-(0,0) =
// dieser LV95-Punkt", damit der IFC-/DXF-Export trotzdem die echten
// Landeskoordinaten schreibt und weitere Importe lagerichtig dazu liegen.
// Mit „Neuer Bezugspunkt" (georef) lässt sich der Ursprung später auf einen
// beliebigen Modellpunkt verschieben.
let origin: GeoOrigin = geoAnchor
? { e: geoAnchor.lv95.e - geoAnchor.model.x, n: geoAnchor.lv95.n - geoAnchor.model.y } ? { e: geoAnchor.lv95.e - geoAnchor.model.x, n: geoAnchor.lv95.n - geoAnchor.model.y }
: undefined; : makeOrigin(refLv95);
const all: GeoFeature[] = []; const all: GeoFeature[] = [];
// Echte swissBUILDINGS3D-Meshes (v2/v3) landen direkt als ContextObject // Echte swissBUILDINGS3D-Meshes (v2/v3) landen direkt als ContextObject
// (kein Footprint+Box-Umweg über GeoFeature/featuresToContextObjects). // (kein Footprint+Box-Umweg über GeoFeature/featuresToContextObjects).
@@ -200,8 +243,10 @@ export function ContextImportDialog({
} }
// Gelände-Höhenraster (echtes swissALTI3D-XYZ, native Auflösung) → Terrain-Mesh. // Gelände-Höhenraster (echtes swissALTI3D-XYZ, native Auflösung) → Terrain-Mesh.
let terrain: ContextObject | null = null; // Die Drapierung braucht das TIN -> Terrain auch dann holen, wenn nur
if (sources.swissTerrain) { // „Luftbild aufs Gelände" (ohne eigenen Terrain-Haken) gewählt wäre.
let terrain: TerrainMesh | null = null;
if (sources.swissTerrain || sources.swissImageDrape) {
const r = await fetchTerrainXyz(center, radius, origin, terrainResolution); const r = await fetchTerrainXyz(center, radius, origin, terrainResolution);
origin = r.origin; origin = r.origin;
if (r.grid) { if (r.grid) {
@@ -210,7 +255,30 @@ export function ContextImportDialog({
} }
} }
if (all.length === 0 && !terrain && extraObjs.length === 0) { // SWISSIMAGE-Luftbild (WMS) → georeferenziertes Rasterbild (EIN ContextImage).
// • `swissImage` → 2D-Grundriss-Hintergrund.
// • `swissImageDrape` → `drape:true`: der 3D-Viewer legt es pixelscharf als
// Textur aufs Gelände (render3d `set_aerial_texture`).
// Beide Modi teilen dasselbe Bild; das Bild wird nur EINMAL geladen.
let image: ContextObject | null = null;
if (sources.swissImage || sources.swissImageDrape) {
const r = await fetchSwissImage(center, radius, origin);
origin = r.origin;
if (r.dataUrl) {
image = {
id: `img-${Date.now()}`,
type: "image",
name: `Luftbild ${selected.label}`,
layer: "Luftbild",
src: r.dataUrl,
min: r.min,
max: r.max,
drape: sources.swissImageDrape,
};
}
}
if (all.length === 0 && !terrain && !image && extraObjs.length === 0) {
setError( setError(
buildingsSkipped > 0 buildingsSkipped > 0
? t("ctxImport.buildingsSkipped", { count: buildingsSkipped }) ? t("ctxImport.buildingsSkipped", { count: buildingsSkipped })
@@ -220,16 +288,26 @@ export function ContextImportDialog({
return; return;
} }
// Erster Import in diesem Projekt (noch kein Bezug) -> DIESEN Lauf als // Erster Import in diesem Projekt (noch kein Bezug) -> Bezug speichern:
// Georeferenzierungs-Bezug speichern (Modell-(0,0) = der gesuchte Ort), // Modell-(0,0) entspricht dem gesuchten Ort (LV95 `center`). Damit landet
// damit künftige Importe lagerichtig zueinander bleiben. // der Import nahe dem Ursprung (gut darstellbar) UND der Modell-Export
if (!geoAnchor && origin) { // kann später auf die echten LV95-Koordinaten zurückrechnen.
onSetGeoAnchor({ lv95: { e: origin.e, n: origin.n }, model: { x: 0, y: 0 }, label: selected.label }); if (!geoAnchor) {
const anchorLabel = hasRef
? t("ctxImport.refPoint.anchorLabel", { e: Math.round(refE), n: Math.round(refN) })
: selected.label;
onSetGeoAnchor({
lv95: refLv95,
height: hasRefH ? refH : undefined,
model: { x: 0, y: 0 },
label: anchorLabel,
});
} }
const objs = featuresToContextObjects(all, selected.label); const objs = featuresToContextObjects(all, selected.label);
objs.push(...extraObjs); objs.push(...extraObjs);
if (terrain) objs.push(terrain); if (terrain) objs.push(terrain);
if (image) objs.push(image);
onImport(objs); onImport(objs);
setStatus( setStatus(
buildingsSkipped > 0 buildingsSkipped > 0
@@ -252,17 +330,20 @@ export function ContextImportDialog({
} }
}; };
return ( const dialog = (
<div className="imp-overlay" onClick={onClose}>
<div <div
className="imp-dialog" className={`imp-dialog${windowed ? " imp-dialog-embedded" : ""}`}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t("ctxImport.title")} aria-label={t("ctxImport.title")}
onClick={(e) => e.stopPropagation()} onClick={windowed ? undefined : (e) => e.stopPropagation()}
>
<header
className="imp-head"
{...(windowed ? { "data-tauri-drag-region": true } : {})}
> >
<header className="imp-head">
<span className="imp-head-title">{t("ctxImport.title")}</span> <span className="imp-head-title">{t("ctxImport.title")}</span>
{!windowed && (
<button <button
className="imp-close" className="imp-close"
onClick={onClose} onClick={onClose}
@@ -270,14 +351,26 @@ export function ContextImportDialog({
> >
× ×
</button> </button>
)}
</header> </header>
<div className="imp-body"> <div className="imp-body">
{/* Georeferenzierungs-Bezug: sobald gesetzt, teilen sich ALLE Importe {/* Georeferenzierungs-Bezug: sobald gesetzt, teilen sich ALLE Importe
dieses Projekts denselben ModellLV95-Bezug (s. Project.geoAnchor). */} dieses Projekts denselben ModellLV95-Bezug (s. Project.geoAnchor).
{geoAnchor && ( Ohne Bezug wird ROH importiert (echte LV95-Koordinaten, weit vom
Nullpunkt) ein Hinweis erklärt den Folge-Schritt Neuer
Bezugspunkt". */}
{geoAnchor ? (
<div className="cim-geo-anchor"> <div className="cim-geo-anchor">
<span>{t("ctxImport.geoAnchor", { label: geoAnchor.label ?? "?" })}</span> <span>
{t("ctxImport.geoAnchor", { label: geoAnchor.label ?? "?" })}
{" — "}
{t("ctxImport.geoAnchor.coords", {
e: geoAnchor.lv95.e.toFixed(2),
n: geoAnchor.lv95.n.toFixed(2),
h: typeof geoAnchor.height === "number" ? geoAnchor.height.toFixed(2) : "—",
})}
</span>
<button <button
type="button" type="button"
className="imp-linklike" className="imp-linklike"
@@ -287,6 +380,8 @@ export function ContextImportDialog({
{t("ctxImport.geoAnchor.reset")} {t("ctxImport.geoAnchor.reset")}
</button> </button>
</div> </div>
) : (
<div className="cim-geo-anchor cim-geo-hint">{t("ctxImport.rawHint")}</div>
)} )}
{/* Ortssuche */} {/* Ortssuche */}
<section className="imp-section"> <section className="imp-section">
@@ -355,6 +450,45 @@ export function ContextImportDialog({
</div> </div>
</section> </section>
{/* Bezugspunkt (Modell-Nullpunkt): nur solange noch kein Bezug steht.
Leer -> Suchzentrum wird (0,0); gefüllt -> dieser LV95-Punkt wird
(0,0) (z. B. amtlicher Vermessungspunkt aus dem Kataster). */}
{!geoAnchor && (
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.refPoint")}</div>
<div className="cim-radius-row">
<input
className="cim-input cim-input-num"
type="number"
step={0.01}
value={refPoint.e}
placeholder={t("ctxImport.refPoint.e")}
onChange={(e) => setRefPoint((p) => ({ ...p, e: e.target.value }))}
aria-label={t("ctxImport.refPoint.e")}
/>
<input
className="cim-input cim-input-num"
type="number"
step={0.01}
value={refPoint.n}
placeholder={t("ctxImport.refPoint.n")}
onChange={(e) => setRefPoint((p) => ({ ...p, n: e.target.value }))}
aria-label={t("ctxImport.refPoint.n")}
/>
<input
className="cim-input cim-input-num"
type="number"
step={0.01}
value={refPoint.h}
placeholder={t("ctxImport.refPoint.h")}
onChange={(e) => setRefPoint((p) => ({ ...p, h: e.target.value }))}
aria-label={t("ctxImport.refPoint.h")}
/>
</div>
<div className="cim-geo-hint">{t("ctxImport.refPoint.hint")}</div>
</section>
)}
{/* Quellen */} {/* Quellen */}
<section className="imp-section"> <section className="imp-section">
<div className="imp-section-title">{t("ctxImport.sources")}</div> <div className="imp-section-title">{t("ctxImport.sources")}</div>
@@ -397,6 +531,22 @@ export function ContextImportDialog({
<option value="0.5">{t("ctxImport.terrain.res05")}</option> <option value="0.5">{t("ctxImport.terrain.res05")}</option>
</select> </select>
</label> </label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.swissImage}
onChange={() => toggle("swissImage")}
/>
{t("ctxImport.src.swissImage")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.swissImageDrape}
onChange={() => toggle("swissImageDrape")}
/>
{t("ctxImport.src.swissImageDrape")}
</label>
<label className="cim-check"> <label className="cim-check">
<input <input
type="checkbox" type="checkbox"
@@ -473,6 +623,12 @@ export function ContextImportDialog({
</button> </button>
</footer> </footer>
</div> </div>
);
if (windowed) return dialog;
return (
<div className="imp-overlay" onClick={onClose}>
{dialog}
</div> </div>
); );
} }
+17
View File
@@ -0,0 +1,17 @@
// Beschriftetes Feld des Ebenen-/Geschoss-InlineEditors (App.tsx) — extrahiert,
// damit das native Ebeneneinstellungs-Fenster (native/LayerSettingsWindowApp.tsx)
// dasselbe Layout wiederverwendet, ohne den ganzen App.tsx-Modulgraph in das
// (bewusst schlanke) Nebenfenster-Bundle zu ziehen.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import type { ReactNode } from "react";
export function EditorField({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="editor-field">
<span className="editor-label">{label}</span>
<span className="editor-control">{children}</span>
</label>
);
}
+91
View File
@@ -0,0 +1,91 @@
// Arbeitsumgebung-Menü (Fenster-/Dock-Layouts) — aktuelles Layout speichern,
// benanntes Layout laden/löschen. Extrahiert aus App.tsx, damit SettingsDialog
// es sowohl inline (Haupt-App) als auch in seinem eigenen nativen Fenster
// (native/SettingsWindowApp.tsx) rendern kann, ohne es als vorgefertigtes
// ReactNode über die Fenstergrenze reichen zu müssen (JSX ist nicht
// serialisierbar). Speichern/Laden/Löschen laufen direkt über localStorage
// (panels/layout.ts) — funktioniert in JEDEM Fenster identisch, da alle
// Tauri-Fenster derselben App dieselbe Origin/denselben Storage teilen. Nur
// „aktuelles Layout übernehmen" (`onApply`) muss zurück ins Fenster mit dem
// echten App-Zustand — daher weiterhin ein Callback-Prop, kein Store-Zugriff
// hier.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useState } from "react";
import { Dropdown } from "./Dropdown";
import type { DropdownItem } from "./Dropdown";
import { PromptDialog } from "./PromptDialog";
import { t } from "../i18n";
import {
deleteNamedLayout,
listNamedLayouts,
loadNamedLayout,
saveNamedLayout,
} from "../panels/layout";
import type { LayoutState } from "../panels/types";
export function LayoutMenu({
layout,
onApply,
}: {
layout: LayoutState;
onApply: (layout: LayoutState) => void;
}) {
const [names, setNames] = useState<string[]>([]);
// Name-Erfassung über PromptDialog statt window.prompt (im Tauri-WKWebView
// deaktiviert — liefert sofort null, nichts würde je gespeichert).
const [saving, setSaving] = useState(false);
const refresh = () => setNames(listNamedLayouts());
const onSave = () => setSaving(true);
const commitSave = (name: string) => {
saveNamedLayout(name.trim(), layout);
refresh();
setSaving(false);
};
// Aktions-Menü (analog ComboMenu in TopBar.tsx): „Aktuelles speichern" oben,
// dann je eine Gruppe „Laden" und „Löschen" (Trenner statt <optgroup>, die
// die Dropdown-Komponente nicht kennt).
const items: DropdownItem[] = [];
items.push({ label: t("layout.saveCurrent"), onSelect: onSave });
if (names.length > 0) {
items.push({ divider: true });
for (const n of names) {
items.push({
label: n,
onSelect: () => {
const loaded = loadNamedLayout(n);
if (loaded) onApply(loaded);
},
});
}
items.push({ divider: true });
for (const n of names) {
items.push({
label: `${n}`,
danger: true,
onSelect: () => {
deleteNamedLayout(n);
refresh();
},
});
}
}
return (
<span onPointerDownCapture={refresh}>
<Dropdown items={items} placeholder={t("layout.placeholder")} title={t("layout.title")} />
{saving && (
<PromptDialog
title={t("layout.title")}
label={t("layout.savePrompt")}
onConfirm={commitSave}
onClose={() => setSaving(false)}
/>
)}
</span>
);
}
+38 -5
View File
@@ -2393,6 +2393,14 @@ function WindowStylesTab({
/> />
</FieldRow> </FieldRow>
)} )}
<FieldRow label={t("resources.field.innerLayerRecess")}>
<input
type="checkbox"
checked={!!win.innerLayerRecess}
onChange={(e) => patch({ innerLayerRecess: e.target.checked })}
title={t("resources.field.innerLayerRecess.hint")}
/>
</FieldRow>
<FieldRow label={t("resources.field.transomHeight")}> <FieldRow label={t("resources.field.transomHeight")}>
<NumberField <NumberField
value={win.transomHeight ?? 0} value={win.transomHeight ?? 0}
@@ -3114,12 +3122,20 @@ export function ResourceManager({
onClose, onClose,
handlers, handlers,
initialTab, initialTab,
windowed = false,
}: { }: {
project: Project; project: Project;
onClose: () => void; onClose: () => void;
handlers: ResourceManagerHandlers; handlers: ResourceManagerHandlers;
/** Beim Öffnen (bzw. bei erneutem Sprung) direkt aktiver Tab, z. B. „doorStyles". */ /** Beim Öffnen (bzw. bei erneutem Sprung) direkt aktiver Tab, z. B. „doorStyles". */
initialTab?: TabId; initialTab?: TabId;
/**
* Läuft in einem EIGENEN nativen Fenster (siehe native/resourceWindow.ts)
* statt als schwebendes Overlay im Hauptfenster: füllt die Fensterfläche
* komplett (kein eigenes Verschieben/Grössenändern per JS das übernimmt
* das OS-Fenster), Kopfzeile bleibt nur als Tab-Träger ohne Drag-Cursor.
*/
windowed?: boolean;
}) { }) {
const [tab, setTab] = useState<TabId>(initialTab ?? "components"); const [tab, setTab] = useState<TabId>(initialTab ?? "components");
// Erneuter Sprung aus einem Element heraus (z. B. „Typ bearbeiten" an einer // Erneuter Sprung aus einem Element heraus (z. B. „Typ bearbeiten" an einer
@@ -3185,23 +3201,36 @@ export function ResourceManager({
return ( return (
<aside <aside
className="res-window" className={`res-window${windowed ? " res-window-embedded" : ""}`}
role="dialog" role="dialog"
aria-label={t("resources.label")} aria-label={t("resources.label")}
style={{ style={
windowed
? undefined
: {
left: `${rect.x}px`, left: `${rect.x}px`,
top: `${rect.y}px`, top: `${rect.y}px`,
width: `${rect.w}px`, width: `${rect.w}px`,
height: `${rect.h}px`, height: `${rect.h}px`,
}} }
}
>
<header
className="res-head"
onPointerDown={windowed ? undefined : onTitlePointerDown}
// Im eigenen nativen Fenster (randlos, s. BORDERLESS_WINDOW_CHROME)
// ist diese Kopfzeile der einzige Ziehbereich — genau wie die
// TopBar des Hauptfensters (`data-tauri-drag-region`). Ohne OS-
// Titelzeile bleibt so trotzdem EIN Griff zum Verschieben.
{...(windowed ? { "data-tauri-drag-region": true } : {})}
> >
<header className="res-head" onPointerDown={onTitlePointerDown}>
<div className="res-head-title"> <div className="res-head-title">
<span className="res-head-icon"> <span className="res-head-icon">
<EyeIcon open /> <EyeIcon open />
</span> </span>
{t("resources.title")} {t("resources.title")}
</div> </div>
{!windowed && (
<button <button
className="res-close" className="res-close"
onClick={onClose} onClick={onClose}
@@ -3209,6 +3238,7 @@ export function ResourceManager({
> >
× ×
</button> </button>
)}
</header> </header>
<nav className="res-tabs" role="tablist"> <nav className="res-tabs" role="tablist">
@@ -3309,7 +3339,9 @@ export function ResourceManager({
)} )}
</div> </div>
{/* Greifer unten rechts (Größe ändern). */} {/* Greifer unten rechts (Größe ändern) im eigenen OS-Fenster übernimmt
das Betriebssystem das Grössenändern, daher windowed ohne Greifer. */}
{!windowed && (
<div <div
className="floating-resize res-resize" className="floating-resize res-resize"
role="separator" role="separator"
@@ -3317,6 +3349,7 @@ export function ResourceManager({
title={t("dock.resizePanel")} title={t("dock.resizePanel")}
onPointerDown={onResizePointerDown} onPointerDown={onResizePointerDown}
/> />
)}
</aside> </aside>
); );
} }
+71 -22
View File
@@ -10,20 +10,26 @@
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). // Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { ACCENT_SWATCHES } from "../theme/accents"; import { ACCENT_SWATCHES } from "../theme/accents";
import type { Project } from "../model/types";
import { ColorHexField } from "./ColorHexField"; import { ColorHexField } from "./ColorHexField";
import { LayoutMenu } from "./LayoutMenu";
import type { RendererMode } from "./StatusBar"; import type { RendererMode } from "./StatusBar";
import type { LayoutState } from "../panels/types";
import type { ThemeMode } from "../theme/themeMode";
export interface SettingsDialogProps { export interface SettingsDialogProps {
/** Hell/Dunkel — Standard ist Hell (siehe theme/themeMode.ts). */
themeMode: ThemeMode;
onThemeModeChange: (mode: ThemeMode) => void;
marqueeColor: string; marqueeColor: string;
onMarqueeColorChange: (hex: string) => void; onMarqueeColorChange: (hex: string) => void;
snapColor: string; snapColor: string;
onSnapColorChange: (hex: string) => void; onSnapColorChange: (hex: string) => void;
project: Project; /** Referenzhöhe des Erdgeschosses (m ü. M.) statt des ganzen Projekts nur
setProject: (next: Project | ((p: Project) => Project)) => void; * dieses eine Feld + sein Setter (schmale Grenzfläche, s. `windowed`). */
referenceElevationMasl: number | undefined;
onReferenceElevationChange: (n: number | undefined) => void;
/** Aktuell gewählter Plan-Renderer (aus dem `?engine`-URL-Param abgeleitet). */ /** Aktuell gewählter Plan-Renderer (aus dem `?engine`-URL-Param abgeleitet). */
rendererMode: RendererMode; rendererMode: RendererMode;
/** Ist `navigator.gpu` vorhanden? Steuert, ob „Nordstern" wählbar ist. */ /** Ist `navigator.gpu` vorhanden? Steuert, ob „Nordstern" wählbar ist. */
@@ -33,23 +39,37 @@ export interface SettingsDialogProps {
* Live-Remount der Renderer-Hooks). * Live-Remount der Renderer-Hooks).
*/ */
onRendererModeChange: (mode: RendererMode) => void; onRendererModeChange: (mode: RendererMode) => void;
/** Arbeitsumgebung-Auswahl (Fenster-/Dock-Layouts) — früher in der TopBar. */ /** Arbeitsumgebung (Fenster-/Dock-Layouts) LayoutMenu wird HIER gerendert
layoutMenu: ReactNode; * (nicht mehr als vorgefertigtes ReactNode hereingereicht: JSX ist über eine
* Fenstergrenze nicht serialisierbar, s. `windowed`). */
layout: LayoutState;
onApplyLayout: (layout: LayoutState) => void;
onClose: () => void; onClose: () => void;
/**
* Läuft in einem EIGENEN nativen Fenster (siehe native/settingsWindow.ts)
* statt als abgedunkelter Overlay-Dialog im Hauptfenster: kein Backdrop/
* Klick-aussen-schliesst (kein aussen" in einem echten OS-Fenster), füllt
* die Fensterfläche, Kopfzeile wird zum Ziehbereich statt zum Schliessen-Klick.
*/
windowed?: boolean;
} }
export function SettingsDialog({ export function SettingsDialog({
themeMode,
onThemeModeChange,
marqueeColor, marqueeColor,
onMarqueeColorChange, onMarqueeColorChange,
snapColor, snapColor,
onSnapColorChange, onSnapColorChange,
project, referenceElevationMasl,
setProject, onReferenceElevationChange,
rendererMode, rendererMode,
webGpuAvailable, webGpuAvailable,
onRendererModeChange, onRendererModeChange,
layoutMenu, layout,
onApplyLayout,
onClose, onClose,
windowed = false,
}: SettingsDialogProps) { }: SettingsDialogProps) {
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -64,47 +84,49 @@ export function SettingsDialog({
// gerundeten Projektwert zu schreiben. Synchronisiert sich, wenn sich das // gerundeten Projektwert zu schreiben. Synchronisiert sich, wenn sich das
// Projekt von aussen ändert (z. B. Undo/Import). // Projekt von aussen ändert (z. B. Undo/Import).
const [elevationText, setElevationText] = useState( const [elevationText, setElevationText] = useState(
project.referenceElevationMasl != null ? String(project.referenceElevationMasl) : "", referenceElevationMasl != null ? String(referenceElevationMasl) : "",
); );
useEffect(() => { useEffect(() => {
setElevationText( setElevationText(referenceElevationMasl != null ? String(referenceElevationMasl) : "");
project.referenceElevationMasl != null ? String(project.referenceElevationMasl) : "", }, [referenceElevationMasl]);
);
}, [project.referenceElevationMasl]);
const commitElevation = (raw: string) => { const commitElevation = (raw: string) => {
setElevationText(raw); setElevationText(raw);
const trimmed = raw.trim(); const trimmed = raw.trim();
if (trimmed === "") { if (trimmed === "") {
setProject((p) => ({ ...p, referenceElevationMasl: undefined })); onReferenceElevationChange(undefined);
return; return;
} }
const n = Number(trimmed); const n = Number(trimmed);
if (Number.isFinite(n)) { if (Number.isFinite(n)) {
setProject((p) => ({ ...p, referenceElevationMasl: n })); onReferenceElevationChange(n);
} }
}; };
return ( const dialog = (
<div className="imp-overlay" onClick={onClose}>
<div <div
className="imp-dialog" className={`imp-dialog${windowed ? " imp-dialog-embedded" : ""}`}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t("settings.title")} aria-label={t("settings.title")}
onClick={(e) => e.stopPropagation()} onClick={windowed ? undefined : (e) => e.stopPropagation()}
>
<header
className="imp-head"
{...(windowed ? { "data-tauri-drag-region": true } : {})}
> >
<header className="imp-head">
<span className="imp-head-title">{t("settings.title")}</span> <span className="imp-head-title">{t("settings.title")}</span>
{!windowed && (
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}> <button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
× ×
</button> </button>
)}
</header> </header>
<div className="imp-body"> <div className="imp-body">
<section className="imp-section"> <section className="imp-section">
<div className="imp-section-title">{t("settings.section.workspace")}</div> <div className="imp-section-title">{t("settings.section.workspace")}</div>
{layoutMenu} <LayoutMenu layout={layout} onApply={onApplyLayout} />
</section> </section>
<section className="imp-section"> <section className="imp-section">
@@ -136,6 +158,25 @@ export function SettingsDialog({
<section className="imp-section"> <section className="imp-section">
<div className="imp-section-title">{t("settings.section.display")}</div> <div className="imp-section-title">{t("settings.section.display")}</div>
<span className="settings-field-label">{t("settings.theme")}</span>
<span className="sb-renderer-toggle" role="group" aria-label={t("settings.theme")}>
<button
type="button"
className={`sb-renderer-btn${themeMode === "light" ? " active" : ""}`}
aria-pressed={themeMode === "light"}
onClick={() => onThemeModeChange("light")}
>
{t("settings.theme.light")}
</button>
<button
type="button"
className={`sb-renderer-btn${themeMode === "dark" ? " active" : ""}`}
aria-pressed={themeMode === "dark"}
onClick={() => onThemeModeChange("dark")}
>
{t("settings.theme.dark")}
</button>
</span>
<AccentColorField <AccentColorField
label={t("settings.marqueeColor")} label={t("settings.marqueeColor")}
hint={t("settings.marqueeColor.hint")} hint={t("settings.marqueeColor.hint")}
@@ -169,12 +210,20 @@ export function SettingsDialog({
</section> </section>
</div> </div>
{!windowed && (
<footer className="imp-foot"> <footer className="imp-foot">
<button className="imp-btn primary" onClick={onClose}> <button className="imp-btn primary" onClick={onClose}>
{t("export.close")} {t("export.close")}
</button> </button>
</footer> </footer>
)}
</div> </div>
);
if (windowed) return dialog;
return (
<div className="imp-overlay" onClick={onClose}>
{dialog}
</div> </div>
); );
} }
+124 -594
View File
@@ -16,10 +16,6 @@ import { Dropdown } from "./Dropdown";
import type { DropdownItem, DropdownOption } from "./Dropdown"; import type { DropdownItem, DropdownOption } from "./Dropdown";
import { PromptDialog } from "./PromptDialog"; import { PromptDialog } from "./PromptDialog";
import { WindowControls } from "./WindowControls"; import { WindowControls } from "./WindowControls";
import { RibbonTabs } from "./ribbon/RibbonBar";
import type { RibbonTabId } from "./ribbon/ribbonItems";
import { getCommand } from "../commands/registry";
import { CommandIcon } from "./ribbon/CommandIcon";
import { import {
applyMark, applyMark,
applyPreset, applyPreset,
@@ -57,6 +53,7 @@ export type DetailLevel = "grob" | "mittel" | "fein";
export type RenderMode = export type RenderMode =
| "shaded" | "shaded"
| "white" | "white"
| "contour"
| "textured" | "textured"
| "wireframe" | "wireframe"
| "hidden" | "hidden"
@@ -105,14 +102,11 @@ const ZOOM_CLUSTER_WIDTH = 140;
export interface TopBarProps { export interface TopBarProps {
project: Project; project: Project;
// Ribbon-Tab-Reiter (2D/3D/BIM/Ansichten) sitzen in DIESER Zeile — der Ribbon- // Kein Ribbon-Tab-Reiter mehr (Werkzeuge leben im ToolsPanel, siehe
// Inhalt darunter (RibbonBar). Der aktive Tab liegt in App (geteilter State). // ribbonItems.ts/layout.ts v12) — die TopBar ist nur noch die schmale
ribbonTab: RibbonTabId; // globale Leiste: Marke/Menü · Datei/Export/Einstellungen. Ansichts-/Zoom-/
onRibbonTab: (id: RibbonTabId) => void; // Darstellungs-Steuerung folgt direkt darunter (siehe ViewRibbonTab, von
// App unconditional gerendert statt über Tab-Umschaltung).
// Ansichts-/Zoom-/Darstellungs-Steuerung ist in den „Ansichten"-Ribbon-Tab
// gewandert (siehe ViewRibbonTab). Die TopBar ist nur noch die schmale globale
// Leiste: Marke/Ressourcen · Tabs · Text-Formatierung · Datei/Export/Einstell.
/** Ob Zoom/Plan aktiv ist — steuert nur noch, ob Export im Datei-Menü geht. */ /** Ob Zoom/Plan aktiv ist — steuert nur noch, ob Export im Datei-Menü geht. */
zoomEnabled: boolean; zoomEnabled: boolean;
@@ -155,8 +149,16 @@ export interface TopBarProps {
/** Projekt aus einer Datei laden. */ /** Projekt aus einer Datei laden. */
onOpenProject: () => void; onOpenProject: () => void;
// Die Text-/Font-Formatierung (TextGroup) liegt jetzt im „Text"-Ribbon-Tab /** Menüleiste (Bearbeiten/Ansicht/Fenster, {@link AppMenuBar}) von App
// (App reicht sie via RibbonBar.tabContent), nicht mehr in dieser Leiste. * zusammengesetzt (dort leben Undo/Redo/Auswahl/View-State), hier nur
* platziert. `undefined` blendet sie aus (z. B. für künftige eingebettete
* Kontexte ohne volle Chrome). */
menuBar?: React.ReactNode;
/** Ansichts-/Zoom-/Darstellungs-Steuerung ({@link ViewRibbonTab}) von App
* geliefert (dort leben View-State/Setter), hier direkt in DERSELBEN
* schmalen Leiste platziert (kein eigenes Band mehr, Mockup: EINE Zeile). */
viewControls?: React.ReactNode;
} }
/** /**
@@ -191,18 +193,6 @@ function Sep() {
return <span className="tb-sep" aria-hidden="true" />; return <span className="tb-sep" aria-hidden="true" />;
} }
/**
* Material-Symbols-Icon für die Oberleiste (gleiche Icon-Font wie ContextMenu/
* MenuIcon). Rein dekorativ der sprechende Text steckt im `title`/aria-label
* des umgebenden Buttons.
*/
function TbIcon({ name }: { name: string }) {
return (
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
{name}
</span>
);
}
/** /**
* Kleines Kombinations-Menü (Vectorworks-Stil) gespiegelt von `LayoutMenu`: * Kleines Kombinations-Menü (Vectorworks-Stil) gespiegelt von `LayoutMenu`:
@@ -288,88 +278,6 @@ function ComboMenu({
); );
} }
/**
* Kamera-Einstellungen als kleines Popover (Vectorworks-Stil): ein FOV-Regler
* für den Sichtwinkel der Perspektivkamera. Öffnet/schliesst per Button; ein
* Klick ausserhalb schliesst es. Rein gesteuert über `fov`/`onFov`.
*/
function CameraMenu({
fov,
onFov,
disabled,
}: {
fov: number;
onFov: (n: number) => void;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
// Popover-Position (fixed), aus dem Button-Rect berechnet — die Oberleiste
// hat overflow:hidden und würde ein absolut positioniertes Popover abschneiden.
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const wrapRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const toggle = () => {
const next = !open;
if (next && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
setPos({ left: r.left, top: r.bottom + 6 });
}
setOpen(next);
};
return (
<div className="tb-popwrap" ref={wrapRef}>
<button
ref={btnRef}
className={`view-icon${open ? " active" : ""}`}
disabled={disabled}
onClick={toggle}
title={`${t("view3d.camera")}${t("view3d.camera.hint")}`}
aria-label={t("view3d.camera")}
aria-haspopup="true"
aria-expanded={open}
>
<ViewIcon name="camera" />
</button>
{open && !disabled && (
<div
className="tb-popover"
role="dialog"
aria-label={t("view3d.camera")}
style={{ left: pos.left, top: pos.top }}
>
<label className="tb-poprow">
<span className="tb-label">
{t("view3d.fov")}: {Math.round(fov)}°
</span>
<input
type="range"
min={20}
max={90}
step={1}
value={fov}
onChange={(e) => onFov(Number(e.target.value))}
title={t("view3d.fov.hint")}
/>
</label>
</div>
)}
</div>
);
}
/** Die vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt). */ /** Die vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt). */
const ISO_VARIANTS: View3d[] = ["iso", "isoFrontLeft", "isoBackRight", "isoBackLeft"]; const ISO_VARIANTS: View3d[] = ["iso", "isoFrontLeft", "isoBackRight", "isoBackLeft"];
@@ -389,27 +297,42 @@ function IsoCheck() {
); );
} }
/** Die Elevations-Ansichten (Vorne/Hinten/Links/Rechts/Oben) als EIN Symbol
* + Dropdown zusammengefasst (statt fünf Buchstaben-Knöpfen). */
const ELEVATION_VIEWS: View3d[] = ["front", "back", "left", "side", "top"];
/** /**
* Iso-Oktanten als Split-Button (Vectorworks-Stil): Klick auf das Würfel-Icon * Split-Button mit Dropdown (Vectorworks-Stil): Klick auf das Symbol springt
* springt direkt auf die zuletzt gewählte Iso-Ansicht (Default iso" = * direkt auf die aktuelle/zuletzt gewählte Ansicht der Gruppe; der schmale
* vorne-oben-rechts, s. View3d); der schmale Pfeil daneben öffnet ein Popover * Pfeil daneben öffnet ein Popover mit allen Varianten. Generisch für die
* mit allen vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt). * Ansichten" (Elevations) UND „Iso" (Oktanten) beide teilen dieselbe
* Popover-Markup/Klassen wie `Dropdown`s Listenmodus (`ctx-menu`/`tb-dd-item`), * robuste Popover-Logik (fixed positioniert, entkommt dem Oberleisten-
* Positionierung wie `CameraMenu` (fixed, kein Portal entkommt dem * Overflow; schliesst bei Aussenklick).
* `overflow:hidden` der Oberleiste).
*/ */
function IsoMenu({ function ViewSplitMenu({
view3d, view3d,
onView3d, onView3d,
viewType, viewType,
onViewType, onViewType,
disabled, disabled,
variants,
fallback,
icon,
menuLabel,
}: { }: {
view3d: View3d; view3d: View3d;
onView3d: (v: View3d) => void; onView3d: (v: View3d) => void;
viewType: ViewType; viewType: ViewType;
onViewType: (v: ViewType) => void; onViewType: (v: ViewType) => void;
disabled: boolean; disabled: boolean;
/** Die im Dropdown wählbaren Ansichten dieser Gruppe. */
variants: View3d[];
/** Ziel des Symbol-Klicks, falls die aktuelle Ansicht nicht zur Gruppe gehört. */
fallback: View3d;
/** Symbol des Haupt-Knopfs. */
icon: React.ReactNode;
/** Beschriftung (aria/Tooltip) der Gruppe. */
menuLabel: string;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 }); const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
@@ -427,8 +350,8 @@ function IsoMenu({
return () => document.removeEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown);
}, [open]); }, [open]);
const current: View3d = ISO_VARIANTS.includes(view3d) ? view3d : "iso"; const current: View3d = variants.includes(view3d) ? view3d : fallback;
const active = viewType === "perspektive" && ISO_VARIANTS.includes(view3d); const active = viewType === "perspektive" && variants.includes(view3d);
const select = (v: View3d) => { const select = (v: View3d) => {
onView3d(v); onView3d(v);
@@ -448,21 +371,23 @@ function IsoMenu({
return ( return (
<div className="tb-popwrap view-icon-split" ref={wrapRef}> <div className="tb-popwrap view-icon-split" ref={wrapRef}>
<button <button
type="button"
className={`view-icon view-icon-split-main${active ? " active" : ""}`} className={`view-icon view-icon-split-main${active ? " active" : ""}`}
disabled={disabled} disabled={disabled}
onClick={() => select(current)} onClick={() => select(current)}
title={disabled ? t("topbar.viewGroup.disabled") : `${t(`view3d.${current}`)}${t(`view3d.${current}.hint`)}`} title={disabled ? t("topbar.viewGroup.disabled") : `${t(`view3d.${current}`)}${t(`view3d.${current}.hint`)}`}
aria-label={t("view3d.iso")} aria-label={menuLabel}
> >
<ViewIcon name="iso" /> {icon}
</button> </button>
<button <button
ref={caretRef} ref={caretRef}
type="button"
className={`view-icon-caret${open ? " active" : ""}`} className={`view-icon-caret${open ? " active" : ""}`}
disabled={disabled} disabled={disabled}
onClick={toggle} onClick={toggle}
title={disabled ? t("topbar.viewGroup.disabled") : `${t("view3d.iso.menu")}${t("view3d.iso.menu.hint")}`} title={disabled ? t("topbar.viewGroup.disabled") : menuLabel}
aria-label={t("view3d.iso.menu")} aria-label={menuLabel}
aria-haspopup="true" aria-haspopup="true"
aria-expanded={open} aria-expanded={open}
> >
@@ -470,14 +395,16 @@ function IsoMenu({
<path d="M0 0l5 6 5-6z" fill="currentColor" /> <path d="M0 0l5 6 5-6z" fill="currentColor" />
</svg> </svg>
</button> </button>
{open && !disabled && ( {open &&
!disabled &&
createPortal(
<div <div
className="ctx-menu tb-dd-menu" className="ctx-menu tb-dd-menu"
role="menu" role="menu"
aria-label={t("view3d.iso.menu")} aria-label={menuLabel}
style={{ left: pos.left, top: pos.top }} style={{ left: pos.left, top: pos.top }}
> >
{ISO_VARIANTS.map((v) => ( {variants.map((v) => (
<button <button
key={v} key={v}
type="button" type="button"
@@ -491,7 +418,8 @@ function IsoMenu({
<span className="ctx-item-label">{t(`view3d.${v}`)}</span> <span className="ctx-item-label">{t(`view3d.${v}`)}</span>
</button> </button>
))} ))}
</div> </div>,
document.body,
)} )}
</div> </div>
); );
@@ -596,6 +524,28 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
} }
} }
/** Symbol für die Ansichten" (Elevations): eine Fassade Rechteck mit Tür +
* Bodenlinie (statt des unklaren Quadrat-mit-Steg-Icons). */
function ElevationIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
aria-hidden="true"
>
<rect x="3.5" y="2.5" width="9" height="10.5" />
<rect x="6.4" y="8" width="3.2" height="5" />
<line x1="1.5" y1="13" x2="14.5" y2="13" />
</svg>
);
}
/** Eine Zelle einer Segmentpille. */ /** Eine Zelle einer Segmentpille. */
interface SegmentCell { interface SegmentCell {
/** Stabiler Schlüssel. */ /** Stabiler Schlüssel. */
@@ -1059,191 +1009,10 @@ function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[]
return out; return out;
} }
/** /** App-Version (spiegelt src-tauri/tauri.conf.json). Exportiert für appMenu.ts
* Datei-Menü der Oberleiste (Burger, ganz rechts): Speichern/Öffnen des * (native macOS-Menüleiste, Über Dossier"), damit die Versionsnummer nur an
* Projekts sowie Import (DXF/DWG) und Export (PDF/DXF) vorher eigene * dieser einen Stelle gepflegt wird. */
* Icon-Pillen im Zoom-Bereich, jetzt gebündelt hinter einem Menü, damit die export const APP_VERSION = "0.1.0";
* Leiste ruhiger bleibt (GTK-Burger-Stil).
*/
/** Ein kleiner Icon-Knopf der Quick-Access-Leiste (OCS-Stil). */
function QaButton({
icon,
label,
onClick,
disabled,
active,
}: {
icon: string;
label: string;
onClick: () => void;
disabled?: boolean;
active?: boolean;
}) {
return (
<button
type="button"
className={`tb-qa-btn${active ? " active" : ""}`}
onClick={onClick}
disabled={disabled}
aria-pressed={active}
aria-label={label}
title={label}
>
<TbIcon name={icon} />
</button>
);
}
/** Kleiner Trenner in der Quick-Access-Leiste. */
function QaSep() {
return <span className="tb-qa-sep" aria-hidden="true" />;
}
/**
* Quick-Access-Leiste (OCS-Stil) alle früheren Datei-Burger-Aktionen plus
* Ressourcen/Einstellungen als kleine Icon-Knöpfe direkt in der TopBar-Zeile.
* Ersetzt das Burger-Menü (`AppMenu`). Rein gesteuert über Callbacks (i18n via t).
*/
/**
* Kompakte Kamera-Gruppe der Quick-Access-Leiste: NUR das Kamera-Settings-
* Popover (FOV), damit es immer erreichbar ist, ohne die Ansichts-Presets
* (die bleiben unverändert im Ansichten"-Tab und im 3D-Ribbon-Tab).
*/
function QaCameraGroup({
viewType,
fov,
onFov,
cameraEnabled,
}: {
viewType: ViewType;
fov: number;
onFov: (n: number) => void;
cameraEnabled: boolean;
}) {
return (
<div className="tb-qa-camera" role="toolbar" aria-label={t("topbar.viewGroup")}>
<CameraMenu
fov={fov}
onFov={onFov}
disabled={!cameraEnabled || viewType !== "perspektive"}
/>
</div>
);
}
/** App-Version (spiegelt src-tauri/tauri.conf.json). */
const APP_VERSION = "0.1.0";
/**
* Export-Sammelmenü: EIN Export"-Knopf in der Quick-Access-Leiste öffnet ein
* Dropdown (wie ein Kontextmenü) mit allen Export-Formaten ersetzt die früher
* einzeln aufgereihten Export-Icons. Popover fixed positioniert (die Oberleiste
* hat overflow:hidden), Muster wie {@link CameraMenu}.
*/
function ExportMenu({
onExportPdf,
onExportDxf,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
exportEnabled,
}: {
onExportPdf: () => void;
onExportDxf: () => void;
onExportSchedule: () => void;
onExportIfc: () => void;
onExportObj: () => void;
onExportStl: () => void;
exportEnabled: boolean;
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const wrapRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
// Das Popover hängt per Portal an body (NICHT in wrapRef) — der Outside-Handler
// muss es daher separat als „innen" behandeln, sonst schliesst der mousedown
// das Menü vor dem Klick.
const popRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (wrapRef.current?.contains(target)) return;
if (popRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const toggle = () => {
const next = !open;
if (next && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
setPos({ left: r.left, top: r.bottom + 6 });
}
setOpen(next);
};
const pick = (fn: () => void) => {
setOpen(false);
fn();
};
const items: { icon: string; label: string; onClick: () => void; disabled?: boolean }[] = [
{ icon: "picture_as_pdf", label: t("file.exportPdf"), onClick: onExportPdf, disabled: !exportEnabled },
{ icon: "output", label: t("file.exportDxf"), onClick: onExportDxf, disabled: !exportEnabled },
{ icon: "table_view", label: t("file.exportSchedule"), onClick: onExportSchedule },
{ icon: "deployed_code", label: t("file.exportIfc"), onClick: onExportIfc },
{ icon: "view_in_ar", label: t("file.exportObj"), onClick: onExportObj },
{ icon: "landscape", label: t("file.exportStl"), onClick: onExportStl },
];
return (
<div className="tb-popwrap" ref={wrapRef}>
<button
ref={btnRef}
type="button"
className={`tb-qa-btn${open ? " active" : ""}`}
onClick={toggle}
title={t("file.export")}
aria-label={t("file.export")}
aria-haspopup="true"
aria-expanded={open}
>
<TbIcon name="ios_share" />
</button>
{open &&
createPortal(
<div
ref={popRef}
className="tb-popover tb-menu"
role="menu"
aria-label={t("file.export")}
style={{ left: pos.left, top: pos.top }}
>
{items.map((it) => (
<button
key={it.label}
type="button"
className="ctx-item"
role="menuitem"
disabled={it.disabled}
onClick={() => pick(it.onClick)}
>
<span className="ctx-item-icon">
<TbIcon name={it.icon} />
</span>
<span className="ctx-item-label">{it.label}</span>
</button>
))}
</div>,
document.body,
)}
</div>
);
}
/** /**
* Über"-Dialog (In-App, dunkel) App-Marke, Version, Kurzbeschreibung und die * Über"-Dialog (In-App, dunkel) App-Marke, Version, Kurzbeschreibung und die
@@ -1298,77 +1067,6 @@ function AboutDialog({ onClose }: { onClose: () => void }) {
); );
} }
function QuickAccess({
onOpenProject,
onSaveProject,
onImport,
onExportPdf,
onExportDxf,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
onToggleResources,
onOpenSettings,
resourcesOpen,
exportEnabled,
viewType,
fov,
onFov,
cameraEnabled,
}: {
onOpenProject: () => void;
onSaveProject: () => void;
onImport: () => void;
onExportPdf: () => void;
onExportDxf: () => void;
onExportSchedule: () => void;
onExportIfc: () => void;
onExportObj: () => void;
onExportStl: () => void;
onToggleResources: () => void;
onOpenSettings: () => void;
resourcesOpen: boolean;
exportEnabled: boolean;
viewType: ViewType;
fov: number;
onFov: (n: number) => void;
cameraEnabled: boolean;
}) {
return (
<div className="tb-qa" role="toolbar" aria-label={t("file.menu")}>
<QaButton icon="folder_open" label={t("file.open")} onClick={onOpenProject} />
<QaButton icon="save" label={t("file.save")} onClick={onSaveProject} />
<QaSep />
<QaButton icon="download" label={t("file.import")} onClick={onImport} />
<ExportMenu
onExportPdf={onExportPdf}
onExportDxf={onExportDxf}
onExportSchedule={onExportSchedule}
onExportIfc={onExportIfc}
onExportObj={onExportObj}
onExportStl={onExportStl}
exportEnabled={exportEnabled}
/>
<QaSep />
<QaCameraGroup
viewType={viewType}
fov={fov}
onFov={onFov}
cameraEnabled={cameraEnabled}
/>
<QaSep />
<QaButton
icon="widgets"
label={t("topbar.resources")}
onClick={onToggleResources}
active={resourcesOpen}
/>
<QaButton icon="settings" label={t("topbar.settings")} onClick={onOpenSettings} />
</div>
);
}
/** /**
* Props des Ansichten"-Tabs die Ansichts-/Zoom-/Darstellungs-Steuerung, die * Props des Ansichten"-Tabs die Ansichts-/Zoom-/Darstellungs-Steuerung, die
* früher in der Mitte der TopBar sass. Eine Teilmenge von {@link TopBarProps}. * früher in der Mitte der TopBar sass. Eine Teilmenge von {@link TopBarProps}.
@@ -1469,70 +1167,61 @@ export function ViewRibbonTab({
return ( return (
<div className="ribbon-views"> <div className="ribbon-views">
{/* Ansichten als EIN kompaktes Icon-Grid (2D-Grundriss + 3D-Blicke). */} {/* Reihenfolge: Grundriss · Ansichten · Iso · Axo ohne Trennstriche
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}> (eine zusammenhängende Symbol-Zeile, Nutzer-Wunsch). */}
<div className="view-grid view-grid-4"> <span className="tb-viewtoggle" role="group" aria-label={t("topbar.viewGroup")}>
<button <button
className={`view-icon${ type="button"
className={`tb-vt-btn${
viewToggleEnabled && viewType === "grundriss" ? " active" : "" viewToggleEnabled && viewType === "grundriss" ? " active" : ""
}`} }`}
onClick={() => onViewType("grundriss")} onClick={() => onViewType("grundriss")}
disabled={!viewToggleEnabled} disabled={!viewToggleEnabled}
title={ title={viewToggleEnabled ? t("topbar.grundriss") : t("topbar.viewGroup.disabled")}
viewToggleEnabled
? t("topbar.grundriss")
: t("topbar.viewGroup.disabled")
}
aria-label={t("topbar.grundriss")} aria-label={t("topbar.grundriss")}
> >
<ViewIcon name="grundriss" /> <ViewIcon name="grundriss" />
</button> </button>
{( <ViewSplitMenu
[
["top", t("view3d.top")],
["iso", t("view3d.iso")],
["perspective", t("view3d.perspective")],
["front", t("view3d.front")],
["side", t("view3d.side")],
["back", t("view3d.back")],
["left", t("view3d.left")],
] as [View3d, string][]
).map(([key, label]) =>
key === "iso" ? (
<IsoMenu
key={key}
view3d={view3d} view3d={view3d}
onView3d={onView3d} onView3d={onView3d}
viewType={viewType} viewType={viewType}
onViewType={onViewType} onViewType={onViewType}
disabled={!viewToggleEnabled} disabled={!viewToggleEnabled}
variants={ELEVATION_VIEWS}
fallback="front"
icon={<ElevationIcon />}
menuLabel={t("topbar.views")}
/>
<ViewSplitMenu
view3d={view3d}
onView3d={onView3d}
viewType={viewType}
onViewType={onViewType}
disabled={!viewToggleEnabled}
variants={ISO_VARIANTS}
fallback="iso"
icon={<ViewIcon name="iso" />}
menuLabel={t("view3d.iso.menu")}
/> />
) : (
<button <button
key={key} type="button"
className={`view-icon${ className={`tb-vt-btn${
viewToggleEnabled && viewType === "perspektive" && view3d === key viewToggleEnabled && viewType === "perspektive" && view3d === "perspective"
? " active" ? " active"
: "" : ""
}`} }`}
onClick={() => { onClick={() => {
onView3d(key); onView3d("perspective");
if (viewType !== "perspektive") onViewType("perspektive"); if (viewType !== "perspektive") onViewType("perspektive");
}} }}
disabled={!viewToggleEnabled} disabled={!viewToggleEnabled}
title={ title={viewToggleEnabled ? `${t("topbar.axo")}${t("topbar.axo.hint")}` : t("topbar.viewGroup.disabled")}
viewToggleEnabled aria-label={t("topbar.axo")}
? `${label}${t(`view3d.${key}.hint`)}`
: t("topbar.viewGroup.disabled")
}
aria-label={label}
> >
<ViewIcon name={key} /> <ViewIcon name="perspective" />
</button> </button>
), </span>
)}
</div>
</div>
<Sep /> <Sep />
{/* Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen). */} {/* Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen). */}
@@ -1595,6 +1284,7 @@ export function ViewRibbonTab({
{ value: "shaded", label: t("display.style.shaded") }, { value: "shaded", label: t("display.style.shaded") },
{ value: "shaded-edges", label: t("display.style.shadedEdges") }, { value: "shaded-edges", label: t("display.style.shadedEdges") },
{ value: "white", label: t("display.style.white") }, { value: "white", label: t("display.style.white") },
{ value: "contour", label: t("display.style.contour") },
{ value: "textured", label: t("display.style.textured") }, { value: "textured", label: t("display.style.textured") },
{ value: "wireframe", label: t("display.style.wireframe") }, { value: "wireframe", label: t("display.style.wireframe") },
{ value: "hidden", label: t("display.style.hidden") }, { value: "hidden", label: t("display.style.hidden") },
@@ -1613,9 +1303,9 @@ export function ViewRibbonTab({
)} )}
</label> </label>
</div> </div>
<Sep />
{/* Massstab/Zoom-Cluster (2×2). */} {/* Massstab/Zoom + Text-Formatierung OHNE Trennstriche, gleich gross wie
die Dropdowns (Mockup). */}
<div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}> <div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}>
<div <div
className={`tb-stat${ className={`tb-stat${
@@ -1671,8 +1361,6 @@ export function ViewRibbonTab({
/> />
</div> </div>
</div> </div>
<Sep />
{/* Schatten ein/aus NUR für eine aktive Ansicht (kind "elevation"). {/* Schatten ein/aus NUR für eine aktive Ansicht (kind "elevation").
Schreibt DrawingLevel.shadows; der Ansichts-Generator (toElevation) Schreibt DrawingLevel.shadows; der Ansichts-Generator (toElevation)
@@ -1710,149 +1398,10 @@ export function ViewRibbonTab({
); );
} }
export interface Ribbon3dTabProps {
viewToggleEnabled: boolean;
viewType: ViewType;
onViewType: (v: ViewType) => void;
view3d: View3d;
onView3d: (v: View3d) => void;
renderMode: RenderMode;
onRenderMode: (m: RenderMode) => void;
renderModeEnabled: boolean;
onRunCommand: (name: string) => void;
activeCommand: string | null;
}
/** 3D-Tab des Ribbons Kamera-Presets + Darstellungsart. Kein Duplikat des
* Ansichten-Tabs: hier nur 3D-relevante Steuerung (Blickwinkel, Rendermodus). */
export function Ribbon3dTab({
viewToggleEnabled,
viewType,
onViewType,
view3d,
onView3d,
renderMode,
onRenderMode,
renderModeEnabled,
onRunCommand,
activeCommand,
}: Ribbon3dTabProps) {
return (
<div className="ribbon-views">
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
<div className="view-grid view-grid-4">
<button
className={`view-icon${viewToggleEnabled && viewType === "grundriss" ? " active" : ""}`}
onClick={() => onViewType("grundriss")}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? t("topbar.grundriss") : t("topbar.viewGroup.disabled")}
aria-label={t("topbar.grundriss")}
>
<ViewIcon name="grundriss" />
</button>
{(
[
["top", t("view3d.top")],
["iso", t("view3d.iso")],
["perspective", t("view3d.perspective")],
["front", t("view3d.front")],
["side", t("view3d.side")],
["back", t("view3d.back")],
["left", t("view3d.left")],
] as [View3d, string][]
).map(([key, label]) =>
key === "iso" ? (
<IsoMenu
key={key}
view3d={view3d}
onView3d={onView3d}
viewType={viewType}
onViewType={onViewType}
disabled={!viewToggleEnabled}
/>
) : (
<button
key={key}
className={`view-icon${viewToggleEnabled && viewType === "perspektive" && view3d === key ? " active" : ""}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? `${label}${t(`view3d.${key}.hint`)}` : t("topbar.viewGroup.disabled")}
aria-label={label}
>
<ViewIcon name={key} />
</button>
),
)}
</div>
</div>
<Sep />
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
<label className="tb-field">
<span className="tb-label">{t("display.style")}</span>
<Dropdown
value={renderMode}
disabled={!renderModeEnabled}
onChange={(v) => onRenderMode(v as RenderMode)}
title={t("display.style.hint")}
options={[
{ value: "shaded", label: t("display.style.shaded") },
{ value: "shaded-edges", label: t("display.style.shadedEdges") },
{ value: "white", label: t("display.style.white") },
{ value: "textured", label: t("display.style.textured") },
{ value: "wireframe", label: t("display.style.wireframe") },
{ value: "hidden", label: t("display.style.hidden") },
]}
/>
</label>
</div>
<Sep />
<div className="tb-group" role="group" aria-label={t("cmd.extrude.label")}>
{(() => {
const cmd = getCommand("extrude");
const label = cmd ? t(cmd.labelKey) : "extrude";
const active = activeCommand === "extrude";
return (
<button
type="button"
className={`ribbon-btn${active ? " active" : ""}`}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? label : t("tool.floorOnlyDisabled")}
onClick={() => onRunCommand("extrude")}
>
<CommandIcon name="extrude" />
<span>{label}</span>
</button>
);
})()}
</div>
</div>
);
}
export function TopBar({ export function TopBar({
project, project,
ribbonTab, menuBar,
onRibbonTab, viewControls,
zoomEnabled,
onExportPdf,
onExportDxf,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
viewType,
fov,
onFov,
cameraEnabled,
resourcesOpen,
onToggleResources,
onOpenSettings,
onImport,
onSaveProject,
onOpenProject,
}: TopBarProps) { }: TopBarProps) {
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt // Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste // der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
@@ -1911,36 +1460,17 @@ export function TopBar({
</button> </button>
</div> </div>
{aboutOpen && <AboutDialog onClose={() => setAboutOpen(false)} />} {aboutOpen && <AboutDialog onClose={() => setAboutOpen(false)} />}
<QuickAccess {menuBar}
onOpenProject={onOpenProject}
onSaveProject={onSaveProject}
onImport={onImport}
onExportPdf={onExportPdf}
onExportDxf={onExportDxf}
onExportSchedule={onExportSchedule}
onExportIfc={onExportIfc}
onExportObj={onExportObj}
onExportStl={onExportStl}
onToggleResources={onToggleResources}
onOpenSettings={onOpenSettings}
resourcesOpen={resourcesOpen}
exportEnabled={zoomEnabled}
viewType={viewType}
fov={fov}
onFov={onFov}
cameraEnabled={cameraEnabled}
/>
<Sep /> {/* Ansichts-/Zoom-/Darstellungs-Steuerung direkt in DERSELBEN Zeile
(Mockup: EINE schmale Leiste, kein eigenes Band mehr). */}
{viewControls && <Sep />}
{viewControls}
{/* Ribbon-Tab-Reiter (2D/3D/BIM/Text/Ansichten) sitzen in dieser Zeile, {/* Rechte Gruppe: Datei-/Export-Aktionen (Quick-Access) + Projektname
der Ribbon-Inhalt folgt darunter (RibbonBar). So gibt es nur ZWEI per margin-left:auto an den rechten Rand geschoben (Mockup:
Bänder: diese schmale Leiste (Chrome + Tabs) und den Inhalt. */} Projektname ganz rechts). Die Fensterknöpfe sitzen ganz links
<RibbonTabs tab={ribbonTab} onTab={onRibbonTab} /> (macOS-Titelleisten-Konvention). */}
{/* Rechte Gruppe: Projektname. Die Fensterknöpfe sitzen ganz links (macOS-
Titelleisten-Konvention); Datei-/Export-Aktionen in der Quick-Access-
Leiste; die Arbeitsumgebung (Layout) liegt jetzt in den Einstellungen. */}
<div className="topbar-right"> <div className="topbar-right">
<span className="project-name">{project.name}</span> <span className="project-name">{project.name}</span>
</div> </div>
+28
View File
@@ -116,6 +116,34 @@ export function CommandIcon({ name }: { name: string }) {
<rect x="6" y="6.5" width="4" height="3" strokeDasharray="1.4 1.2" /> <rect x="6" y="6.5" width="4" height="3" strokeDasharray="1.4 1.2" />
</svg> </svg>
); );
case "extrude":
// Extrusion: Grundfläche + nach oben herausgezogenes Volumen (Prisma).
return (
<svg {...common}>
<rect x="2.5" y="7.5" width="7" height="5" />
<path d="M2.5 7.5 L5.5 4.5 L12.5 4.5 L9.5 7.5" />
<path d="M9.5 12.5 L12.5 9.5 L12.5 4.5" />
</svg>
);
case "sectionline":
// Schnittlinie: Achse mit Blickrichtungs-Winkeln (SIA-Schnittsymbol).
return (
<svg {...common}>
<line x1="2.5" y1="8" x2="13.5" y2="8" />
<polyline points="4.5,4.5 4.5,8 8,8" />
<polyline points="11.5,11.5 11.5,8 8,8" />
</svg>
);
case "viewline":
// Ansichtslinie: Standpunkt (Kreis) mit Blickkegel.
return (
<svg {...common}>
<circle cx="4" cy="8" r="1.6" />
<line x1="5.4" y1="8" x2="13" y2="4" />
<line x1="5.4" y1="8" x2="13" y2="12" />
<line x1="13" y1="4" x2="13" y2="12" />
</svg>
);
default: default:
return null; return null;
} }
+8 -51
View File
@@ -1,17 +1,14 @@
// Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert den INHALT des // Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert die Gruppen
// aktiven Ribbon-Tabs. Die Tab-REITER selbst sitzen in der TopBar-Zeile (eine // EINES festen Tabs (kein Tab-Umschalter mehr, v12: Werkzeuge leben im
// Leiste: globales Chrome + Tabs), damit es nur ZWEI Bänder gibt: TopBar-Zeile // ToolsPanel). App rendert mehrere `<RibbonBar tab="...">`-Instanzen permanent
// (mit Tabs) oben, Ribbon-Inhalt darunter. Der aktive Tab wird als `tab`-Prop // nebeneinander (2d/bim/custom) statt einer einzigen, umschaltbaren — jede
// hereingereicht (State liegt in App, geteilt mit der TopBar). // zeigt nur die Befehls-Gruppen ohne Panel-Entsprechung.
// //
// Werkzeug-Elemente laufen über `onSelectTool` (gekoppelte Befehle inklusive, wie // Werkzeug-Elemente laufen über `onSelectTool` (gekoppelte Befehle inklusive, wie
// in der Sidebar); Befehls-Elemente über `onRunCommand` (engine.start). Der // im ToolsPanel); Befehls-Elemente über `onRunCommand` (engine.start).
// „Ansichten"-Tab trägt reichen Custom-Inhalt (`viewsContent`, aus der TopBar
// gemergt) statt datengetriebener Item-Gruppen.
// //
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). // Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import type { ReactNode } from "react";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { ToolIcon } from "../../panels/ToolsPanel"; import { ToolIcon } from "../../panels/ToolsPanel";
import { getTool } from "../../tools/tools"; import { getTool } from "../../tools/tools";
@@ -43,13 +40,6 @@ export interface RibbonBarProps {
onSelectTool: (id: ToolId) => void; onSelectTool: (id: ToolId) => void;
/** Engine-Befehl starten (Ändern-Gruppe u. a.). */ /** Engine-Befehl starten (Ändern-Gruppe u. a.). */
onRunCommand: (name: string) => void; onRunCommand: (name: string) => void;
/**
* Reicher Custom-Inhalt je Tab (statt datengetriebener Item-Gruppen) z. B.
* Ansichten" (`ViewRibbonTab`) und „Text" (`TextGroup`). Von App geliefert,
* damit deren Setter/State dort bleiben. Ein Tab ohne Custom-Inhalt und ohne
* Gruppen zeigt den Leer-Hinweis.
*/
tabContent?: Partial<Record<RibbonTabId, ReactNode>>;
/** Persistierte Item-Schlüssel der modularen Custom-Bar (Tab „Eigene"). */ /** Persistierte Item-Schlüssel der modularen Custom-Bar (Tab „Eigene"). */
customItems: string[]; customItems: string[];
/** Ein Item in der Custom-Bar an-/abwählen (Picker im „Eigene"-Tab). */ /** Ein Item in der Custom-Bar an-/abwählen (Picker im „Eigene"-Tab). */
@@ -106,35 +96,6 @@ function RibbonButton({ item, ctx }: { item: RibbonItem; ctx: ButtonCtx }) {
); );
} }
/**
* Die Tab-REITER als eigene Komponente exportiert, weil sie in der TopBar-Zeile
* gerendert werden (nicht hier). Rein gesteuert über `tab`/`onTab`.
*/
export function RibbonTabs({
tab,
onTab,
}: {
tab: RibbonTabId;
onTab: (id: RibbonTabId) => void;
}) {
return (
<div className="ribbon-tabs" role="tablist">
{RIBBON_TABS.map((tt) => (
<button
key={tt.id}
type="button"
role="tab"
aria-selected={tt.id === tab}
className={`ribbon-tab${tt.id === tab ? " active" : ""}`}
onClick={() => onTab(tt.id)}
>
{t(tt.labelKey)}
</button>
))}
</div>
);
}
/** /**
* Modulare Custom-Bar (Tab Eigene") rendert die vom Nutzer gewählten Items * Modulare Custom-Bar (Tab Eigene") rendert die vom Nutzer gewählten Items
* plus einen "-Picker, der ALLE verfügbaren Werkzeuge/Befehle mit Häkchen für * plus einen "-Picker, der ALLE verfügbaren Werkzeuge/Befehle mit Häkchen für
@@ -184,7 +145,6 @@ function CustomBar({
export function RibbonBar(props: RibbonBarProps) { export function RibbonBar(props: RibbonBarProps) {
const current = RIBBON_TABS.find((tt) => tt.id === props.tab) ?? RIBBON_TABS[0]; const current = RIBBON_TABS.find((tt) => tt.id === props.tab) ?? RIBBON_TABS[0];
const custom = props.tabContent?.[current.id];
const ctx: ButtonCtx = { const ctx: ButtonCtx = {
activeTool: props.activeTool, activeTool: props.activeTool,
activeCommand: props.activeCommand, activeCommand: props.activeCommand,
@@ -195,9 +155,8 @@ export function RibbonBar(props: RibbonBarProps) {
return ( return (
<div className="ribbon"> <div className="ribbon">
{/* Inhalt des aktiven Tabs. Tabs mit reichem Custom-Inhalt (Ansichten", {/* Der Eigene"-Tab rendert die modulare Custom-Bar; sonst die
Text") rendern diesen; der „Eigene"-Tab die modulare Custom-Bar; sonst datengetriebenen Item-Gruppen. */}
die datengetriebenen Item-Gruppen. */}
<div className="ribbon-body"> <div className="ribbon-body">
{current.id === "custom" ? ( {current.id === "custom" ? (
<CustomBar <CustomBar
@@ -205,8 +164,6 @@ export function RibbonBar(props: RibbonBarProps) {
onToggle={props.onToggleCustomItem} onToggle={props.onToggleCustomItem}
ctx={ctx} ctx={ctx}
/> />
) : custom ? (
custom
) : current.groups.length === 0 ? ( ) : current.groups.length === 0 ? (
<div className="ribbon-empty">{t("ribbon.empty")}</div> <div className="ribbon-empty">{t("ribbon.empty")}</div>
) : ( ) : (
+14 -43
View File
@@ -28,7 +28,7 @@ export interface RibbonGroup {
} }
/** Kennung eines Ribbon-Tabs (fest, für Tab-State + i18n). */ /** Kennung eines Ribbon-Tabs (fest, für Tab-State + i18n). */
export type RibbonTabId = "views" | "2d" | "3d" | "bim" | "custom"; export type RibbonTabId = "2d" | "bim" | "custom";
/** Ein Ribbon-Tab: Beschriftung + gruppierte Elemente. */ /** Ein Ribbon-Tab: Beschriftung + gruppierte Elemente. */
export interface RibbonTab { export interface RibbonTab {
@@ -37,41 +37,24 @@ export interface RibbonTab {
groups: RibbonGroup[]; groups: RibbonGroup[];
} }
const t = (id: ToolId): RibbonItem => ({ kind: "tool", id });
const c = (name: string): RibbonItem => ({ kind: "command", name }); const c = (name: string): RibbonItem => ({ kind: "command", name });
/** /**
* Die Ribbon-Tabs (Reihenfolge Ansichten·2D·3D·BIM). Ansichten" ist der * Die Ribbon-Tabs (2D·BIM·Eigene) keine Tab-Reiter mehr in der UI (v12,
* Standard-Tab (zuerst, initial aktiv) und trägt reichen Custom-Inhalt: die aus * siehe layout.ts): App rendert alle drei permanent nebeneinander (kein
* der TopBar gemergte Ansichts-/Zoom-/Darstellungs-Steuerung UND die Text-/Font- * Umschalter). Reine Werkzeug-Elemente (`t(...)`, persistentes Werkzeug wie
* Formatierung auf DERSELBEN Leiste (`ViewRibbonTab` `groups` bleibt leer). 2D * Wand/Linie/Fenster) leben AUSSCHLIESSLICH im ToolsPanel (kein Doppel-
* (Zeichnen + Ändern) und BIM (Bauteile) sind datengetrieben aus Werkzeugen/ * Auftritt). Reine Befehls-Elemente (`c(...)`, Einmal-Aktionen wie
* Befehlen; 3D ist vorerst leer (spätere Phase). * Verschieben/Kopieren/Säule setzen ohne eigenen Werkzeug-Modus) bleiben hier,
* da sie im Panel keine Entsprechung haben. Ansichts-/Zoom-/Darstellungs-
* Steuerung + Text-Formatierung sitzen direkt in App (ViewRibbonTab), nicht
* in dieser Registry.
*/ */
export const RIBBON_TABS: RibbonTab[] = [ export const RIBBON_TABS: RibbonTab[] = [
{
// Custom-Inhalt (ViewRibbonTab): Ansichts-/Zoom-/Darstellungssteuerung + Text.
id: "views",
labelKey: "ribbon.tab.views",
groups: [],
},
{ {
id: "2d", id: "2d",
labelKey: "ribbon.tab.2d", labelKey: "ribbon.tab.2d",
groups: [ groups: [
{
titleKey: "ribbon.group.draw",
items: [
t("select"),
t("line"),
t("polyline"),
t("rect"),
t("circle"),
t("arc"),
t("text"),
t("textbox"),
],
},
{ {
titleKey: "ribbon.group.modify", titleKey: "ribbon.group.modify",
items: [ items: [
@@ -87,28 +70,16 @@ export const RIBBON_TABS: RibbonTab[] = [
}, },
], ],
}, },
{
id: "3d",
labelKey: "ribbon.tab.3d",
groups: [],
},
{ {
id: "bim", id: "bim",
labelKey: "ribbon.tab.bim", labelKey: "ribbon.tab.bim",
groups: [ groups: [
{ {
// "extrude" war vormals im (jetzt entfernten) 3D-Ribbon-Tab
// (Ribbon3dTab) — einziger Zugang zum Befehl, daher hier statt
// gestrichen.
titleKey: "ribbon.group.components", titleKey: "ribbon.group.components",
items: [ items: [c("column"), c("ceilingopening"), c("roof"), c("extrude")],
t("wall"),
t("window"),
t("door"),
t("stair"),
c("column"),
t("ceiling"),
c("ceilingopening"),
c("roof"),
t("room"),
],
}, },
{ {
titleKey: "ribbon.group.sections", titleKey: "ribbon.group.sections",
+16 -1
View File
@@ -135,9 +135,22 @@ export function Viewport3D(
* Wasm3DViewport); die three.js-Sicht hebt die Auswahl weiterhin über ihr * Wasm3DViewport); die three.js-Sicht hebt die Auswahl weiterhin über ihr
* eigenes Material hervor (highlightMat in ThreeViewport3D). */ * eigenes Material hervor (highlightMat in ThreeViewport3D). */
highlightLines?: Float32Array | null; highlightLines?: Float32Array | null;
/** Aktive 3D-Schnittebene (nur WASM-Pfad) s. Wasm3DViewport `section3d`.
* Bindet den Live-Schnitt an eine Grundriss-Schnittlinie. */
section3d?: { point: [number, number, number]; normal: [number, number, number] } | null;
/** Klick auf den gebundenen Schnitt-Knopf im WASM-Viewport → Bindung lösen. */
onClearSection3d?: () => void;
}, },
) { ) {
const { gridVisible, onToggleGrid, onPick3d, highlightLines, ...threeProps } = props; const {
gridVisible,
onToggleGrid,
onPick3d,
highlightLines,
section3d,
onClearSection3d,
...threeProps
} = props;
if (WASM_ENGINE_ACTIVE) { if (WASM_ENGINE_ACTIVE) {
return ( return (
<Wasm3DViewport <Wasm3DViewport
@@ -162,6 +175,8 @@ export function Viewport3D(
onEditRoofPitch={props.onEditRoofPitch} onEditRoofPitch={props.onEditRoofPitch}
onEditWallTop={props.onEditWallTop} onEditWallTop={props.onEditWallTop}
onEditEnd={props.onEditEnd} onEditEnd={props.onEditEnd}
section3d={section3d}
onClearSection3d={onClearSection3d}
/> />
); );
} }
+44 -31
View File
@@ -274,6 +274,14 @@ function fitTargetDist(project: Project): {
grow(p[0], s.zTop, p[1]); grow(p[0], s.zTop, p[1]);
} }
} }
// Meshes (Dächer, Extrusionen, Stützen UND importierte Gebäude/Terrain) —
// ohne die kann „Alles einpassen" den (evtl. weit entfernten) Import nicht
// rahmen: model (x,y,z=Höhe) → world (x, z, y). Nutzer-Report: „im 3D
// funktioniert das Fokussieren / Alles-anzeigen nicht".
for (const m of model.meshes) {
const pos = m.positions;
for (let i = 0; i + 2 < pos.length; i += 3) grow(pos[i], pos[i + 2], pos[i + 1]);
}
// Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start und dem // Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start und dem
// Rust-Fallback in `set_view_preset` bei fehlender Bounding-Box). // Rust-Fallback in `set_view_preset` bei fehlender Bounding-Box).
if (!Number.isFinite(minX)) { if (!Number.isFinite(minX)) {
@@ -334,6 +342,8 @@ export function Wasm3DViewport({
onEditRoofPitch, onEditRoofPitch,
onEditWallTop, onEditWallTop,
onEditEnd, onEditEnd,
section3d = null,
onClearSection3d,
}: { }: {
project: Project; project: Project;
/** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */ /** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */
@@ -393,6 +403,12 @@ export function Wasm3DViewport({
onEditWallTop?: (wallId: string, topZ: number) => void; onEditWallTop?: (wallId: string, topZ: number) => void;
/** Ende eines Griff-Drags. */ /** Ende eines Griff-Drags. */
onEditEnd?: () => void; onEditEnd?: () => void;
/** Aktive 3D-Schnittebene (world point+normal) aus der gebundenen Schnittlinie
* (App-State, `sectionPlaneFromLevel`), oder `null` = kein Live-Schnitt.
* Ersetzt den früheren lokalen Hardcode-Toggle. */
section3d?: { point: [number, number, number]; normal: [number, number, number] } | null;
/** Klick auf den Viewport-Schnitt-Knopf: hebt die Bindung auf (App löscht die Id). */
onClearSection3d?: () => void;
}) { }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
// Pick-Callback in einem Ref spiegeln, damit der (einmal registrierte) // Pick-Callback in einem Ref spiegeln, damit der (einmal registrierte)
@@ -438,11 +454,9 @@ export function Wasm3DViewport({
ready, ready,
failed, failed,
} = useWasm3dRenderer(canvasRef, true); } = useWasm3dRenderer(canvasRef, true);
// Live-Schnitt-Zustand (lokaler Overlay-Umschalter, MVP): vertikale // Live-Schnitt: die aktive Ebene kommt jetzt aus `section3d` (App-State, an
// Schnittebene durch die Modellmitte, Blick +Y (Grundriss-Y = world Z, s. // eine Schnittlinie gebunden — `sectionPlaneFromLevel`) statt aus einem
// section.rs `looking_plus_y`: normal = [0,0,1]). Kein Store-/App-Prop — // lokalen Hardcode-Toggle. `null` = kein Schnitt.
// reine Viewport-Ansichts-Option analog zum Grid-Overlay.
const [sectionActive, setSectionActive] = useState(false);
// Orbit-Zustand in einem Ref (kein React-State): Mausbewegungen zeichnen // Orbit-Zustand in einem Ref (kein React-State): Mausbewegungen zeichnen
// direkt neu, ohne die Komponente je Frame zu re-rendern. Dient NUR der // direkt neu, ohne die Komponente je Frame zu re-rendern. Dient NUR der
// freien Navigation NACH einem Praeset-Sprung (s. Datei-Kommentar). // freien Navigation NACH einem Praeset-Sprung (s. Datei-Kommentar).
@@ -515,36 +529,33 @@ export function Wasm3DViewport({
}, [ready, renderMode, setRenderStyle]); }, [ready, renderMode, setRenderStyle]);
// Boden-Referenzraster: bei Bereitschaft oder Wechsel von Sichtbarkeit/ // Boden-Referenzraster: bei Bereitschaft oder Wechsel von Sichtbarkeit/
// Geschoss-Hoehe neu setzen + zeichnen. Fixe Ausdehnung/Abstand (40 m / // Geschoss-Hoehe neu setzen + zeichnen. Fixe Ausdehnung/Abstand (300 m /
// 1 m) — ausreichend fuer die ueblichen Grundriss-Groessen dieses Spikes. // 1 m) — bei den bisherigen 40 m lief man bei flachem Blickwinkel (Iso/
// Perspektive) schnell auf den harten Gitterrand (wirkte wie ein Clipping-
// Bug, war aber nur das Ende der Flaeche). 300 m ist bei gleicher
// Liniendichte immer noch sehr billig (nur ein LineList-Puffer).
useEffect(() => { useEffect(() => {
if (!ready) return; if (!ready) return;
setGroundGrid(gridVisible, groundElevation, 40, 1); setGroundGrid(gridVisible, groundElevation, 300, 1);
redrawRef.current(); redrawRef.current();
}, [ready, gridVisible, groundElevation, setGroundGrid]); }, [ready, gridVisible, groundElevation, setGroundGrid]);
// Live-Schnitt: bei Bereitschaft, Umschalten (`sectionActive`) oder // Live-Schnitt: die gebundene Schnittlinie liefert `section3d` = world
// Modelländerung neu setzen. MVP-Ebene = vertikal durch die Modellmitte // point+normal (vertikale Ebene entlang der Grundriss-Linie, Blickrichtung
// (`fitTargetDist`-Ziel), Blick nach +Y -> Normale [0,0,1] (Grundriss-Y = // aus `directionSign` — s. sectionPlaneFromLevel). Ohne Bindung (`null`) kein
// world Z, s. section.rs `looking_plus_y`). Bei aktivem Schnitt baut Rust die // Schnitt. Bei aktivem Schnitt baut Rust die Schnittflaechen-Schraffur (Caps)
// Schnittflaechen-Schraffur (Caps) aus den gecachten Waenden/Decken. // aus den gecachten Waenden/Decken. Neu setzen bei Bereitschaft, Wechsel der
// Ebene oder Modelländerung (Caps folgen dem aktuellen Modell).
useEffect(() => { useEffect(() => {
if (!ready) return; if (!ready) return;
// Bei inaktivem Schnitt braucht es kein Ziel (Ebene ist ohnehin unsichtbar) if (!section3d) {
// — `fitTargetDist` NICHT aufrufen, das würde sonst bei JEDER Projekt-
// Änderung (auch waehrend eines Griff-Drags) unnoetig die komplette
// Wand-/Oeffnungs-/Decken-Geometrie erneut flatten, nur um eine Bounding-
// Box zu ziehen, die `updateModel` fuer denselben `project`-Stand gerade
// erst berechnet hat.
if (!sectionActive) {
setSectionPlane(false, [0, 0, 0], [0, 0, 1]); setSectionPlane(false, [0, 0, 0], [0, 0, 1]);
redrawRef.current(); redrawRef.current();
return; return;
} }
const { target } = fitTargetDist(project); setSectionPlane(true, section3d.point, section3d.normal);
setSectionPlane(true, target, [0, 0, 1]);
redrawRef.current(); redrawRef.current();
}, [ready, sectionActive, project, setSectionPlane]); }, [ready, section3d, project, setSectionPlane]);
// Auswahl-Hervorhebung: bei Bereitschaft oder Änderung der Highlight-Linien // Auswahl-Hervorhebung: bei Bereitschaft oder Änderung der Highlight-Linien
// (Auswahl gewechselt/aufgehoben) neu setzen + zeichnen. Die Engine hält das // (Auswahl gewechselt/aufgehoben) neu setzen + zeichnen. Die Engine hält das
@@ -1030,15 +1041,16 @@ export function Wasm3DViewport({
}} }}
/> />
))} ))}
{/* Live-Schnitt-Umschalter (eigener Overlay-Button, MVP): vertikale {/* Live-Schnitt-Knopf: nur sichtbar, wenn eine Schnittlinie den 3D-Schnitt
Schnittebene durch die Modellmitte, Blick +Y. Sitzt ueber dem treibt (`section3d` gebunden, Bindung im Objekt-Info gesetzt). Klick
Grid-Button (falls vorhanden). Reine Ansichts-Option des Viewports. */} hebt die Bindung auf. Sitzt ueber dem Grid-Button (falls vorhanden). */}
{section3d && (
<button <button
type="button" type="button"
aria-label={t("viewport3d.section.label")} aria-label={t("viewport3d.section.label")}
onClick={() => setSectionActive((v) => !v)} onClick={() => onClearSection3d?.()}
title={t(sectionActive ? "viewport3d.section.hide" : "viewport3d.section.show")} title={t("viewport3d.section.bound")}
aria-pressed={sectionActive} aria-pressed
style={{ style={{
position: "absolute", position: "absolute",
right: 10, right: 10,
@@ -1051,8 +1063,8 @@ export function Wasm3DViewport({
padding: 0, padding: 0,
borderRadius: 7, borderRadius: 7,
border: "1px solid rgba(255,255,255,0.14)", border: "1px solid rgba(255,255,255,0.14)",
background: sectionActive ? "rgba(255,255,255,0.14)" : "rgba(20,20,24,0.65)", background: "rgba(255,255,255,0.14)",
color: sectionActive ? "#f2f2f2" : "#9a9a9a", color: "#f2f2f2",
cursor: "pointer", cursor: "pointer",
backdropFilter: "blur(4px)", backdropFilter: "blur(4px)",
}} }}
@@ -1061,6 +1073,7 @@ export function Wasm3DViewport({
content_cut content_cut
</span> </span>
</button> </button>
)}
{onToggleGrid && ( {onToggleGrid && (
// Dezenter Overlay-Button (eigener DOM-Knoten neben dem Canvas, `.viewport` // Dezenter Overlay-Button (eigener DOM-Knoten neben dem Canvas, `.viewport`
// ist `position: relative` — s. styles.css), stört die Maus-Navigation // ist `position: relative` — s. styles.css), stört die Maus-Navigation

Some files were not shown because too many files have changed in this diff Show More