Compare commits
8 Commits
0ab0361e3d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e73c684c3 | |||
| 6637533162 | |||
| 3b79e49cc6 | |||
| a6c2c04736 | |||
| 956a85d93f | |||
| f95674aa09 | |||
| 2ac5c27fa7 | |||
| 68a0459d0e |
@@ -11,3 +11,7 @@ scripts/*.png
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
*.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
|
||||
|
||||
@@ -1,452 +1,390 @@
|
||||
# Architektur — Standalone Browser-BIM (cad)
|
||||
# Architektur — Dossier (Desktop-CAAD)
|
||||
|
||||
> Stand: 2026-06-29
|
||||
> Vision, Phasen und Backlog: [ROADMAP.md](ROADMAP.md). Konventionen: [CONVENTIONS.md](CONVENTIONS.md).
|
||||
> Detail-Designs: [docs/design/elements.md](docs/design/elements.md) ·
|
||||
> [docs/design/plans-output.md](docs/design/plans-output.md) ·
|
||||
> [docs/design/resources-graphics.md](docs/design/resources-graphics.md).
|
||||
> Stand: 2026-07-21 (grundlegend überarbeitet — siehe [STATUS.md](STATUS.md) für
|
||||
> die volle Bestandsaufnahme inkl. Mist-Liste, die diese Überarbeitung begründet).
|
||||
> Vision/Phasen (historisch, Tag-1-Stand): [ROADMAP.md](ROADMAP.md). Konventionen:
|
||||
> [CONVENTIONS.md](CONVENTIONS.md). Detail-Designs (teils ebenfalls veraltet,
|
||||
> siehe Hinweis in [docs/README.md](docs/README.md)): [docs/design/](docs/design/).
|
||||
|
||||
Dieses Dokument beschreibt, **wie** die Standalone-Browser-App gebaut wird und
|
||||
wie sie die Konzepte des DOSSIER-Rhino-Plugins in Browser-Äquivalente übersetzt.
|
||||
DOSSIER ist ein Rhino-8-Plugin (Python + React-WebView); `cad` ist die *eigen
|
||||
ständige* Browser-Variante: kein Rhino-Document, kein `doc.Strings`, kein
|
||||
IronPython — stattdessen ein **eigenes typisiertes Datenmodell in TypeScript**,
|
||||
gerendert über **Three.js** (3D) und **SVG** (Plan), gespeichert als **JSON-Datei**.
|
||||
Dieses Dokument beschreibt, **wie** Dossier tatsächlich gebaut ist — nicht wie
|
||||
es am ersten Tag geplant war. Dossier ist die eigenständige Neuimplementierung
|
||||
des Rhino-Plugins [DOSSIER](https://git.openbureau.ch/karim/dossier): dieselbe
|
||||
Denkweise (Geschosse, Kategorie-Ebenen, mehrschichtige Bauteile,
|
||||
Prioritäts-Verschneidung), aber als **native Desktop-App** mit einem **eigenen
|
||||
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
|
||||
UI-Texte sind deutsch. Einheiten intern in **Metern**.
|
||||
**Desktop-Rahmen (plattformabhängig, wegen WebGPU):** auf **macOS Tauri**
|
||||
(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
|
||||
|
||||
Das semantische Gebäudemodell ist die **einzige Wahrheit**. Jede Sicht (3D,
|
||||
Grundriss, Schnitt, Ansicht) ist eine **reine Ableitung** (`derive`) daraus.
|
||||
Das semantische Gebäudemodell (`Project`) ist die **einzige Wahrheit**. Jede
|
||||
Sicht (3D, Grundriss, Schnitt, Ansicht) ist eine **reine Ableitung** daraus.
|
||||
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
|
||||
│ resources · types · designLevels · │
|
||||
│ layers · elements │
|
||||
│ resources · types · drawingLevels · │
|
||||
│ layers · walls/doors/openings/stairs/… │
|
||||
└──────────────┬───────────────────────────┘
|
||||
│ pure derive()
|
||||
┌───────────────────────┼────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
Scene3D (Three.js) PlanModel → SVG SectionModel → SVG
|
||||
buildScene() generatePlan() generateSection() (HLR)
|
||||
│ │ │
|
||||
└────────── beide lesen dieselben joins/components/styles ──────────┘
|
||||
┌───────────────────────┼────────────────────────────┬───────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
3D-Viewport 2D-Plan (generatePlan) 3D-Live-Schnitt Export
|
||||
Viewport3D (three.js) PlanView (SVG) · glPlan (GL2) render3d/section IFC/DXF/
|
||||
ODER Wasm3DViewport ODER render2d (Rust/WGSL) (Rust, analytisch)PDF/STL
|
||||
(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)
|
||||
|
||||
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).
|
||||
## 1. Repo-Struktur (IST-Zustand)
|
||||
|
||||
```
|
||||
src/
|
||||
model/
|
||||
types.ts // Project, DesignLevel, LayerCategory, Element-Union (existiert)
|
||||
geometry.ts // 2D/3D-Mathe ohne Kernel (existiert)
|
||||
joins.ts // Wand-Verschneidung (Gehrung; später Prio-T/X) (existiert)
|
||||
sampleProject.ts // Demo-Haus (existiert)
|
||||
project.ts // Factory, Defaults, Migrationen
|
||||
selectors.ts // abgeleitete Reads (visibleCodes, elementsOnLevel…)
|
||||
ids.ts // newId(prefix) — UUID-Erzeugung
|
||||
elements/ // pro Bauteil ein Modul (Daten + Generierung)
|
||||
wall.ts opening.ts slab.ts stair.ts roof.ts structure.ts space.ts
|
||||
resources/
|
||||
componentManager.ts hatchManager.ts lineManager.ts symbolLibrary.ts
|
||||
overrides.ts // regelbasierte Engine (Condition → Action)
|
||||
store/
|
||||
store.ts // Zustand-Store: { project, ui }, Aktionen, Undo/Redo
|
||||
persistence.ts // Datei save/load (JSON), Autosave (IndexedDB)
|
||||
history.ts // Undo/Redo-Ring
|
||||
plan/
|
||||
generatePlan.ts // Grundriss aus Footprint + Symbolik (existiert)
|
||||
generateSection.ts // Schnitt/Ansicht aus 3D-Projektion (HLR-Worker)
|
||||
PlanView.tsx // SVG-Renderer + Pan/Zoom/Grips (existiert)
|
||||
primitives.ts // Primitive-Union, SVG-Serializer, DXF/PDF-Export
|
||||
viewport/
|
||||
Viewport3D.tsx // Three.js-Szene aus dem Modell (existiert)
|
||||
scene.ts // buildScene(project) → THREE.Group (Layer-Spiegel)
|
||||
clip.ts // Plan-/Schnitt-Clipping über THREE.Plane
|
||||
camera.ts // Kamera-Presets, Norden-Rotation
|
||||
sheets/
|
||||
layout.ts // Sheet/Viewport-Datenmodell
|
||||
SheetEditor.tsx // Plansatz-Editor
|
||||
exportPdf.ts // Vektor-PDF (svg → pdf-lib / jsPDF)
|
||||
workers/
|
||||
geometry.worker.ts // Booleans (Öffnungen) + HLR via Comlink
|
||||
ui/
|
||||
App.tsx Navigator panels… // React-Oberfläche (heute in App.tsx, wird gesplittet)
|
||||
model/ Project-Schema (types.ts, ~2500 LOC), joins.ts (Wand-Gehrung/
|
||||
-Prio-Stösse), parametricWalls.ts, roomStamp.ts, terrain.ts,
|
||||
geoRebase.ts, sampleProject.ts
|
||||
geometry/ 2D-Kernel (kernel2d.ts: offset/trim/fillet/split — LIVE, siehe
|
||||
§3), ceiling/opening/roomArea/roomBoundary/stair/roof/column.ts,
|
||||
polygonHoles.ts
|
||||
commands/ Rhino-artiges Kommandosystem: types/registry/engine/parseInput.ts
|
||||
+ cmds/ (ein Modul je Kommando: wall, opening, stair, roof,
|
||||
column, room, line/rect/circle/arc, move/mirror/copy/offset/
|
||||
trim/join, extrude, import, terrain, measure, georef, …)
|
||||
tools/ Interaktive Zeichenwerkzeuge, snapping.ts, transform.ts (Grips)
|
||||
compute/ Compute-Boundary: leitet Ops (aktuell nur computeJoins) unter
|
||||
Tauri via `invoke` an Rust weiter, sonst TS-Fallback
|
||||
plan/ generatePlan.ts (2D-Ableitung), PlanView.tsx (SVG + Pan/Zoom/
|
||||
Grips), glPlan/ (eigener WebGL2-Renderer), toSection.ts/
|
||||
toElevation.ts (Schnitt/Ansicht-Ableitung), toWalls3d.ts,
|
||||
toRenderScene.ts (Szene für Rust-render2d), wallMeshCut.ts
|
||||
viewport/ Viewport3D.tsx (three.js, „Free") UND Wasm3DViewport.tsx
|
||||
(Rust/wgpu „Nordstern", Default/editierbar), raycast3d.ts
|
||||
section/ TOTER Code (OCCT-WASM-HLR-Spike, keine Aufrufer mehr) — Schnitt
|
||||
läuft über render3d/section.rs, siehe §4.3
|
||||
export/ exportIfc.ts (IFC4), exportDxf.ts/dxfWriter.ts, exportPdf.ts,
|
||||
layoutPdf.ts (Mehrseiten-PDF pro Ordner), exportMesh.ts (STL/OBJ),
|
||||
exportSchedule.ts (CSV-Bauteilliste), sceneToPrintSvg.ts
|
||||
materials/ ambientcg.ts (Live-Suche ambientCG-API), library.ts (13
|
||||
gebündelte Starter-Materialien), runtime.ts (PBR-Material aus
|
||||
ComponentMaterial via three.js TextureLoader)
|
||||
io/ DXF/DWG-Import, Swisstopo (swissBUILDINGS3D/-ALTI3D/SWISSIMAGE,
|
||||
LV95↔WGS84), OSM-Overpass, .lin/.pat-Parser, projectFile.ts
|
||||
(.obp Speichern/Laden, Tauri-Lock)
|
||||
overrides/ Regelbasierte Override-Engine (Bedingung → Aktion)
|
||||
state/ Eigener Store auf `useSyncExternalStore` (KEIN Zustand/Redux):
|
||||
store.ts + Slices (project/history/selection/view/layout/site/
|
||||
notify), appStore.ts komponiert sie
|
||||
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.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
|
||||
JSON-Bäume (`dossier_zeichnungsebenen`, `dossier_ebenen`). Wir übernehmen das
|
||||
1:1 in `types.ts` (existiert bereits als `drawingLevels` + `layers`):
|
||||
1. **Zeichnungsebenen** (`DrawingLevel`) — Geschoss (`kind:"floor"`,
|
||||
`floorHeight`/`cutHeight`/`baseElevation`), Schnitt/Ansicht
|
||||
(`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
|
||||
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 …`
|
||||
### 2.2 Elemente — typisierte Arrays statt `Element[]`-Union
|
||||
|
||||
Ein **Element** kennt sein **Geschoss** (`floorId`) *und* seine **Ebene**
|
||||
(`categoryCode`). Sichtbarkeit ergibt sich aus dem Schnitt (Geschoss `geschoss × code`
|
||||
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.
|
||||
Anders als ursprünglich geplant hält `Project` (`src/model/types.ts:2095`)
|
||||
**pro Bauteiltyp ein eigenes (meist optionales) Array**:
|
||||
|
||||
```ts
|
||||
interface Resources {
|
||||
lineStyles: LineStyle[]; // Line Manager: { id, name, weight(mm), color, dash:number[] }
|
||||
hatches: Hatch[]; // Hatch Manager: { id, name, pattern, scale, angle, lineStyleId }
|
||||
components: Component[]; // Component Manager (= DOSSIER-Material erweitert):
|
||||
// { id, name, hatchId, color3d, texture3d?, joinPriority }
|
||||
interface Project {
|
||||
walls: Wall[];
|
||||
ceilings?: Ceiling[];
|
||||
roofs?: Roof[];
|
||||
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
|
||||
**Daten** statt Hardcode — steuert die Prioritäts-T-Verschneidung (Risiko #1).
|
||||
Ein `Element`-Typalias existiert noch (`kind:"door"|"window"` etc.), wird aber
|
||||
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
|
||||
interface Resources { lineStyles: LineStyle[]; hatches: Hatch[]; components: Component[]; }
|
||||
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
|
||||
einmal pflegen). 3D und Plan lesen dieselben `layers[]`.
|
||||
`joinPriority` sitzt am `Component` (Daten statt Hardcode) und steuert die
|
||||
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
|
||||
`Window | Slab | Stair | Roof | Column | Beam | Space | Draw2d`. Jedes Element:
|
||||
`{ id, type, floorId, categoryCode, ... }`. Gehostete Elemente (Tür/Fenster)
|
||||
tragen `hostWallId` statt `floorId` (Geschoss ergibt sich aus der Wand). Volle
|
||||
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 };
|
||||
}
|
||||
```
|
||||
`ViewSnapshot` (Kamera/Massstab/Detailgrad/Sichtbarkeiten/Override-Preset) in
|
||||
`ViewSnapshotFolder`-Bäumen; `Layout` (Papierformat, mehrere
|
||||
`LayoutViewport`s, freie `LayoutAnnotation`s, optionale `MasterLayout`-Vererbung)
|
||||
in `LayoutFolder`-Bäumen. Beide gehören ins Dokument (nicht LocalStorage).
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
nicht für Werkzeuge + Undo. Migration auf **Zustand** (ROADMAP §4):
|
||||
Entgegen der ursprünglichen Empfehlung (Zustand-Library) wurde ein
|
||||
**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
|
||||
interface AppState {
|
||||
project: Project; // das Dokument
|
||||
ui: { // flüchtig, NICHT persistiert/undobar
|
||||
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;
|
||||
}
|
||||
```
|
||||
**Nicht umgesetzt** (geplant in `docs/design/state-architecture.md`): die
|
||||
Extraktion von View-Routing nach `src/views/` und Kontextmenü-Aufbau nach
|
||||
`src/menus/` — beide Ordner existieren nicht, diese Logik liegt weiterhin
|
||||
inline in `App.tsx` (7.130 Zeilen). Siehe STATUS.md §4.3.
|
||||
|
||||
**Trennung Modell ↔ UI** ist hart: nur `project` ist undobar und wird gespeichert.
|
||||
`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
|
||||
|
||||
### 3.2 Persistenz — JSON-Datei statt `doc.Strings`
|
||||
|
||||
DOSSIER speichert *alles* in `doc.Strings` (Key-Value am Rhino-Document) +
|
||||
globale Presets als JSON-Dateien im User-Home. Browser-Äquivalent:
|
||||
|
||||
| DOSSIER | Browser (cad) |
|
||||
|---|---|
|
||||
| `doc.Strings.SetString(key, json)` | Feld im `Project`-Objekt (ein JSON-Baum) |
|
||||
| `.3dm`-Datei mit eingebetteten Strings | **`.cad.json`-Datei** via File System Access API |
|
||||
| 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.
|
||||
- **Datei:** eigenes `.obp`-Format (`src/io/projectFile.ts`), unter Tauri über
|
||||
native Speichern/Öffnen-Dialoge (`plugin-fs`/`plugin-dialog`), im Browser
|
||||
Blob-Fallback. Ältere `.json`-Projekte bleiben ladbar.
|
||||
OS-Level-Exklusiv-Lock (`fs4`-Crate, `src-tauri/src/lock.rs`) verhindert
|
||||
Doppelöffnen desselben Projekts.
|
||||
- **Compute-Boundary:** `src/compute/index.ts` leitet einzelne Operationen
|
||||
(aktuell nur `computeJoins`) unter Tauri via `invoke` an eine native Rust-
|
||||
Implementierung weiter; im Browser bzw. für nicht migrierte Ops (Room-
|
||||
Detection, DWG-Parsing) läuft die TS-Implementierung.
|
||||
|
||||
### 3.3 Undo/Redo
|
||||
|
||||
DOSSIER überlässt Undo Rhino (und hat dadurch Cache-Stale-Bugs, Schwachstelle
|
||||
#4.3). Wir besitzen das Dokument selbst → **eigener History-Ring**:
|
||||
|
||||
```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.
|
||||
Eigener History-Ring im `projectSlice` (kein DOSSIER-Sticky-Bus, kein
|
||||
Cache-Stale-Problem, da alle Sichten pure Ableitungen sind).
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
(`layer_builder.build_layers`) und hängt jedes Objekt an den passenden Sublayer.
|
||||
Browser-Spiegel: ein **`THREE.Group`-Baum** mit identischer Struktur, gebaut aus
|
||||
dem Modell (nicht persistiert — reine Ableitung):
|
||||
- **`Viewport3D.tsx`** — three.js, die „Free"-Stufe.
|
||||
- **`Wasm3DViewport.tsx`** — Rust/wgpu, „Nordstern", editierbar, **Default**.
|
||||
Ein Settings-Schalter wählt die Engine; WebGL2/three.js nur noch als
|
||||
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
|
||||
└─ levelGroup[floorId] (y-Offset = baseElevation) ← Geschoss
|
||||
└─ categoryGroup[categoryCode] (userData.code) ← Ebene
|
||||
└─ element meshes (userData.elementId)
|
||||
App.tsx (section3dCutId/section3dPlane)
|
||||
→ Wasm3DViewport.tsx (section3d-Prop → setSectionPlane)
|
||||
→ src-tauri/render3d/src/{section.rs, section_boolean.rs, section_fill.rs}
|
||||
```
|
||||
|
||||
```ts
|
||||
// scene.ts
|
||||
function buildScene(project: Project, vis: VisibilityState): THREE.Group
|
||||
function syncScene(root: THREE.Group, project, vis) // diff statt full rebuild (Perf)
|
||||
```
|
||||
`section_boolean.rs` ist ein 1:1-Port von `toSection.ts::subtractDominantBands`
|
||||
— 2D-Plan-Schnitt und 3D-Live-Schnitt nutzen dieselbe Prioritäts-Logik und
|
||||
stimmen dadurch exakt überein. Kein Worker, kein Comlink (beides war geplant,
|
||||
existiert nirgends im Projekt) — läuft synchron GPU-seitig.
|
||||
|
||||
- **Sichtbarkeit:** `categoryGroup.visible = vis.codes.has(code)` und
|
||||
`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.4 Öffnungen als Löcher (kein Mesh-Boolean)
|
||||
|
||||
### 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
|
||||
`okff + schnitthöhe` legt, Normale −Z (`layer_builder.update_clipping_plane`).
|
||||
Zwei Browser-Pfade — **beide nötig** (ROADMAP §3):
|
||||
### 4.5 Materialien
|
||||
|
||||
1. **3D-Viewport im Plan-Modus:** `THREE.Plane(normal=(0,0,-1), constant=cutZ)` über
|
||||
`renderer.clippingPlanes` + `material.clippingPlanes`. `clip.ts` setzt die Ebene
|
||||
pro aktivem Geschoss (`cutZ = baseElevation + cutHeight`), Kamera orthografisch
|
||||
von oben. So sieht man den **echten geschnittenen Volumenkörper**.
|
||||
2. **Symbolischer SVG-Grundriss** (der Hauptweg, schnell + sauber): `generatePlan.ts`
|
||||
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 1–2 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.
|
||||
`materials/library.ts` (13 gebündelte PBR-Starter, ambientCG CC0) +
|
||||
`materials/ambientcg.ts` (Live-Suche der kompletten ambientCG-Bibliothek,
|
||||
1K/2K/4K-Auflösungswahl, On-Demand-Download via `jszip`, Proxy wegen CORS) →
|
||||
`materials/runtime.ts` baut daraus gecachte `THREE.MeshStandardMaterial`s mit
|
||||
physisch korrekter Kachelgrösse (UV in Weltmetern).
|
||||
|
||||
---
|
||||
|
||||
## 5. React-Panel-Struktur
|
||||
|
||||
DOSSIER ist eine Sammlung **getrennter WebView-Panels** (EBENEN, ELEMENTE,
|
||||
GESTALTUNG, OBERLEISTE, MASSSTAB, AUSSCHNITTE, DIMENSIONEN, LAYOUTS, OVERRIDES),
|
||||
die über die Python-Bridge + `sc.sticky` kommunizieren. Im Browser ist alles
|
||||
**eine SPA** mit einem geteilten Store — die Panels werden zu Docking-Bereichen.
|
||||
Dock-/Floating-Panel-System (`src/panels/`: Dock, FloatingPanel, TabStrip,
|
||||
Registry) mit eingebauten Panels: Tools, Attributes, ObjectInfo,
|
||||
DrawingLevels, Layers, Site (Kontext/Terrain-Import), RoomBalance
|
||||
(SIA-416-CSV), Elements (Bauteilbaum), ViewSnapshots, Layouts.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ TopBar Werkzeuge · Ansichtstyp · Massstab · Snaps · Save/Load │ ≙ OBERLEISTE+MASSSTAB
|
||||
├──────────────┬──────────────────────────────────────┬───────────────┤
|
||||
│ Navigator │ Viewport (Three.js oder SVG-Plan) │ Inspector │
|
||||
│ ┌──────────┐ │ ┌────────────────────────────────┐ │ ┌───────────┐ │
|
||||
│ │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
|
||||
```
|
||||
Der **Resource Manager läuft bewusst NICHT als Dock-Panel**, sondern als
|
||||
eigenständiges (unter Tauri natives) Fenster — ebenso Settings,
|
||||
DrawingLevels-Detaileditor, LayerSettings und ContextImport
|
||||
(`src/native/*Window.ts` + `*WindowApp.tsx`), jeweils mit
|
||||
`isTauriRuntime()`-Gate und ohne separate Browser-Variante (im Browser bleibt
|
||||
die entsprechende In-App-Overlay-Variante aktiv, wo vorhanden).
|
||||
|
||||
**Komponenten-Map** (heute alles in `App.tsx`; wird gesplittet):
|
||||
|
||||
| Bereich | DOSSIER-Panel | cad-Komponente |
|
||||
|---|---|---|
|
||||
| Zeichnungsebenen-Liste | EBENEN (oben) | `Navigator/DrawingLevels.tsx` |
|
||||
| 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.
|
||||
**Werkzeug-System:** `Tool`-Interface (`src/tools/types.ts`) für Zeichenwerkzeuge
|
||||
mit Snap-Engine; daneben das umfangreichere **Kommandosystem** (`src/commands/`)
|
||||
für Rhino-artige getippte Eingabe (`5,3`/`r5,3`/`5<45`, Tab-Feld-Zyklus in
|
||||
`CommandLine.tsx`) — beide koexistieren, decken unterschiedliche
|
||||
Interaktionsstile ab.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| **`doc.Strings[key]=json`** | Feld im `Project`-JSON | persistiert in `.cad.json` |
|
||||
| **`.3dm`-Datei** | **`.cad.json`** via File System Access API | + IndexedDB-Autosave |
|
||||
| **Globale Presets (~/Library/*.json)** | **LocalStorage** + Export/Import | cross-Projekt |
|
||||
| **`sc.sticky` (cross-modul Bus)** | **Zustand-Store** (reaktiv) | kein Polling, kein None-Bug |
|
||||
| **`panel_base.BaseBridge` / WebView-IPC** | direkte React-Props/Store | keine `document.title`-Hacks |
|
||||
| **`document.title="RHINOMSG::"` Polling** | entfällt | SPA, kein WebView |
|
||||
| **Eto.Forms Satelliten-Fenster** | React-Modal/Drawer | z.B. Ausschnitt-Settings |
|
||||
| **Rhino Layer-Tabelle (Sublayer-Baum)** | `LayerCategory[]`-Baum + `THREE.Group`-Spiegel | `scene.ts` |
|
||||
| **`layer_builder.build_layers`** | `buildScene` (Ableitung) | Gruppen statt Layer |
|
||||
| **`layer.PlotWeight`** | `LineStyle.weight` (mm) → SVG `stroke-width` | massstabsabh. (§plans) |
|
||||
| **`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) | — |
|
||||
| Rhino `RhinoDoc` | `Project` (TS-Objekt im eigenen Store) | einzige Wahrheit |
|
||||
| `doc.Strings[key]=json` | Feld im `Project`-JSON | persistiert in `.obp` |
|
||||
| `.3dm`-Datei | **`.obp`**-Datei (Tauri `plugin-fs`) | + Blob-Fallback im Browser |
|
||||
| `sc.sticky` (cross-modul Bus) | eigener `useSyncExternalStore`-Store | kein Polling |
|
||||
| Rhino Layer-Tabelle | `LayerCategory[]`-Baum, Sichtbarkeit als `.visible`-Flag | pro Renderer umgesetzt |
|
||||
| Clipping-Plane (`AddClippingPlane`) | `render3d::section.rs` (analytisch, Rechteck-Subtraktion) | KEIN HLR |
|
||||
| `HLRBRep` | — (ungenutzt; OCCT-Spike ist toter Code) | ersetzt durch obiges |
|
||||
| `Rhino.Geometry.Brep`-Booleans | `trucksolid::boolean_mesh` (csgrs) | existiert, nicht an Wände angeschlossen |
|
||||
| Rhino-Grips + DisplayConduit | Pointer-Events + Grip-Overlay (2D vollständig, 3D nur Basis, kein Snap) | siehe STATUS.md §4.7 |
|
||||
| Swisstopo via .NET HttpClient | `fetch()` (CORS-offen) | radiusgenau zugeschnitten (nicht ganze STAC-Kachel) |
|
||||
| IronPython-Laufzeit-Risiken | entfällt | TS/Rust |
|
||||
|
||||
---
|
||||
|
||||
## 7. Was wir von DOSSIER übernehmen — und was nicht
|
||||
## 7. Was übernommen wurde — und was bewusst anders lief
|
||||
|
||||
**Übernehmen (bewährte Konzepte):**
|
||||
- Zwei-Achsen-Dokumentmodell (Zeichnungsebenen × Ebenen).
|
||||
- Layer-Codes/Farben/lw 1:1 (`DEFAULT_LAYER_SCHEMA`).
|
||||
- Component/Hatch/Line-Manager mit id-Verweisen + `joinPriority`.
|
||||
- Ansichtstypen = Kamera + optionaler Schnitt (vereinheitlicht).
|
||||
- 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.
|
||||
**Übernommen (Prinzipien, bewährt):**
|
||||
- Zwei-Achsen-Dokumentmodell, Layer-Codes 1:1, Component/Hatch/Line-Manager
|
||||
mit `joinPriority`, LoD (grob/mittel/fein), regelbasierte Overrides,
|
||||
Ausschnitte, SIA-416-Räume, Norden-Rotation.
|
||||
- Pure-Ableitungs-Architektur (kein Cache-Stale, kein Sticky-Bus).
|
||||
|
||||
**Bewusst anders (Browser-nativ, Schwachstellen vermeiden):**
|
||||
- **Kein Sticky-Bus** → ein reaktiver Store (vermeidet DOSSIER #4.4/#4.5/#4.6).
|
||||
- **Kein Monolith** → Bauteil-Module statt 7244-LOC-`elemente.py` (#4.1).
|
||||
- **Pure Ableitungen** statt mutierter Doc-Objekte → kein Cache-Stale (#4.3),
|
||||
kein Undo/Redo-Loch.
|
||||
- **Versioniertes Datei-Schema** statt UserString-Sticky-Migration.
|
||||
- **Persistenz im Project-JSON** statt verteilt über `doc.Strings`.
|
||||
**Anders gelaufen als geplant (siehe STATUS.md §5 für die volle Tabelle):**
|
||||
- Eigene Rust/WASM-Rendering-Engines statt Three.js/OpenCascade.js/web-ifc.
|
||||
- Typisierte Arrays pro Bauteiltyp statt `Element[]`-Union.
|
||||
- Eigener Store statt Zustand-Library.
|
||||
- Analytische Rust-Schnitt-Pipeline statt HLR/Worker/Comlink.
|
||||
- App.tsx wurde **nicht** wie geplant auf eine dünne Shell reduziert
|
||||
(`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
|
||||
ohne konkretes Problem; kein `try/catch: {}` als Bug-Versteck; erst grep/lesen,
|
||||
dann editieren; Wand-Geometrie nie „nebenbei" refactoren.
|
||||
**Anti-Over-Engineering (weiterhin gültig):** keine Abstraktion ohne konkretes
|
||||
Problem; erst lesen, dann editieren; Geometrie nie „nebenbei" refactoren.
|
||||
|
||||
---
|
||||
|
||||
## 8. Reihenfolge bei Code-Arbeit
|
||||
|
||||
1. **Dieses Dokument + das relevante Detail-Design** (elements/plans-output/
|
||||
resources-graphics) lesen.
|
||||
2. **Dann das betroffene Modul** lesen (nicht raten).
|
||||
3. **Erst danach editieren**; `Project` immer immutabel via `store.apply()` ändern.
|
||||
4. **Verifizieren:** `npx tsc -b`, `npm run build`, Screenshot via
|
||||
`node scripts/probe.mjs` — Geometrie visuell prüfen.
|
||||
5. **Dieses Dokument aktuell halten**, wenn sich Patterns/Mapping ändern.
|
||||
1. **STATUS.md** (aktueller Ist-Zustand) + dieses Dokument lesen.
|
||||
2. **Das betroffene Modul** lesen (nicht raten) — bei Zweifel: welcher der
|
||||
mehreren Renderer/Pfade ist gerade aktiv (§4.1/§4.2)?
|
||||
3. **Erst danach editieren**; `Project` immer immutabel via den Store ändern.
|
||||
4. **Verifizieren:** `npx tsc -b`, `npm test` (Vitest), `cargo test` in den
|
||||
betroffenen Crates, bei Rust-Änderungen `npm run build:engine{,3d}` (WASM
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
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,
|
||||
Statusleiste, Floating-Panels, Ressourcen-Overlay) — keine Geschäftslogik darin.
|
||||
- **Globaler Zustand in einem Store** (`src/state/`, Slices: project/selection/view/layout).
|
||||
Komponenten lesen Zustand über Store-Hooks statt Prop-Drilling.
|
||||
- **Features als eigene Module:** `src/views/` (View-Router-Teile), `src/editors/`
|
||||
(Inline-Editoren), `src/menus/` (Kontextmenü-Builder), `src/panels/`, `src/ui/`.
|
||||
- **Ziel:** `App.tsx` bleibt ein dünner Shell (Store-Provider, Oberleiste,
|
||||
Docks+View-Router, Statusleiste, Floating-Panels, Ressourcen-Overlay) — keine
|
||||
Geschäftslogik darin. **Realität (Stand 2026-07-21):** `App.tsx` ist mit
|
||||
~7.100 Zeilen die grösste Datei des Projekts und enthält weiterhin
|
||||
View-Umschaltung und Kontextmenü-Aufbau inline — die geplante Auslagerung
|
||||
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).
|
||||
Siehe `docs/design/state-architecture.md`.
|
||||
|
||||
## UI-Konventionen
|
||||
|
||||
@@ -61,4 +73,6 @@ Rendern angewandt, nie in die Geometrie eingebacken.
|
||||
- Ä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
|
||||
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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
Lange autonome Session (Nutzer-Auftrag: „arbeite die Pendenzen/Funktionen durch,
|
||||
|
||||
@@ -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] ~~**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 1–6) 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 1–6) 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 R13–R2018-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.**
|
||||
|
||||
@@ -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.
|
||||
- **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):
|
||||
- ✅ **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).
|
||||
- **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.
|
||||
@@ -209,6 +214,7 @@
|
||||
|
||||
_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 **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.
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
# DOSSIER Standalone
|
||||
# Dossier
|
||||
|
||||
Open-Source-CAAD (Computer Aided Architecture Design). Modelliert ein Gebäude
|
||||
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
|
||||
vollständige Fassung.
|
||||
ohne Revit. **Desktop-App** — auf macOS über **Tauri**, auf Linux über eine
|
||||
**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
|
||||
[DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse,
|
||||
Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als
|
||||
React-App mit eigener Rendering-Engine (Rust/WASM/WebGPU) statt als Rhino-Aufsatz.
|
||||
Als Desktop-App (Tauri) läuft sie im eigenen Fenster mit voller Engine-Leistung;
|
||||
browserseitig ist derselbe Kern zugänglich, die App ist die vollständige Fassung.
|
||||
Das ist die eigenständige Neuimplementierung des Rhino-Plugins
|
||||
[DOSSIER](https://git.openbureau.ch/karim/dossier): dieselbe Denkweise
|
||||
(Geschosse, Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-
|
||||
Verschneidung), aber mit eigenem Datenmodell + eigener Rendering-Engine
|
||||
(Rust/WASM/WebGPU, intern „Nordstern" genannt) statt Rhino-Aufsatz.
|
||||
|
||||
## 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
|
||||
3D-Mesh, sondern **analytisch aus den Bauteil-Parametern** (Wandachsen + Dicken →
|
||||
Linien, Öffnungen → Lücken + Symbol). PDF-Export ist derselbe Plan, nur mit
|
||||
echten mm-Stiftstärken statt Bildschirm-Hairlines — kein zweites Rendering.
|
||||
Schnitte und Ansichten brauchen den zweiten Weg — echte 3D-Projektion mit
|
||||
verdeckten Kanten (HLR) —, dessen Machbarkeit per Spike bewiesen, aber noch
|
||||
nicht ans UI angebunden ist.
|
||||
echten mm-Stiftstärken statt Bildschirm-Hairlines. Schnitte und Ansichten laufen
|
||||
über eine eigene, analytische Rust-Pipeline (kein Hidden-Line-Removal nötig,
|
||||
siehe [ARCHITECTURE.md](ARCHITECTURE.md) §4.3) und sind seit Juli live im
|
||||
3D-Viewport verdrahtet, nicht nur ein Spike.
|
||||
|
||||
## Stand heute
|
||||
|
||||
Ehrlich eingeordnet: aus dem Risiko-Spike ist ein **benutzbares 2D-CAD mit
|
||||
abgeleiteter 3D-Sicht, Vektor-PDF/DXF-Export und einer parametrischen
|
||||
Wand-Engine** geworden. Der einfachere Teil steht und ist per Screenshot/Probe
|
||||
verifiziert; die schwierigen BIM-Kernrisiken sind bewusst noch offen.
|
||||
Aus dem ursprünglichen Risiko-Spike ist binnen weniger Wochen ein
|
||||
**funktionsreiches Desktop-BIM-Tool** geworden: eigenes semantisches
|
||||
Gebäudemodell, zwei eigene Rust/WASM-Rendering-Engines, IFC/DXF/PDF/STL/OBJ-
|
||||
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:**
|
||||
- Semantisches Modell mit **mehrschichtigen Wänden** und automatischer
|
||||
**L-Ecken-Gehrung** (`computeJoins`); dieselbe Logik speist Plan *und* 3D.
|
||||
- **Parametrische Wände** (`ParametricWall`-Regelwerke: Grid/Modul/Sequenz/
|
||||
Referenzlinie/bedingte Dicke) lösen sich zu konkreten `Wall`-Objekten auf,
|
||||
statt jede Wand einzeln von Hand zu ziehen.
|
||||
- **Dokumentmodell wie in DOSSIER:** Zeichnungsebenen (Geschosse + Schnitte/
|
||||
Ansichten/Zeichnungen) × Kategorie-Ebenen (Code-Baum 1:1, `20 Wände`, `30 Decken` …).
|
||||
- **Zeichnen & Editieren** im Grundriss: Wand, Linie, Polylinie, Rechteck, Kreis,
|
||||
Öffnungen, Treppen, Decken, Raumstempel; Snapping (Endpunkt/Mittelpunkt/
|
||||
Schnittpunkt/Lot/Raster/Ortho), Grips, Move/Copy/Offset, Spiegeln/Drehen/Array,
|
||||
Trim/Split/Join.
|
||||
- **Rhino-artiges Befehlssystem** mit getippten Koordinaten (`5,3` · `r5,3` ·
|
||||
`5<45`) und Tab-Feld-Zyklus (Länge → Winkel …).
|
||||
- **Vektor-Export:** PDF (A4/A3, Titelblock, echte mm-Stiftstärken nach ISO-Pen-
|
||||
Steps, keine Rasterbilder) und DXF — beide aus derselben `Plan`-Struktur wie
|
||||
der Bildschirm.
|
||||
- **PBR-Material-Bibliothek** (ambientCG-Import, `manifest.json`) für die
|
||||
3D-Ansicht.
|
||||
- **Resource Manager** (Component / Hatch / Line) — alles per id referenziert,
|
||||
zentral änderbar.
|
||||
- **Panel-System** (dockbar, stapelbar, floatend), Top-Bar + Statusleiste
|
||||
(echter Maßstab 1:N, Cursor X/Y), eigenes Kontextmenü, **i18n** (de/en).
|
||||
- **Import:** DXF/DWG (Konturen/Mesh) → Terrain-TIN, Swisstopo/LV95-Geokontext,
|
||||
OSM-Kontextimport; `.lin`/`.pat` für Linien/Schraffuren.
|
||||
**Funktioniert (Auszug — volle Liste in STATUS.md §3):**
|
||||
- Semantisches Modell mit **mehrschichtigen Wänden**, L-Eck-Gehrung **und**
|
||||
Prioritäts-T-/X-Stössen (`joinPriority` am Component) — konsistent in
|
||||
Grundriss, 3D-Viewport und 3D-Live-Schnitt.
|
||||
- **Parametrische Wände**, Decken, Treppen (gerade/L/Wendel), Dächer
|
||||
(Flach/Pult/Sattel/Walm/Mansarde/Zelt), Stützen, SIA-416-Räume mit
|
||||
automatischer Bilanz + CSV-Export.
|
||||
- **Türen & Fenster** gehostet in Wänden, mit Rahmen/Zarge/Kämpfer/Oberlicht,
|
||||
Detailgrad grob/mittel/fein, echte Rechteck-Löcher im 3D-Wandkörper.
|
||||
- Rhino-artiges **Kommandosystem** mit getippten Koordinaten (`5,3` · `r5,3` ·
|
||||
`5<45`) und Tab-Feld-Zyklus; Snapping, Grips, Trim/Split/Join, 2D-Booleans.
|
||||
- **Live 2D↔3D-Schnitt**: eine Schnittebene im 3D-Viewport folgt derselben
|
||||
Prioritäts-Logik wie der 2D-Plan-Schnitt (Rust-Port, keine Diskrepanz).
|
||||
- **Vektor-Export:** PDF (Einzel- **und Mehrseiten-Layouts**), DXF, **IFC4**
|
||||
(mit echten Fenster-/Tür-Löchern), STL/OBJ, CSV-Bauteilliste.
|
||||
- **Materialbibliothek**: 13 gebündelte PBR-Starter **plus** Live-Suche der
|
||||
kompletten ambientCG-Bibliothek (1K/2K/4K, On-Demand-Download).
|
||||
- **Layouts/Plansätze** mit mehreren Viewports pro Blatt, Ausschnitte
|
||||
(View-Snapshots), Kamera-Presets, Norden-Rotation.
|
||||
- **Import:** DXF/DWG, Swisstopo (swissBUILDINGS3D/-ALTI3D/SWISSIMAGE,
|
||||
radiusgenau zugeschnitten), OSM-Kontextimport, `.lin`/`.pat`.
|
||||
- **Native Desktop-Integration** (Tauri): eigene randlose Fenster, native
|
||||
Speichern/Öffnen-Dialoge, eigenes `.obp`-Dateiformat mit OS-Level-Lock gegen
|
||||
Doppelöffnen, mehrere native Zusatzfenster (Resources, Settings, …).
|
||||
- **Resource Manager** (Component/Hatch/Line/Typ-Editoren), regelbasierte
|
||||
Overrides, Panel-System (dockbar/floatend), i18n (de/en).
|
||||
|
||||
**Bewusst noch offen** (die eigentlich harten Teile):
|
||||
- Echte **3D-Booleans** für Öffnungen — Türen/Fenster sind im Plan eine Lücke,
|
||||
noch kein geschnittenes Volumen.
|
||||
- **HLR** (verdeckte Kanten) für Schnitte und Ansichten: Machbarkeit per
|
||||
OCCT-WASM-Spike bewiesen (`docs/welle-c-hlr-spike/`, `src/section/hlr.ts`),
|
||||
aber noch nicht ans UI/Dokumentmodell verdrahtet — Views sind noch Stubs.
|
||||
- **Prioritäts-T-/X-Stöße** mehrschichtiger Wände (Beton läuft durch, Putz
|
||||
verbindet seitlich) — das berüchtigte Risiko #1.
|
||||
- **Multi-Page-Layouts/Ausschnitte** (mehrere Viewports pro Blatt) — PDF-Export
|
||||
ist noch single-sheet.
|
||||
|
||||
Details und die Begründungen stehen im
|
||||
[HANDOVER](HANDOVER.md) (laufendes Arbeitsprotokoll) und in der
|
||||
[ROADMAP](ROADMAP.md) (Vision, Phasen, Backlog).
|
||||
**Bewusst noch offen** (Details + volle Liste: STATUS.md §4.7):
|
||||
- **Echtes Mesh-Boolean für Öffnungen** — funktioniert heute über achsparallele
|
||||
Rechteck-Löcher; ein generisches CSG-Boolean existiert bereits
|
||||
(`trucksolid`/`csgrs`), ist aber nicht an die Wand-Pipeline angeschlossen.
|
||||
- **3D-Griffsystem**: Feld-Controller (Tab-Zyklus) und Snapping fehlen für
|
||||
3D-Grip-Drag (im 2D vollständig vorhanden).
|
||||
- **DWG/DXF-Domänenimport** (Entitäten → echte Wände/Öffnungen) — Lesen
|
||||
funktioniert, das Mapping auf das Modell ist unbegonnen; DWG-Schreiben fehlt.
|
||||
- **`make2D`**-Kommando (3D-Ansicht → flacher 2D-Plan mit Füllungen).
|
||||
- Bekannte, noch nicht bereinigte Doppelspur `Door[]`/`Opening[]` im Modell.
|
||||
|
||||
## 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 |
|
||||
| 3D | Three.js, plus eigener nativer Rust/WASM/WebGPU-Renderer (`render3d`) |
|
||||
| 2D-Plan | eigener SVG-Renderer, plus eigener nativer Rust/WASM/WebGPU-Renderer (`render2d`) |
|
||||
| Geometrie | eigener 2D-Kernel (`src/geometry/kernel2d.ts`), `delaunator` fürs Terrain |
|
||||
| Export | `jspdf`/`svg2pdf.js` (Vektor-PDF), eigener DXF-Writer |
|
||||
| 3D | eigener Rust/WASM/WebGPU-Renderer „Nordstern" (`render3d`, Default), Three.js als leichtgewichtiger Zweitpfad |
|
||||
| 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`); ein Rust-Port (`src-tauri/kernel2d`) existiert nur als Paritätstest, läuft nicht produktiv |
|
||||
| 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 |
|
||||
| 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),
|
||||
Manifold (exakte 3D-Booleans). OCCT-WASM ist für HLR bereits als Spike da (s.o.),
|
||||
aber noch nicht an Dokumentmodell/UI angebunden. Siehe ROADMAP §4.
|
||||
`opencascade.js` steht noch als Dependency in `package.json`, wird aber nur
|
||||
noch von totem Code (`src/section/hlr.ts`, superseded durch die Rust-
|
||||
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
|
||||
|
||||
```bash
|
||||
npm install
|
||||
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
|
||||
npm run build # tsc -b && vite build
|
||||
npm test # vitest run
|
||||
```
|
||||
|
||||
Verifiziert wird visuell: `node scripts/probe.mjs` rendert die App headless und
|
||||
schreibt `scripts/probe.png` — Screenshot ansehen, Geometrie prüfen. Weitere
|
||||
`scripts/probe-*.mjs` decken einzelne Features ab (PDF, Trim, Materialien,
|
||||
Theme, Boolean-Ops …); für Firefox-Fälle gibt es `scripts/probe-ff*.mjs`
|
||||
(Playwright).
|
||||
WASM-Engines nach Rust-Änderungen neu bauen (nur `.rs` committen,
|
||||
`src/engine/pkg*/` ist gitignored):
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```
|
||||
src/
|
||||
model/ semantisches Modell + Ableitungen (types, parametricWalls, roomStamp, joins, terrain)
|
||||
geometry/ 2D-Kernel (offset/trim/fillet/intersect, ceiling, opening, roomArea/-Boundary, stair)
|
||||
model/ semantisches Modell (types, joins, parametricWalls, roomStamp, terrain)
|
||||
geometry/ 2D-Kernel (offset/trim/fillet/intersect, ceiling/opening/roomArea/stair/roof/column)
|
||||
commands/ Rhino-artiges Befehlssystem (engine, parser, registry, cmds/)
|
||||
tools/ interaktive Zeichenwerkzeuge + Snapping + Transformationen
|
||||
plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG) + glPlan (WebGL2-Renderer)
|
||||
viewport/ Viewport3D (Three.js)
|
||||
section/ HLR-Spike (OCCT-WASM) — Schnitte/Ansichten, noch nicht verdrahtet
|
||||
export/ PDF- und DXF-Export aus derselben Plan-Struktur wie der Bildschirm
|
||||
materials/ PBR-Material-Bibliothek (ambientCG-Import, Runtime)
|
||||
text/ Rich-Text (Beschriftungen, Textobjekte)
|
||||
plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG) + glPlan (WebGL2) + Rust-Bindings
|
||||
viewport/ Viewport3D (Three.js) + Wasm3DViewport (Rust/wgpu „Nordstern", Default)
|
||||
section/ TOTER Code (OCCT-HLR-Spike) — Schnitt läuft über render3d, siehe ARCHITECTURE.md §4.3
|
||||
export/ IFC4-, PDF-, DXF-, STL/OBJ-, CSV-Export aus derselben Plan-/Modell-Struktur
|
||||
materials/ ambientCG-Live-Suche + gebündelte Starter-Bibliothek + PBR-Runtime
|
||||
panels/ dockbares Panel-System + die einzelnen Paletten
|
||||
state/ Store + Slices (project/selection/view/layout)
|
||||
ui/ Top-Bar, Statusleiste, Kontextmenü, Resource Manager, Command-Line
|
||||
state/ eigener Store (useSyncExternalStore) + Slices (project/selection/view/layout/…)
|
||||
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
|
||||
i18n/ Wörterbücher de/en
|
||||
src-tauri/ Rust-Crates (render2d/render3d) — headless per wasm-pack zu WASM
|
||||
gebaut (npm run build:engine), Tauri-Host selbst ist ausrangiert
|
||||
src-tauri/ 6 Rust-Crates (render2d/render3d/geometry/kernel2d/trucksolid/dwgimport,
|
||||
je headless UND per wasm-pack baubar) + der Tauri-Host selbst
|
||||
```
|
||||
|
||||
## 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
|
||||
über `t('key')` — keine hartcodierten Strings im JSX.
|
||||
- Intern alles in **Metern**; Anzeige via `formatM`.
|
||||
- `App.tsx` bleibt dünner Shell, Zustand lebt im Store. Verbindliches in
|
||||
[CONVENTIONS.md](CONVENTIONS.md).
|
||||
- Verbindliches in [CONVENTIONS.md](CONVENTIONS.md).
|
||||
|
||||
## Weiterlesen
|
||||
|
||||
- [ROADMAP.md](ROADMAP.md) — Produktvision, Architektur-Entscheidungen, Phasen 0–7, DOSSIER-Backlog
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — Technische Architektur im Detail
|
||||
- [HANDOVER.md](HANDOVER.md) — aktueller Arbeitsstand, Befunde, nächste Schritte
|
||||
- [docs/](docs/) — Design-Specs (Befehlssystem, Zeichenwerkzeuge, Wand-Joins, Backend …)
|
||||
- [STATUS.md](STATUS.md) — **vollständige, ehrliche Bestandsaufnahme**: Zahlen,
|
||||
Feature-Inventar, Mist-Liste, Doku-Widersprüche, Tag-1-Vision vs. heute
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — technische Architektur im Detail (Ist-Zustand)
|
||||
- [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
|
||||
|
||||
Copyright © 2026 Karim Gabriele Varano. Veröffentlicht unter der
|
||||
**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.
|
||||
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)
|
||||
sind als Differenzierer eingeplant.
|
||||
sind ein bewusster Differenzierer.
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# 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 2–5"/„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**
|
||||
> Stand: 2026-06-28
|
||||
|
||||
|
||||
@@ -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 2–5 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.
|
||||
@@ -1,6 +1,13 @@
|
||||
# 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).
|
||||
|
||||
Dieses Verzeichnis bündelt die Recherche- und Design-Dokumente für `cad`, die
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
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"
|
||||
/>
|
||||
<title>cad — Phase 0 Spike</title>
|
||||
<title>Dossier</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "cad",
|
||||
"name": "dossier",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
|
||||
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 702 KiB |
|
After Width: | Height: | Size: 551 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"source": "ambientCG (ambientcg.com)",
|
||||
"license": "CC0 1.0 Universal (Public Domain)",
|
||||
"resolution": "1K",
|
||||
"resolution": "2K",
|
||||
"materials": [
|
||||
{
|
||||
"id": "Concrete048",
|
||||
@@ -61,6 +61,18 @@
|
||||
"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",
|
||||
"name": "Bodenfliesen",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region).",
|
||||
"windows": ["main"],
|
||||
"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", "resources", "settings", "drawing-levels", "layer-settings", "context-import"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-minimize",
|
||||
@@ -12,6 +12,10 @@
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:menu:default",
|
||||
"core:event:default",
|
||||
"dialog:allow-save",
|
||||
"fs:allow-write-text-file"
|
||||
]
|
||||
|
||||
|
Before Width: | Height: | Size: 103 B After Width: | Height: | Size: 12 KiB |
@@ -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]
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// Eine Kante samt ihrer deduplizierten Repraesentanten-Normalen (Rueckseiten
|
||||
/// bereits gepaart, s. `representatives`). Grundlage BEIDER Kanten-Stile: die
|
||||
/// statischen Feature-Edges (`build_mesh_edges`) UND die blickabhaengigen
|
||||
/// Silhouetten-Konturen (`build_silhouette_edges`).
|
||||
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 {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -137,20 +147,83 @@ pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut verts: Vec<f32> = Vec::new();
|
||||
let mut push = |p: [f32; 3]| {
|
||||
verts.push(p[0]);
|
||||
verts.push(p[1]);
|
||||
verts.push(p[2]);
|
||||
verts.push(EDGE_COLOR[0]);
|
||||
verts.push(EDGE_COLOR[1]);
|
||||
verts.push(EDGE_COLOR[2]);
|
||||
};
|
||||
edges
|
||||
.into_values()
|
||||
.map(|rec| EdgeAdj {
|
||||
p0: rec.p0,
|
||||
p1: rec.p1,
|
||||
reps: representatives(&rec.normals),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
for rec in edges.values() {
|
||||
if should_draw(&rec.normals) {
|
||||
push(rec.p0);
|
||||
push(rec.p1);
|
||||
fn push_edge(verts: &mut Vec<f32>, p0: [f32; 3], p1: [f32; 3]) {
|
||||
for p in [p0, p1] {
|
||||
verts.extend_from_slice(&[p[0], p[1], p[2], EDGE_COLOR[0], EDGE_COLOR[1], EDGE_COLOR[2]]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -171,15 +244,18 @@ pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
|
||||
/// nur EINEN Vertreter je Original-Dreieck behalten — danach greift dieselbe
|
||||
/// Rand-/Knick-Logik wie bei einseitigen Meshes (Waende), unabhaengig davon,
|
||||
/// ob doppelseitig gerendert wurde oder nicht.
|
||||
fn should_draw(normals: &[[f32; 3]]) -> bool {
|
||||
let mut representatives: Vec<[f32; 3]> = Vec::new();
|
||||
/// Dedupliziert die angrenzenden Normalen zu Repraesentanten: paart die von der
|
||||
/// 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()];
|
||||
for i in 0..normals.len() {
|
||||
if used[i] {
|
||||
continue;
|
||||
}
|
||||
used[i] = true;
|
||||
representatives.push(normals[i]);
|
||||
reps.push(normals[i]);
|
||||
for j in (i + 1)..normals.len() {
|
||||
if used[j] {
|
||||
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,
|
||||
1 => true, // Silhouette/Rand: gehoert nur einem Original-Dreieck
|
||||
_ => {
|
||||
// Zeichnen, sobald irgendein Normalen-Paar deutlich abknickt.
|
||||
for i in 0..representatives.len() {
|
||||
for j in (i + 1)..representatives.len() {
|
||||
if dot(representatives[i], representatives[j]) < CREASE_COS {
|
||||
for i in 0..reps.len() {
|
||||
for j in (i + 1)..reps.len() {
|
||||
if dot(reps[i], reps[j]) < CREASE_COS {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -277,6 +359,57 @@ mod tests {
|
||||
let m = Mesh::default();
|
||||
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)]
|
||||
|
||||
@@ -12,17 +12,20 @@
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
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::math::{view_projection, Mat4};
|
||||
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_fill::{
|
||||
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::{
|
||||
Camera, Mesh, MeshInput, SlabInput, TexturedMesh, WallInput, FLOATS_PER_VERTEX,
|
||||
TEXTURED_FLOATS_PER_VERTEX,
|
||||
@@ -51,6 +54,11 @@ pub enum RenderStyle {
|
||||
/// Modell-Kanten obenauf (wie bei `Hidden`, aber mit Material- statt
|
||||
/// Weiss-Flaechen).
|
||||
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 {
|
||||
@@ -62,6 +70,7 @@ impl RenderStyle {
|
||||
"wireframe" => RenderStyle::Wireframe,
|
||||
"hidden" => RenderStyle::Hidden,
|
||||
"shaded-edges" => RenderStyle::ShadedEdges,
|
||||
"contour" => RenderStyle::Contour,
|
||||
_ => RenderStyle::Shaded,
|
||||
}
|
||||
}
|
||||
@@ -72,7 +81,8 @@ impl RenderStyle {
|
||||
fn mesh_mode(self) -> f32 {
|
||||
match self {
|
||||
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
|
||||
// gezeichnet), ShadedEdges -> Material.
|
||||
_ => 0.0,
|
||||
@@ -98,7 +108,10 @@ impl RenderStyle {
|
||||
/// zeichnen (Flaechen + Kanten) — bislang nur `Hidden`, jetzt auch
|
||||
/// `ShadedEdges`.
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
const TEXTURE_SIZE: u32 = 256;
|
||||
|
||||
@@ -268,6 +294,30 @@ pub struct Renderer {
|
||||
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
|
||||
/// Shader/Layout unterscheiden sich.
|
||||
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.
|
||||
/// Wird bei `set_material_textures` neu gebaut (mehr Ebenen); bis dahin haelt
|
||||
/// 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,
|
||||
/// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight.
|
||||
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
|
||||
/// (`[pos, color]`). None = kein Schnitt aktiv / keine Kanten. Ueber
|
||||
/// `set_cut_lines` gesetzt/geloescht, gezeichnet NACH den Caps.
|
||||
@@ -733,6 +789,115 @@ impl Renderer {
|
||||
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,
|
||||
// damit die im Shader gesampelten Werte im linearen Raum landen (die
|
||||
// Beleuchtung rechnet linear) — analog zum srgb-Surface-Format. Das
|
||||
@@ -809,6 +974,14 @@ impl Renderer {
|
||||
cap_pipeline,
|
||||
cut_line_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,
|
||||
texture_bind_group_layout,
|
||||
@@ -823,6 +996,7 @@ impl Renderer {
|
||||
grid: None,
|
||||
highlight: None,
|
||||
caps: None,
|
||||
wall_end_caps: None,
|
||||
cut_lines: None,
|
||||
grid_visible: false,
|
||||
style: RenderStyle::Shaded,
|
||||
@@ -859,19 +1033,63 @@ impl Renderer {
|
||||
build_scene_mesh(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
|
||||
/// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer
|
||||
/// die Stile wireframe/hidden — einmalig hier, nicht je Frame.
|
||||
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() {
|
||||
self.mesh = None;
|
||||
self.textured_mesh = None;
|
||||
self.edges = None;
|
||||
self.edge_adj = Vec::new();
|
||||
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.
|
||||
let edge_verts = build_mesh_edges(&mesh);
|
||||
self.edges = if edge_verts.is_empty() {
|
||||
@@ -1111,6 +1329,111 @@ impl Renderer {
|
||||
/// `WallInput::material_index` (1-basiert) auf seine Ebene; Baender ohne
|
||||
/// Material (`-1`) bleiben beim Schachbrett (Ebene 0). `layer_count == 0` (oder
|
||||
/// 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(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -1307,6 +1630,51 @@ impl Renderer {
|
||||
|
||||
self.ensure_depth(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 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
|
||||
// Kanten: sie fuellen die an der Schnittebene weggeschnittene Wunde
|
||||
// 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 ->
|
||||
// das Modell verdeckt das Gitter korrekt). Unabhaengig vom Stil, nur
|
||||
// wenn sichtbar + vorhanden.
|
||||
|
||||
@@ -55,10 +55,13 @@ pub use types::{
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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::{
|
||||
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.
|
||||
@@ -161,6 +164,65 @@ mod tests {
|
||||
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]
|
||||
fn mehrere_waende_addieren_sich() {
|
||||
let mesh = build_walls_mesh(&[
|
||||
@@ -840,6 +902,8 @@ mod tests {
|
||||
indices: vec![0, 1, 2],
|
||||
kind,
|
||||
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]);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn context_mesh_ueberspringt_kaputte_indizes_und_entartung() {
|
||||
let mut mesh = Mesh::default();
|
||||
@@ -892,6 +1023,8 @@ mod tests {
|
||||
indices: vec![0, 1, 9],
|
||||
kind: MeshKind::Imported,
|
||||
color: None,
|
||||
material_index: None,
|
||||
vertex_colors: Vec::new(),
|
||||
},
|
||||
);
|
||||
assert_eq!(mesh.vertex_count(), 0);
|
||||
@@ -903,6 +1036,8 @@ mod tests {
|
||||
indices: vec![0, 1, 2],
|
||||
kind: MeshKind::Imported,
|
||||
color: None,
|
||||
material_index: None,
|
||||
vertex_colors: Vec::new(),
|
||||
},
|
||||
);
|
||||
assert_eq!(mesh.vertex_count(), 0);
|
||||
@@ -1078,5 +1213,14 @@ mod tests {
|
||||
Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||
.validate(&tex_module)
|
||||
.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:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,6 +835,160 @@ fn compute_wall_miters(walls: &[WallInput]) -> Vec<WallMiters> {
|
||||
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),
|
||||
/// an ihren echten Achsenenden ggf. gehrt — bitgenau dieselbe Ecken-Konstruktion
|
||||
/// wie `extrude_layer_segment` fuer das Vollstueck (Einzel-Layer, keine
|
||||
@@ -1026,7 +1180,10 @@ pub fn build_scene_mesh_textured(
|
||||
for m in meshes {
|
||||
let before = mesh.vertex_count();
|
||||
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);
|
||||
@@ -1063,6 +1220,20 @@ pub fn build_scene_mesh(walls: &[WallInput], slabs: &[SlabInput], meshes: &[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) ──────────────────────
|
||||
|
||||
/// 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 vcount = pos.len() / 3;
|
||||
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).
|
||||
let vworld = |i: usize| -> [f32; 3] {
|
||||
let b = i * 3;
|
||||
@@ -1099,6 +1285,7 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
|
||||
let a = vworld(ia);
|
||||
let b = vworld(ib);
|
||||
let c = vworld(ic);
|
||||
let (ca, cb, cc) = (vcol(ia), vcol(ib), vcol(ic));
|
||||
// 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 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];
|
||||
// Vorderseite + Rueckseite (siehe Moduldoc: doppelseitig).
|
||||
push_ctx_tri(mesh, a, b, c, n, color);
|
||||
push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], 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]], [ca, cc, cb]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1127,10 +1314,10 @@ fn push_ctx_tri(
|
||||
b: [f32; 3],
|
||||
c: [f32; 3],
|
||||
normal: [f32; 3],
|
||||
color: Rgb,
|
||||
colors: [Rgb; 3],
|
||||
) {
|
||||
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(&[
|
||||
p[0], p[1], p[2], normal[0], normal[1], normal[2], color[0], color[1], color[2],
|
||||
]);
|
||||
|
||||
@@ -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:
|
||||
/// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion
|
||||
/// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline.
|
||||
|
||||
@@ -336,6 +336,20 @@ pub struct MeshInput {
|
||||
/// Default-Farbe je `kind` (siehe `effective_color`).
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -398,8 +412,15 @@ fn default_ortho_half_height() -> f32 {
|
||||
fn default_near() -> f32 {
|
||||
0.05
|
||||
}
|
||||
fn default_far() -> f32 {
|
||||
1000.0
|
||||
// `pub(crate)`, damit `web::set_camera` denselben Basiswert als Untergrenze fuer
|
||||
// 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 {
|
||||
|
||||
@@ -114,7 +114,11 @@ pub struct WebModelRenderer {
|
||||
/// Decken. Mirror der TS-`fitTargetDist`-Grow-Schleife (Wasm3DViewport.tsx):
|
||||
/// Wand-Achsenpunkte auf Basis-/Firsthoehe, Decken-Umriss auf Unter-/Oberkante.
|
||||
/// `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 max = [f32::NEG_INFINITY; 3];
|
||||
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]);
|
||||
}
|
||||
}
|
||||
// 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() {
|
||||
return None;
|
||||
}
|
||||
@@ -247,7 +262,7 @@ impl WebModelRenderer {
|
||||
pub fn set_model(&mut self, json: &str) -> Result<(), JsValue> {
|
||||
let model: ModelInput = serde_json::from_str(json)
|
||||
.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
|
||||
.upload_model(&self.device, &model.walls, &model.slabs, &model.meshes);
|
||||
// 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
|
||||
/// `eye` aus Orbit-Winkeln/Distanz oder einem Praeset (front/top/side/iso/persp)
|
||||
/// — 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)]
|
||||
pub fn set_camera(
|
||||
&mut self,
|
||||
@@ -316,6 +340,32 @@ impl WebModelRenderer {
|
||||
fov_y: 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 {
|
||||
eye: [eye_x, eye_y, eye_z],
|
||||
target: [target_x, target_y, target_z],
|
||||
@@ -327,6 +377,7 @@ impl WebModelRenderer {
|
||||
},
|
||||
fov_y,
|
||||
ortho_half_height,
|
||||
far,
|
||||
..Camera::default()
|
||||
};
|
||||
}
|
||||
@@ -455,6 +506,39 @@ impl WebModelRenderer {
|
||||
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).
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
let (w, h) = (width.max(1), height.max(1));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "cad",
|
||||
"productName": "Dossier",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ch.dossier.cad",
|
||||
"build": {
|
||||
@@ -11,12 +11,13 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "cad",
|
||||
"title": "Dossier",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"dragDropEnabled": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true
|
||||
"hiddenTitle": true,
|
||||
"trafficLightPosition": { "x": 20, "y": 22 }
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -308,6 +308,49 @@ describe("exportIfcSpf — IFC4-Export", () => {
|
||||
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)", () => {
|
||||
const proj = fixtureProject();
|
||||
proj.walls = [];
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
openingLabel,
|
||||
wallTypeThickness,
|
||||
} from "../model/types";
|
||||
import { projectToWorldCoords } from "../model/geoRebase";
|
||||
import {
|
||||
ceilingVerticalExtent,
|
||||
stairVerticalExtent,
|
||||
@@ -283,7 +284,13 @@ interface Structure {
|
||||
* File). Reiner Rechenkern — kein Datei-IO. Leeres Projekt ⇒ valider Minimal-
|
||||
* 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();
|
||||
|
||||
// Geteilte Grundgeometrie: Ursprung, Z-Extrusionsrichtung, Identitäts-
|
||||
@@ -341,9 +348,14 @@ export function exportIfcSpf(project: Project): string {
|
||||
"IFCPROJECT",
|
||||
`${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(
|
||||
"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(
|
||||
"IFCBUILDING",
|
||||
|
||||
@@ -199,3 +199,108 @@ describe("exportScheduleCsv — Bauteilliste", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
Opening,
|
||||
Stair,
|
||||
ExtrudedSolid,
|
||||
Roof,
|
||||
Column,
|
||||
} from "../model/types";
|
||||
import {
|
||||
getWallType,
|
||||
@@ -63,7 +65,9 @@ export type ScheduleKind =
|
||||
| "Fenster"
|
||||
| "Öffnung"
|
||||
| "Treppe"
|
||||
| "Extrusion";
|
||||
| "Extrusion"
|
||||
| "Dach"
|
||||
| "Stütze";
|
||||
|
||||
/** Eine ausgewertete Zeile der Bauteilliste (je Bauteil-Vorkommen ein Datensatz). */
|
||||
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,
|
||||
* Fenster/Öffnungen, Treppen, Extrusionen — Räume bewusst ausgenommen, siehe
|
||||
* Dateikopf). Reine Ableitung aus dem Modell — nichts wird gespeichert.
|
||||
* Reihenfolge: Wände, Decken, Türen, Öffnungen, Treppen, Extrusionen (je in
|
||||
* Dokumentreihenfolge).
|
||||
* Fenster/Öffnungen, Treppen, Extrusionen, Dächer, Stützen — Räume bewusst
|
||||
* ausgenommen, siehe Dateikopf). Reine Ableitung aus dem Modell — nichts wird
|
||||
* gespeichert. Reihenfolge: Wände, Decken, Türen, Öffnungen, Treppen,
|
||||
* Extrusionen, Dächer, Stützen (je in Dokumentreihenfolge).
|
||||
*/
|
||||
export function scheduleRows(project: Project): 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 s of project.stairs ?? []) rows.push(evalStair(project, s));
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,16 +17,25 @@ export const de = {
|
||||
"view3d.group": "Ansicht 3D",
|
||||
"view3d.front": "Vorne",
|
||||
"view3d.front.hint": "Frontalansicht von vorne",
|
||||
"view3d.front.abbr": "V",
|
||||
"view3d.back": "Hinten",
|
||||
"view3d.back.hint": "Rückansicht von hinten",
|
||||
"view3d.back.abbr": "H",
|
||||
"view3d.top": "Oben",
|
||||
"view3d.top.hint": "Draufsicht (senkrecht von oben)",
|
||||
"view3d.top.abbr": "O",
|
||||
"view3d.side": "Rechts",
|
||||
"view3d.side.hint": "Rechte Seitenansicht (90° zur Front)",
|
||||
"view3d.side.abbr": "R",
|
||||
"view3d.left": "Links",
|
||||
"view3d.left.hint": "Linke Seitenansicht (90° zur Front, gegenüber Rechts)",
|
||||
"view3d.left.abbr": "L",
|
||||
"view3d.perspective": "Perspektive",
|
||||
"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.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)",
|
||||
"view3d.isoFrontLeft": "Vorne-Links",
|
||||
@@ -86,6 +95,7 @@ export const de = {
|
||||
"display.style.shaded": "Schattiert",
|
||||
"display.style.shadedEdges": "Schattiert mit Kanten",
|
||||
"display.style.white": "Weiss",
|
||||
"display.style.contour": "Nur Konturen",
|
||||
"display.style.textured": "Texturiert",
|
||||
"display.style.wireframe": "Drahtgitter",
|
||||
"display.style.hidden": "Kanten",
|
||||
@@ -98,6 +108,41 @@ export const de = {
|
||||
"topbar.resources": "Ressourcen",
|
||||
"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.title": "Arbeitsumgebung",
|
||||
"layout.placeholder": "Arbeitsumgebung …",
|
||||
@@ -148,6 +193,12 @@ export const de = {
|
||||
"ribbon.group.components": "Bauteile",
|
||||
"ribbon.group.sections": "Schnitte",
|
||||
"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.empty": "Elemente über + Hinzufügen wählen",
|
||||
"ribbon.empty": "Noch keine Werkzeuge in diesem Tab",
|
||||
@@ -292,6 +343,16 @@ export const de = {
|
||||
"objinfo.kind.extrudedSolid": "Extrusion",
|
||||
"objinfo.kind.column": "Stütze",
|
||||
"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) ──────────────────────────────────
|
||||
"objinfo.roof.section": "Dach",
|
||||
"objinfo.roof.shape": "Dachform",
|
||||
@@ -501,6 +562,9 @@ export const de = {
|
||||
"objinfo.stair.direction": "Laufrichtung",
|
||||
"objinfo.stair.direction.up": "Aufwä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.bottom": "Unterkante (UK)",
|
||||
"objinfo.stair.top": "Oberkante (OK)",
|
||||
@@ -523,6 +587,7 @@ export const de = {
|
||||
"objinfo.sectionLine.kind.elevation": "Ansicht",
|
||||
"objinfo.sectionLine.name": "Name",
|
||||
"objinfo.sectionLine.flip": "Blickrichtung umkehren",
|
||||
"objinfo.sectionLine.cut3d": "Im 3D schneiden",
|
||||
"objinfo.sectionLine.startX": "Start X",
|
||||
"objinfo.sectionLine.startY": "Start Y",
|
||||
"objinfo.sectionLine.endX": "Ende X",
|
||||
@@ -557,6 +622,8 @@ export const de = {
|
||||
"nav.show": "Einblenden",
|
||||
"nav.categoryVisible": "{name} sichtbar",
|
||||
"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) ───────────────────────────────────
|
||||
"badge.floor": "Geschoss",
|
||||
@@ -571,6 +638,7 @@ export const de = {
|
||||
"display.active": "Nur aktive",
|
||||
"display.grey": "Andere grau",
|
||||
"display.grey_locked": "Andere grau & gesperrt",
|
||||
"display.locked": "Andere gesperrt",
|
||||
|
||||
// ── Dock ─────────────────────────────────────────────────────────────────
|
||||
"dock.resize": "Breite anpassen",
|
||||
@@ -692,6 +760,7 @@ export const de = {
|
||||
"resources.title": "Ressourcen",
|
||||
"resources.label": "Ressourcen-Manager",
|
||||
"resources.close": "Schließen",
|
||||
"resources.loading": "Verbinde mit dem Hauptfenster …",
|
||||
"resources.tab.components": "Bauteile",
|
||||
"resources.tab.hatches": "Schraffuren",
|
||||
"resources.tab.lines": "Linien",
|
||||
@@ -774,6 +843,8 @@ export const de = {
|
||||
"resources.field.frameWidth": "Rahmenbreite (m)",
|
||||
"resources.field.insetFromFace": "Schichteinzug (m)",
|
||||
"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.mullionRows": "Kämpfer-Zeilen",
|
||||
"resources.frameKind.zarge": "Zarge",
|
||||
@@ -1145,9 +1216,15 @@ export const de = {
|
||||
"site.contourSet": "Kontur-Satz",
|
||||
"site.empty": "Kein Kontext geladen. Importiere eine DXF-Datei (Höhenlinien oder Mesh).",
|
||||
"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.contours": "Konturen",
|
||||
"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.terrainFailed": "Kein Gelände erzeugbar (zu wenige/kollineare Konturpunkte).",
|
||||
|
||||
@@ -1254,6 +1331,9 @@ export const de = {
|
||||
"settings.title": "Einstellungen",
|
||||
"settings.section.workspace": "Arbeitsumgebung",
|
||||
"settings.section.display": "Darstellung",
|
||||
"settings.theme": "Erscheinungsbild",
|
||||
"settings.theme.light": "Hell",
|
||||
"settings.theme.dark": "Dunkel",
|
||||
"settings.marqueeColor": "Auswahlrahmen-Farbe",
|
||||
"settings.marqueeColor.hint":
|
||||
"Farbe des Auswahlrahmens beim Aufziehen einer Auswahl (Marquee) im Grundriss.",
|
||||
@@ -1281,8 +1361,10 @@ export const de = {
|
||||
"ctxImport.title": "Standort importieren",
|
||||
"ctxImport.close": "Schließen",
|
||||
"ctxImport.geoAnchor": "Georeferenziert ab: {label}",
|
||||
"ctxImport.geoAnchor.coords": "E {e} / N {n} / H {h}",
|
||||
"ctxImport.geoAnchor.reset": "Zurücksetzen",
|
||||
"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.searchPlaceholder": "z. B. Bundesplatz Bern",
|
||||
"ctxImport.search": "Suchen",
|
||||
@@ -1290,6 +1372,12 @@ export const de = {
|
||||
"ctxImport.noResults": "Keine Treffer.",
|
||||
"ctxImport.searchError": "Suche fehlgeschlagen.",
|
||||
"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.src.swissBuildings": "Swisstopo-Gebäude",
|
||||
"ctxImport.buildings.off": "Aus",
|
||||
@@ -1297,6 +1385,8 @@ export const de = {
|
||||
"ctxImport.buildings.v2": "Echt 2.0 (stabil)",
|
||||
"ctxImport.buildings.v3": "Echt 3.0 (Beta)",
|
||||
"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.res2": "2 m (schnell)",
|
||||
"ctxImport.terrain.res05": "0.5 m (fein, grössere Kacheln)",
|
||||
@@ -1379,6 +1469,7 @@ export const de = {
|
||||
"viewport3d.section.label": "Schnittebene",
|
||||
"viewport3d.section.show": "Schnittebene ein",
|
||||
"viewport3d.section.hide": "Schnittebene aus",
|
||||
"viewport3d.section.bound": "3D-Schnitt aufheben (an Schnittlinie gebunden)",
|
||||
"viewport3d.grip.vertex": "Wandende ziehen",
|
||||
"viewport3d.grip.height": "Höhe ziehen",
|
||||
"viewport3d.grip.move": "Verschieben",
|
||||
@@ -1396,6 +1487,8 @@ export const de = {
|
||||
"elements.kind.opening": "Öffnung",
|
||||
"elements.kind.stair": "Treppe",
|
||||
"elements.kind.extrusion": "Extrusion",
|
||||
"elements.kind.roof": "Dach",
|
||||
"elements.kind.column": "Stütze",
|
||||
|
||||
// Ausschnitte / View-Snapshots (DOSSIER A2).
|
||||
"viewsnap.title": "Ausschnitte",
|
||||
|
||||
@@ -16,16 +16,25 @@ export const en: Record<TranslationKey, string> = {
|
||||
"view3d.group": "3D view",
|
||||
"view3d.front": "Front",
|
||||
"view3d.front.hint": "Front view (frontal)",
|
||||
"view3d.front.abbr": "F",
|
||||
"view3d.back": "Back",
|
||||
"view3d.back.hint": "Back view (rear)",
|
||||
"view3d.back.abbr": "B",
|
||||
"view3d.top": "Top",
|
||||
"view3d.top.hint": "Top view (straight down)",
|
||||
"view3d.top.abbr": "T",
|
||||
"view3d.side": "Right",
|
||||
"view3d.side.hint": "Right side view (90° to the front)",
|
||||
"view3d.side.abbr": "R",
|
||||
"view3d.left": "Left",
|
||||
"view3d.left.hint": "Left side view (90° to the front, opposite Right)",
|
||||
"view3d.left.abbr": "L",
|
||||
"view3d.perspective": "Perspective",
|
||||
"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.hint": "Isometric view (equiangular from front-top-right)",
|
||||
"view3d.isoFrontLeft": "Front Left",
|
||||
@@ -85,6 +94,7 @@ export const en: Record<TranslationKey, string> = {
|
||||
"display.style.shaded": "Shaded",
|
||||
"display.style.shadedEdges": "Shaded with edges",
|
||||
"display.style.white": "White",
|
||||
"display.style.contour": "Contours only",
|
||||
"display.style.textured": "Textured",
|
||||
"display.style.wireframe": "Wireframe",
|
||||
"display.style.hidden": "Hidden line",
|
||||
@@ -97,6 +107,40 @@ export const en: Record<TranslationKey, string> = {
|
||||
"topbar.resources": "Resources",
|
||||
"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.title": "Workspace",
|
||||
"layout.placeholder": "Workspace …",
|
||||
@@ -147,6 +191,12 @@ export const en: Record<TranslationKey, string> = {
|
||||
"ribbon.group.components": "Components",
|
||||
"ribbon.group.sections": "Sections",
|
||||
"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.empty": "Pick elements via “+ Add”",
|
||||
"ribbon.empty": "No tools in this tab yet",
|
||||
@@ -291,6 +341,16 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.kind.extrudedSolid": "Extrusion",
|
||||
"objinfo.kind.column": "Column",
|
||||
"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) ─────────────────────────────────
|
||||
"objinfo.roof.section": "Roof",
|
||||
"objinfo.roof.shape": "Roof shape",
|
||||
@@ -499,6 +559,9 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.stair.direction": "Run direction",
|
||||
"objinfo.stair.direction.up": "Upwards",
|
||||
"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.bottom": "Bottom (UK)",
|
||||
"objinfo.stair.top": "Top (OK)",
|
||||
@@ -520,6 +583,7 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.sectionLine.kind.elevation": "Elevation",
|
||||
"objinfo.sectionLine.name": "Name",
|
||||
"objinfo.sectionLine.flip": "Flip view direction",
|
||||
"objinfo.sectionLine.cut3d": "Cut in 3D",
|
||||
"objinfo.sectionLine.startX": "Start X",
|
||||
"objinfo.sectionLine.startY": "Start Y",
|
||||
"objinfo.sectionLine.endX": "End X",
|
||||
@@ -553,6 +617,8 @@ export const en: Record<TranslationKey, string> = {
|
||||
"nav.show": "Show",
|
||||
"nav.categoryVisible": "{name} visible",
|
||||
"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.
|
||||
"badge.floor": "Floor",
|
||||
@@ -567,6 +633,7 @@ export const en: Record<TranslationKey, string> = {
|
||||
"display.active": "Active only",
|
||||
"display.grey": "Others grey",
|
||||
"display.grey_locked": "Others grey & locked",
|
||||
"display.locked": "Others locked",
|
||||
|
||||
// Dock.
|
||||
"dock.resize": "Adjust width",
|
||||
@@ -687,6 +754,7 @@ export const en: Record<TranslationKey, string> = {
|
||||
"resources.title": "Resources",
|
||||
"resources.label": "Resource manager",
|
||||
"resources.close": "Close",
|
||||
"resources.loading": "Connecting to the main window…",
|
||||
"resources.tab.components": "Components",
|
||||
"resources.tab.hatches": "Hatches",
|
||||
"resources.tab.lines": "Lines",
|
||||
@@ -769,6 +837,8 @@ export const en: Record<TranslationKey, string> = {
|
||||
"resources.field.frameWidth": "Frame width (m)",
|
||||
"resources.field.insetFromFace": "Layer inset (m)",
|
||||
"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.mullionRows": "Transom rows",
|
||||
"resources.frameKind.zarge": "Lining",
|
||||
@@ -1134,9 +1204,15 @@ export const en: Record<TranslationKey, string> = {
|
||||
"site.contourSet": "Contour set",
|
||||
"site.empty": "No context loaded. Import a DXF file (contour lines or mesh).",
|
||||
"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.contours": "Contours",
|
||||
"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.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.section.workspace": "Workspace",
|
||||
"settings.section.display": "Display",
|
||||
"settings.theme": "Appearance",
|
||||
"settings.theme.light": "Light",
|
||||
"settings.theme.dark": "Dark",
|
||||
"settings.marqueeColor": "Selection box color",
|
||||
"settings.marqueeColor.hint":
|
||||
"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.close": "Close",
|
||||
"ctxImport.geoAnchor": "Georeferenced from: {label}",
|
||||
"ctxImport.geoAnchor.coords": "E {e} / N {n} / H {h}",
|
||||
"ctxImport.geoAnchor.reset": "Reset",
|
||||
"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.searchPlaceholder": "e.g. Bundesplatz Bern",
|
||||
"ctxImport.search": "Search",
|
||||
@@ -1278,6 +1359,12 @@ export const en: Record<TranslationKey, string> = {
|
||||
"ctxImport.noResults": "No matches.",
|
||||
"ctxImport.searchError": "Search failed.",
|
||||
"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.src.swissBuildings": "Swisstopo buildings",
|
||||
"ctxImport.buildings.off": "Off",
|
||||
@@ -1285,6 +1372,8 @@ export const en: Record<TranslationKey, string> = {
|
||||
"ctxImport.buildings.v2": "Real 2.0 (stable)",
|
||||
"ctxImport.buildings.v3": "Real 3.0 (Beta)",
|
||||
"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.res2": "2 m (fast)",
|
||||
"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.show": "Section plane on",
|
||||
"viewport3d.section.hide": "Section plane off",
|
||||
"viewport3d.section.bound": "Clear 3D section (bound to section line)",
|
||||
"viewport3d.grip.vertex": "Drag wall endpoint",
|
||||
"viewport3d.grip.edge": "Drag edge",
|
||||
"viewport3d.grip.height": "Drag height",
|
||||
@@ -1384,6 +1474,8 @@ export const en: Record<TranslationKey, string> = {
|
||||
"elements.kind.opening": "Opening",
|
||||
"elements.kind.stair": "Stair",
|
||||
"elements.kind.extrusion": "Extrusion",
|
||||
"elements.kind.roof": "Roof",
|
||||
"elements.kind.column": "Column",
|
||||
|
||||
// Views / view snapshots (DOSSIER A2).
|
||||
"viewsnap.title": "Views",
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
// `swissTopo.ts::fetchBuildings`/`geocode` (kein Fetch-Mocking im Repo).
|
||||
|
||||
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";
|
||||
|
||||
describe("shiftMeshToOrigin", () => {
|
||||
@@ -79,3 +85,137 @@ describe("clipMeshToBbox", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +105,189 @@ export function clipMeshToBbox(
|
||||
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 {
|
||||
origin: GeoOrigin;
|
||||
meshes: ImportedMesh[];
|
||||
@@ -169,7 +352,14 @@ export async function fetchBuildings3d(
|
||||
const parsed = parseDxf(text);
|
||||
for (const m of parsed.meshes) {
|
||||
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 };
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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.1–0.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 };
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,30 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
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 { getLanguage, setLanguage } from "./i18n";
|
||||
import type { Language } from "./i18n";
|
||||
import { applyThemeMode, getStoredThemeMode } from "./theme/themeMode";
|
||||
import "./styles.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-
|
||||
// Steuerung am window verfügbar machen. Identifier englisch; Werte übersetzbar.
|
||||
declare global {
|
||||
@@ -52,10 +70,19 @@ document.addEventListener("dragstart", (e) => {
|
||||
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(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
<RootByWindowKind />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Auto-generated built-in material library (ambientCG, CC0 1.0).
|
||||
// Static asset paths under /public; loadable directly via three.js TextureLoader.
|
||||
// Eingebaute Material-Bibliothek (ambientCG, CC0 1.0) — 13 fest gebündelte
|
||||
// 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.
|
||||
|
||||
export type MaterialMapKind =
|
||||
@@ -23,7 +26,7 @@ export interface MaterialAsset {
|
||||
|
||||
export const MATERIAL_LIBRARY_SOURCE = "ambientCG (ambientcg.com)";
|
||||
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[] = [
|
||||
{
|
||||
@@ -84,6 +87,18 @@ export const MATERIAL_LIBRARY: MaterialAsset[] = [
|
||||
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",
|
||||
name: "Bodenfliesen",
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Column, ExtrudedSolid, Project } from "./types";
|
||||
import { sampleProject } from "./sampleProject";
|
||||
import { translateProject } from "./geoRebase";
|
||||
import { translateProject, projectToWorldCoords, projectGeoOrigin } from "./geoRebase";
|
||||
|
||||
const DX = 100;
|
||||
const DY = -50;
|
||||
@@ -201,3 +201,49 @@ describe("translateProject", () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,9 @@ function shiftContextObject(obj: ContextObject, dx: number, dy: number): Context
|
||||
if (obj.type === "contourSet") {
|
||||
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();
|
||||
for (let i = 0; i + 2 < positions.length; i += 3) {
|
||||
positions[i] += dx;
|
||||
@@ -118,3 +121,28 @@ export function translateProject(project: Project, dx: number, dy: number): Proj
|
||||
: 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);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { materialFromAsset } from "../materials/runtime";
|
||||
const PLASTER_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Plaster001")!;
|
||||
const BRICK_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Bricks104")!;
|
||||
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:
|
||||
// EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand.
|
||||
@@ -148,6 +149,19 @@ export const sampleProject: Project = {
|
||||
joinPriority: 10,
|
||||
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).
|
||||
{
|
||||
id: "concrete",
|
||||
@@ -208,7 +222,7 @@ export const sampleProject: Project = {
|
||||
name: "Steildach gedämmt",
|
||||
// Aussen (Eindeckung) → innen (Verkleidung); Bauteile aus der Bibliothek.
|
||||
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: "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.
|
||||
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: [],
|
||||
};
|
||||
|
||||
@@ -13,6 +13,9 @@ import type { Contour, TerrainMesh } from "./types";
|
||||
|
||||
let terrainSeq = 0;
|
||||
|
||||
/** Sichtbarer Ebenen-/Gruppierungsname des Geländes (SitePanel). */
|
||||
export const TERRAIN_LAYER = "Terrain";
|
||||
|
||||
/**
|
||||
* Erzeugt ein TIN aus Konturen.
|
||||
*
|
||||
@@ -96,7 +99,7 @@ export function generateTerrainFromContours(contours: Contour[]): TerrainMesh {
|
||||
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) {
|
||||
return { id, type: "terrainMesh", name, positions: [], indices: [] };
|
||||
}
|
||||
return { id, type: "terrainMesh", name, positions, indices };
|
||||
return { id, type: "terrainMesh", name, layer: TERRAIN_LAYER, positions, indices };
|
||||
}
|
||||
|
||||
@@ -453,6 +453,14 @@ export interface WindowType {
|
||||
insetFromFace?: number;
|
||||
/** Von welcher Wandfläche der {@link insetFromFace} gemessen wird; Default "aussen". */
|
||||
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
|
||||
* (Meter), abgeteilt durch einen Kämpfer. 0/fehlt ⇒ kein Oberlicht.
|
||||
@@ -1356,6 +1364,12 @@ export interface Stair {
|
||||
* Kategorie-Farbe.
|
||||
*/
|
||||
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;
|
||||
type: "terrainMesh";
|
||||
name: string;
|
||||
/** Ebenen-/Gruppierungsname (SitePanel), z. B. „Terrain". Optional. */
|
||||
layer?: string;
|
||||
positions: 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. */
|
||||
/**
|
||||
@@ -2196,6 +2247,13 @@ export interface Project {
|
||||
geoAnchor?: {
|
||||
/** Reale Landeskoordinate (LV95/EPSG:2056, Meter). */
|
||||
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). */
|
||||
model: Vec2;
|
||||
/** Anzeige-Adresse/Bezeichnung des Bezugspunkts. */
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -13,8 +13,9 @@ import { t } from "../i18n";
|
||||
import { Dropdown } from "../ui/Dropdown";
|
||||
|
||||
/**
|
||||
* Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) — exakt die
|
||||
* fünf DOSSIER-Modi aus docs/design/context-menu.md.
|
||||
* Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) — die fünf
|
||||
* 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 }[] = [
|
||||
{ value: "all_force", labelKey: "display.all_force" },
|
||||
@@ -22,6 +23,7 @@ const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [
|
||||
{ value: "active", labelKey: "display.active" },
|
||||
{ value: "grey", labelKey: "display.grey" },
|
||||
{ value: "grey_locked", labelKey: "display.grey_locked" },
|
||||
{ value: "locked", labelKey: "display.locked" },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ import { itemDisplay } from "./displayMode";
|
||||
import { usePanelHost } from "./host";
|
||||
import type { DrawingLevel } from "./host";
|
||||
import type { DisplayMode } from "./types";
|
||||
import { DisplayModeSelect } from "./DisplayModeSelect";
|
||||
import type { DrawingLevelKind } from "../model/types";
|
||||
import { t } from "../i18n";
|
||||
|
||||
@@ -31,8 +32,9 @@ const KIND_BADGE_KEY: Record<DrawingLevelKind, string> = {
|
||||
drawing: "badge.drawing",
|
||||
};
|
||||
|
||||
/** Kategorien der Zeichnungsebenen, in Anzeigereihenfolge. */
|
||||
const CATEGORY_ORDER: DrawingLevelKind[] = [
|
||||
/** Kategorien der Zeichnungsebenen, in Anzeigereihenfolge. Exportiert für das
|
||||
* native Zeichnungsebenen-Fenster (native/DrawingLevelsWindowApp.tsx). */
|
||||
export const CATEGORY_ORDER: DrawingLevelKind[] = [
|
||||
"floor",
|
||||
"section",
|
||||
"elevation",
|
||||
@@ -40,7 +42,7 @@ const CATEGORY_ORDER: DrawingLevelKind[] = [
|
||||
];
|
||||
|
||||
/** i18n-Key des Kategorie-Titels. */
|
||||
const CATEGORY_TITLE_KEY: Record<DrawingLevelKind, string> = {
|
||||
export const CATEGORY_TITLE_KEY: Record<DrawingLevelKind, string> = {
|
||||
floor: "levelCat.floor",
|
||||
section: "levelCat.section",
|
||||
elevation: "levelCat.elevation",
|
||||
@@ -48,7 +50,7 @@ const CATEGORY_TITLE_KEY: Record<DrawingLevelKind, string> = {
|
||||
};
|
||||
|
||||
/** 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",
|
||||
section: "levels.addSection",
|
||||
elevation: "levels.addElevation",
|
||||
@@ -93,10 +95,13 @@ export function DrawingLevelsPanel() {
|
||||
|
||||
return (
|
||||
<section className="nav-group">
|
||||
<header className="nav-group-head">
|
||||
<span className="nav-group-title">{t("nav.drawingLevels")}</span>
|
||||
</header>
|
||||
|
||||
{/* Kein eigener Titel/Kopf mehr — der PanelFrame liefert die EINE
|
||||
Kopfzeile; die Fenster-Ausklinken-Aktion sitzt dort via headerActions
|
||||
(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) => {
|
||||
const levelsOfKind = levelsTopDown.filter((l) => l.kind === kind);
|
||||
const isOpen = !collapsed[kind];
|
||||
@@ -151,8 +156,28 @@ export function DrawingLevelsPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Eine Zeile im Zeichnungsebenen-Bereich. */
|
||||
function LevelRow({
|
||||
/** Aktionen für die gemeinsame PanelFrame-Kopfzeile (statt eigener Titelzeile):
|
||||
* 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,
|
||||
active,
|
||||
mode,
|
||||
|
||||
@@ -26,6 +26,8 @@ import { t } from "../i18n";
|
||||
const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
|
||||
"Wand",
|
||||
"Decke",
|
||||
"Dach",
|
||||
"Stütze",
|
||||
"Fenster",
|
||||
"Öffnung",
|
||||
"Treppe",
|
||||
@@ -36,6 +38,8 @@ const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
|
||||
const CLASS_LABEL_KEY: Record<Exclude<ScheduleKind, "Tür">, string> = {
|
||||
Wand: "elements.kind.wall",
|
||||
Decke: "elements.kind.ceiling",
|
||||
Dach: "elements.kind.roof",
|
||||
Stütze: "elements.kind.column",
|
||||
Fenster: "elements.kind.window",
|
||||
Öffnung: "elements.kind.opening",
|
||||
Treppe: "elements.kind.stair",
|
||||
|
||||
@@ -4,14 +4,18 @@
|
||||
// Navigator: Farb-Swatch, Code, Name, EyeIcon zum Schalten, eingerückte
|
||||
// Kinder, Kopfzeile mit + Ebene. Daten/Handler über usePanelHost.
|
||||
//
|
||||
// Das Panel deklariert hasDisplayMode. Kategorien kennen keine „aktive"
|
||||
// Auswahl (anders als Zeichnungsebenen), daher ist isActive je Zeile false;
|
||||
// `itemDisplay` ergibt damit (fünf DOSSIER-Modi):
|
||||
// Das Panel deklariert hasDisplayMode. „Aktiv" ist die per Klick gewählte
|
||||
// Kategorie (activeCategoryCode, Zeichen-Ziel für neue Elemente); dieselbe
|
||||
// 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" — alle sichtbaren normal (Standard, wie bisher).
|
||||
// • "active" — keine Kategorie aktiv → alle gedimmt.
|
||||
// • "grey" — sichtbare Kategorien ausgegraut.
|
||||
// • "grey_locked" — wie „grey", zusätzlich gesperrt.
|
||||
// • "active" — nur die gewählte Kategorie sichtbar, Rest ausgeblendet.
|
||||
// • "grey" — gewählte normal, übrige sichtbare ausgegraut.
|
||||
// • "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
|
||||
// Steuerung); statt auszublenden dimmen wir gedimmte Zeilen per Inline-Opacity
|
||||
// (styles.css bleibt unangetastet).
|
||||
@@ -23,6 +27,7 @@ import { itemDisplay } from "./displayMode";
|
||||
import { usePanelHost } from "./host";
|
||||
import type { LayerCategory } from "./host";
|
||||
import type { DisplayMode } from "./types";
|
||||
import { DisplayModeSelect } from "./DisplayModeSelect";
|
||||
import { t } from "../i18n";
|
||||
|
||||
/** Inhalt des Panels „Ebenen". */
|
||||
@@ -46,15 +51,13 @@ export function LayersPanel() {
|
||||
|
||||
return (
|
||||
<section className="nav-group">
|
||||
<header className="nav-group-head">
|
||||
<span className="nav-group-title">{t("nav.layers")}</span>
|
||||
<span className="nav-group-actions">
|
||||
<button className="nav-add" onClick={host.onAddCategory}>
|
||||
{t("layers.addCategory")}
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* Kein eigener Titel/Kopf mehr — der PanelFrame liefert die EINE
|
||||
Kopfzeile; die „+ Kategorie"-Aktion sitzt dort via headerActions
|
||||
(LayersHeaderActions), kein doppelter „Ebenen"-Titel. */}
|
||||
<div className="panel-vis">
|
||||
<span className="panel-vis-label">{t("nav.visibility")}</span>
|
||||
<DisplayModeSelect value={displayMode.mode} onChange={displayMode.setMode} />
|
||||
</div>
|
||||
<div className="nav-list">
|
||||
{project.layers.map((cat) => (
|
||||
<CategoryRow
|
||||
@@ -66,6 +69,7 @@ export function LayersPanel() {
|
||||
onSelect={host.onSelectCategory}
|
||||
onToggle={toggle}
|
||||
onContext={onRowContext}
|
||||
onSettings={host.onOpenLayerSettings}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -82,6 +86,7 @@ function CategoryRow({
|
||||
onSelect,
|
||||
onToggle,
|
||||
onContext,
|
||||
onSettings,
|
||||
}: {
|
||||
category: LayerCategory;
|
||||
depth: number;
|
||||
@@ -90,6 +95,7 @@ function CategoryRow({
|
||||
onSelect: (code: string) => void;
|
||||
onToggle: (code: string) => 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).
|
||||
const isActive = category.code === activeCode;
|
||||
@@ -137,6 +143,19 @@ function CategoryRow({
|
||||
<span className="cat-code">{category.code}</span>
|
||||
<span className="cat-swatch" style={{ background: category.color }} />
|
||||
<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>
|
||||
{category.children?.map((child) => (
|
||||
<CategoryRow
|
||||
@@ -148,8 +167,38 @@ function CategoryRow({
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -242,6 +242,35 @@ export function ObjectInfoPanel() {
|
||||
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)
|
||||
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
|
||||
die exportierten *Section-Komponenten). */}
|
||||
@@ -393,6 +422,20 @@ export function SectionLineSection({
|
||||
{t("objinfo.sectionLine.flip")}
|
||||
</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). */}
|
||||
{line && (
|
||||
<>
|
||||
@@ -854,6 +897,27 @@ export function StairSection({
|
||||
</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. */}
|
||||
<DimensionField
|
||||
label={t("objinfo.stair.width")}
|
||||
|
||||
@@ -54,12 +54,15 @@ export function PanelFrame({
|
||||
<section className="panel-frame">
|
||||
<header className="panel-head">
|
||||
<span className="panel-title">{t(def.title)}</span>
|
||||
<span className="panel-head-actions">
|
||||
{def.headerActions}
|
||||
{showMode && (
|
||||
<DisplayModeSelect
|
||||
value={displayMode as DisplayMode}
|
||||
onChange={onDisplayModeChange as (m: DisplayMode) => void}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</header>
|
||||
<div className="panel-body">{def.render(ctx ?? {})}</div>
|
||||
</section>
|
||||
|
||||
@@ -19,6 +19,8 @@ import type { ContextObject } from "../model/types";
|
||||
import { ContextImportDialog } from "../ui/ContextImportDialog";
|
||||
import { Dropdown } from "../ui/Dropdown";
|
||||
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. */
|
||||
function typeBadgeKey(obj: ContextObject): TranslationKey {
|
||||
@@ -29,9 +31,43 @@ function typeBadgeKey(obj: ContextObject): TranslationKey {
|
||||
return "site.type.contours";
|
||||
case "terrainMesh":
|
||||
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() {
|
||||
const host = usePanelHost();
|
||||
const notify = useStore((s) => s.notify);
|
||||
@@ -72,7 +108,10 @@ export function SitePanel() {
|
||||
<button
|
||||
type="button"
|
||||
className="site-btn"
|
||||
onClick={() => setCtxImportOpen(true)}
|
||||
onClick={() => {
|
||||
if (isTauriRuntime()) void openContextImportWindowNative();
|
||||
else setCtxImportOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("site.importLocation")}
|
||||
</button>
|
||||
@@ -117,12 +156,30 @@ export function SitePanel() {
|
||||
|
||||
<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 ? (
|
||||
<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">
|
||||
{objects.map((obj) => (
|
||||
{items.map((obj) => (
|
||||
<li
|
||||
key={obj.id}
|
||||
className={
|
||||
@@ -148,6 +205,8 @@ export function SitePanel() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="site-hint">{t("site.dwgHint")}</div>
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
// Inhalts-Panel „Werkzeuge" — die vertikale Werkzeug-Palette (ArchiCAD-/
|
||||
// Vectorworks-Stil): eine Zeile je Zeichenwerkzeug, jeweils Icon + Name, das
|
||||
// aktive Werkzeug hervorgehoben. Darunter — wenn das Wand-Werkzeug aktiv ist —
|
||||
// ein Wandtyp-Auswahlfeld, und ein kompakter Fang-Abschnitt mit Checkboxen.
|
||||
// Inhalts-Panel „Werkzeuge" — die Werkzeug-Palette (ArchiCAD-/Vectorworks-Stil).
|
||||
// Oben ein Umschalter „Liste ↔ Symbole" (Icon+Name als Zeilen ODER kompaktes
|
||||
// Icon-Raster). Darunter drei einheitlich gestylte Rubriken: 2D (Zeichnen),
|
||||
// 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-
|
||||
// Liste und ihre Metadaten stammen aus der Tools-Registry (TOOL_ORDER/getTool).
|
||||
// Daten/Handler kommen über usePanelHost (kein Prop-Drilling). Werkzeug-Metadaten
|
||||
// aus der Tools-Registry (getTool), Befehls-Labels aus der Command-Registry.
|
||||
//
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
|
||||
// Texte über t(...).
|
||||
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { usePanelHost } 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 ──────────────────────────────────────────────────────────────────
|
||||
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
|
||||
@@ -31,18 +36,17 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
||||
};
|
||||
switch (id) {
|
||||
case "select":
|
||||
// Pfeil-Cursor.
|
||||
// Pfeil-Cursor (klassischer Zeiger mit Schweif).
|
||||
return (
|
||||
<svg {...common} fill="currentColor" stroke="none">
|
||||
<path d="M3 2l9 5-3.6 1.1L10.4 13 8.5 13.7 6.6 9.4 3 11z" />
|
||||
<svg {...common} fill="currentColor" stroke="currentColor" strokeWidth={0.8}>
|
||||
<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>
|
||||
);
|
||||
case "wall":
|
||||
// Zwei parallele Linien (Wandband).
|
||||
// Wandband: zwei parallele Kanten mit geschlossenen Enden (Wandstück).
|
||||
return (
|
||||
<svg {...common}>
|
||||
<line x1="2" y1="5.5" x2="14" y2="5.5" />
|
||||
<line x1="2" y1="10.5" x2="14" y2="10.5" />
|
||||
<rect x="2" y="5.5" width="12" height="5" />
|
||||
</svg>
|
||||
);
|
||||
case "ceiling":
|
||||
@@ -55,29 +59,37 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
||||
</svg>
|
||||
);
|
||||
case "window":
|
||||
// Fenster: Rahmen-Rechteck mit Mittelsprosse (Verglasung).
|
||||
// Fenster (SIA-Aufsicht): Wandöffnung mit doppelter Glaslinie.
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="2.5" y="4" width="11" height="8" />
|
||||
<line x1="8" y1="4" x2="8" y2="12" />
|
||||
<line x1="2" y1="5.5" x2="14" y2="5.5" />
|
||||
<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>
|
||||
);
|
||||
case "door":
|
||||
// Tür: Pfosten + Türblatt mit Schwenkbogen.
|
||||
// Tür (SIA-Aufsicht): Türblatt aufrecht + Viertelkreis-Schwenkbogen.
|
||||
return (
|
||||
<svg {...common}>
|
||||
<line x1="3" y1="13" x2="3" y2="4" />
|
||||
<line x1="3" y1="4" x2="11" y2="4" />
|
||||
<path d="M3 13 A9 9 0 0 0 11 4" />
|
||||
<line x1="3.5" y1="13" x2="3.5" y2="3" />
|
||||
<path d="M3.5 3 A9.5 9.5 0 0 1 13 12.5" strokeDasharray="1.4 1.4" />
|
||||
<line x1="3.5" y1="13" x2="13" y2="13" />
|
||||
</svg>
|
||||
);
|
||||
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 (
|
||||
<svg {...common}>
|
||||
<polyline points="2,14 2,11 6,11 6,8 10,8 10,5 14,5 14,2" />
|
||||
<line x1="4" y1="12.5" x2="12" y2="3.5" />
|
||||
<polyline points="10.5,3 12,3.5 11.5,5" />
|
||||
<rect x="3" y="2" width="10" height="12" />
|
||||
<line x1="3" y1="4.4" x2="13" y2="4.4" />
|
||||
<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>
|
||||
);
|
||||
case "room":
|
||||
@@ -118,10 +130,12 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
||||
</svg>
|
||||
);
|
||||
case "arc":
|
||||
// Offener Halbbogen.
|
||||
// Offener Bogen (gut 180°) mit Endpunkt-Markierungen.
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ToolsPanel() {
|
||||
const host = usePanelHost();
|
||||
const [view, setView] = useState<ToolsView>(loadView);
|
||||
|
||||
return (
|
||||
<div className="tools-panel">
|
||||
{/* Werkzeug-Zeilen: Icon + Name, aktives hervorgehoben. */}
|
||||
{TOOL_ORDER.map((id) => {
|
||||
const chooseView = (v: ToolsView) => {
|
||||
setView(v);
|
||||
try {
|
||||
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 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 title = disabled
|
||||
? t("tool.floorOnlyDisabled")
|
||||
: t(tool.hintKey(tool.init()));
|
||||
const cls =
|
||||
"tool-row" + (active ? " active" : "") + (disabled ? " disabled" : "");
|
||||
const label = t(tool.labelKey);
|
||||
const title = disabled ? t("tool.floorOnlyDisabled") : t(tool.hintKey(tool.init()));
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={cls}
|
||||
title={title}
|
||||
className={"tp-item" + (active ? " active" : "") + (disabled ? " disabled" : "")}
|
||||
title={view === "icons" ? label : title}
|
||||
disabled={disabled}
|
||||
onClick={() => host.onSelectTool(id)}
|
||||
>
|
||||
<ToolIcon id={id} />
|
||||
<span>{t(tool.labelKey)}</span>
|
||||
<span className="tp-label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
};
|
||||
|
||||
{/* Wandtyp-/Deckentyp-Auswahl ist ins Attribute-Panel gewandert (Ribbon-UI
|
||||
Phase 3: Werkzeug-Sidebar nicht mehr im Default-Layout). Der Picker
|
||||
erscheint dort bei aktivem Wand-/Decken-Werkzeug. */}
|
||||
// Ein Befehls-Knopf (Ändern-/BIM-Kommando ohne eigenen Werkzeug-Modus) —
|
||||
// gleicher Look wie die Werkzeuge (Nutzerwunsch: nicht „ganz anders").
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { registerPanel } from "./registry";
|
||||
import { DrawingLevelsPanel } from "./DrawingLevelsPanel";
|
||||
import { LayersPanel } from "./LayersPanel";
|
||||
import { DrawingLevelsPanel, DrawingLevelsHeaderActions } from "./DrawingLevelsPanel";
|
||||
import { LayersPanel, LayersHeaderActions } from "./LayersPanel";
|
||||
import { ToolsPanel } from "./ToolsPanel";
|
||||
import { AttributesPanel } from "./AttributesPanel";
|
||||
import { ObjectInfoPanel } from "./ObjectInfoPanel";
|
||||
@@ -82,7 +82,10 @@ registerPanel({
|
||||
// damit der Titel der gewählten Sprache folgt.
|
||||
title: "nav.drawingLevels",
|
||||
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 />,
|
||||
});
|
||||
|
||||
@@ -90,7 +93,8 @@ registerPanel({
|
||||
id: BUILTIN_PANEL_IDS.layers,
|
||||
title: "nav.layers",
|
||||
icon: <LayersTabIcon />,
|
||||
hasDisplayMode: true,
|
||||
hasDisplayMode: false,
|
||||
headerActions: <LayersHeaderActions />,
|
||||
render: () => <LayersPanel />,
|
||||
});
|
||||
|
||||
|
||||
@@ -32,8 +32,10 @@ export interface ItemDisplay {
|
||||
* ausgegraut.
|
||||
* • "grey_locked" — wie „grey", übrige (sichtbare, nicht aktive) zusätzlich
|
||||
* 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
|
||||
* nicht aktives, unsichtbares Element wird (außer in "all_force") nie
|
||||
* gezeichnet.
|
||||
@@ -65,5 +67,12 @@ export function itemDisplay(
|
||||
const other = isVisible && !isActive;
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,11 @@ export interface PanelHostValue {
|
||||
onSelectTool: (id: ToolId) => void;
|
||||
/** Werkzeuge nur im Grundriss eines Geschosses sinnvoll. */
|
||||
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). */
|
||||
activeWallTypeId: string;
|
||||
onActiveWallTypeId: (id: string) => void;
|
||||
@@ -112,6 +117,14 @@ export interface PanelHostValue {
|
||||
* Löschen) und hält das offene Menü.
|
||||
*/
|
||||
onLayerContextMenu: (code: string, clientX: number, clientY: number) => void;
|
||||
/** Öffnet die Ebeneneinstellungen direkt (Zahnrad-Symbol je Zeile) — unter
|
||||
* Tauri als natives Fenster, sonst derselbe InlineEditor-Popover wie über
|
||||
* das Kontextmenü (siehe App.tsx `openLayerSettings`). */
|
||||
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
|
||||
// ResourceManagerHandlers, hier flach getippt, damit dieses Modul nicht von
|
||||
@@ -351,6 +364,15 @@ export interface PanelHostValue {
|
||||
* linePoints/depth) — wirkt NUR auf die selektierte Linie.
|
||||
*/
|
||||
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
|
||||
* Ebene selbst bleibt bestehen (der Schnitt zeigt dann wieder den Hinweis).
|
||||
@@ -368,6 +390,8 @@ export interface PanelHostValue {
|
||||
onSetStairRise: (totalRise: number) => void;
|
||||
/** Setzt die Laufrichtung (aufwärts/abwärts). */
|
||||
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). */
|
||||
onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void;
|
||||
/** Weist der Treppe einen Treppentyp (Bibliothek) zu; "" löst die Zuweisung. */
|
||||
@@ -410,6 +434,8 @@ export interface PanelHostValue {
|
||||
onAddContextObjects: (objs: ContextObject[]) => void;
|
||||
/** Entfernt ein Kontext-Objekt per ID. */
|
||||
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
|
||||
* 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).
|
||||
*/
|
||||
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;
|
||||
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
|
||||
onImportDxf: () => void;
|
||||
|
||||
@@ -40,7 +40,10 @@ import type {
|
||||
// als weiterer Tab in der rechten unteren Gruppe.
|
||||
// v11: Layouts-Panel ("layouts", DOSSIER A3 / ROADMAP Paragraf 11) als weiterer
|
||||
// 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. */
|
||||
const CURRENT_KEY = "cad.layout";
|
||||
@@ -64,11 +67,11 @@ const NAMED_PREFIX = "cad.layouts.";
|
||||
* OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein
|
||||
* schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht.
|
||||
*/
|
||||
// Ribbon-UI Phase 3: die Werkzeug-Sidebar ("tools") ist NICHT mehr im Default-
|
||||
// Layout — die Werkzeuge liegen im Ribbon, der Wandtyp-/Deckentyp-Picker im
|
||||
// Attribute-Panel. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar),
|
||||
// nur nicht mehr per Default sichtbar → Attribute bekommt die volle linke Höhe.
|
||||
// v12: Werkzeug-Palette ("tools") wieder im Default-Layout — oben links, mit
|
||||
// der Attribute-Palette darunter (Vectorworks-Bild). Das Ribbon-Band bleibt als
|
||||
// Reiter erreichbar, ist aber nicht mehr der primäre Weg zu den Werkzeugen.
|
||||
const DEFAULT_LEFT_GROUPS: DockGroup[] = [
|
||||
{ tabs: ["tools"], activeTab: "tools", weight: 1 },
|
||||
{ tabs: ["attributes"], activeTab: "attributes", weight: 1 },
|
||||
];
|
||||
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
|
||||
@@ -376,6 +379,26 @@ export function setActiveTab(
|
||||
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-
|
||||
// Semantik; als Helfer exportiert, damit Wiring-Code symmetrisch arbeiten kann.
|
||||
export { otherDock };
|
||||
|
||||
@@ -97,16 +97,18 @@ export interface LayoutState {
|
||||
/**
|
||||
* Darstellungsmodus für ortsabhängige Inhalte (Navigator-Listen, 3D, Grundriss).
|
||||
* 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
|
||||
* Zeichnungsebenen identisch:
|
||||
* fünf DOSSIER-Modi (siehe docs/design/context-menu.md) plus eine eigene
|
||||
* Ergänzung ("locked"), für Ebenen UND Zeichnungsebenen identisch:
|
||||
* • "all_force" — alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge
|
||||
* wechselt zu „Ausgewählte".
|
||||
* • "all" — sichtbar nach per-Zeile-Flag (Standard).
|
||||
* • "active" — nur das aktive Element; andere stark gedimmt.
|
||||
* • "grey" — aktives normal, andere 45 % (Sichtbarkeits-Flags gelten).
|
||||
* • "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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -151,6 +153,14 @@ export interface PanelDef {
|
||||
* Kopfzeile anbieten soll. Default: kein Umschalter.
|
||||
*/
|
||||
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) ────────────────────────
|
||||
|
||||
@@ -213,6 +213,12 @@ export interface PlanSelection {
|
||||
* über allem — ein Klick genau darauf meint die Linie).
|
||||
*/
|
||||
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
|
||||
* Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu
|
||||
@@ -303,6 +309,12 @@ export interface PlanViewProps {
|
||||
selectedRoomIds?: string[];
|
||||
/** Aktuell ausgewählte Dach-IDs (von App); die Ansicht markiert die Traufe. */
|
||||
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
|
||||
* Ansicht markiert das Pick-Band JEDER dieser Linien mit einer Akzentkontur.
|
||||
@@ -331,6 +343,9 @@ export interface PlanViewProps {
|
||||
onStampEdit?: (roomId: string) => void;
|
||||
/** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */
|
||||
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
|
||||
* der CAD-Konvention (siehe {@link MarqueeSelection}). Optional.
|
||||
@@ -447,6 +462,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
selectedStairIds,
|
||||
selectedRoomIds,
|
||||
selectedRoofIds,
|
||||
selectedContextObjectIds,
|
||||
selectedSectionLineIds,
|
||||
selectedExtrudedSolidIds,
|
||||
selectedColumnIds,
|
||||
@@ -454,6 +470,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
onStampMove,
|
||||
onStampEdit,
|
||||
onEditDrawingText,
|
||||
onOpenSectionLevel,
|
||||
activeTool,
|
||||
toolInputActive,
|
||||
toolHandlers,
|
||||
@@ -537,9 +554,15 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
// „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).
|
||||
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
|
||||
// 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-
|
||||
// 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;
|
||||
};
|
||||
|
||||
// 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
|
||||
// ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon
|
||||
// trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie).
|
||||
@@ -1589,6 +1628,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
columnId: null,
|
||||
roofId: null,
|
||||
sectionLineId,
|
||||
contextObjectId: null,
|
||||
shift: e.shiftKey,
|
||||
});
|
||||
return;
|
||||
@@ -1644,6 +1684,13 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId || roomId
|
||||
? null
|
||||
: 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({
|
||||
model: viewToModel(v.x, v.y),
|
||||
hitIndex,
|
||||
@@ -1657,6 +1704,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
columnId,
|
||||
roofId,
|
||||
sectionLineId: null,
|
||||
contextObjectId,
|
||||
shift: e.shiftKey,
|
||||
});
|
||||
}
|
||||
@@ -1729,6 +1777,14 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -1856,6 +1912,21 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
);
|
||||
}, [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)
|
||||
// Pick-Band (Polygon mit sectionLineId) mit Akzentkontur überzeichnen.
|
||||
const highlightSectionLinePolys = useMemo(() => {
|
||||
@@ -1988,8 +2059,9 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
darüber erreichen. An `wantGpu` (Absicht) montiert — NICHT an `useGpuRenderer`
|
||||
(=wantGpu&&glReady), sonst könnte GL nie initialisieren (Canvas fehlt →
|
||||
glReady bleibt false → Deadlock). Bei WebGL-Fehler bleibt es leer hinten
|
||||
und der volle SVG-Pfad übernimmt sichtbar. */}
|
||||
{wantGpu && (
|
||||
und der volle SVG-Pfad übernimmt sichtbar. NICHT montiert, wenn ein
|
||||
Luftbild aktiv ist — dann rendert der SVG-Pfad alles inkl. Bild-Ebene. */}
|
||||
{wantGpu && !hasBgImages && (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
@@ -2058,6 +2130,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
className={useGpuRenderer ? undefined : "plan-bg"}
|
||||
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
|
||||
gezeichnet → gehrte Ecken statt Butt-Cap-Stufe; im Memo übersprungen. */}
|
||||
{primitiveEls}
|
||||
@@ -2114,6 +2208,18 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
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). */}
|
||||
{highlightSectionLinePolys.map((poly, i) => (
|
||||
<polygon
|
||||
@@ -2332,8 +2438,16 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
hudFields={hudFields ?? null}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
{/* 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 && (() => {
|
||||
const m = measure(view);
|
||||
if (!m.rect) return null;
|
||||
@@ -2345,15 +2459,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
h: (m.rect.height || 1) / scale,
|
||||
};
|
||||
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
|
||||
vb={vb}
|
||||
vbPerPx={1 / scale}
|
||||
pxPerMeter={scale * PX_PER_M}
|
||||
cursor={rulerCursor}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -2385,7 +2512,7 @@ function DraftOverlay({
|
||||
} | null;
|
||||
}) {
|
||||
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 (
|
||||
<g className="tool-overlay" pointerEvents="none">
|
||||
{/* Vorschau-Formen. */}
|
||||
@@ -3271,8 +3398,29 @@ function buildDrawingRuns(prims: Primitive[]): DrawingRun[] {
|
||||
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). */
|
||||
function DrawingRunShape({
|
||||
@@ -3315,12 +3463,13 @@ function DrawingRunShape({
|
||||
// Punktmuster (dash enthält eine 0): runde Kappe, damit die 0-Längen-Segmente
|
||||
// als Dots erscheinen (Punktlinie / Strich-Punkt).
|
||||
const cap = dashHasDot(run.dash) ? ("round" as const) : undefined;
|
||||
const stroke = run.greyed ? greyColor(run.color) : run.color;
|
||||
const shape = run.closed ? (
|
||||
<polygon
|
||||
points={pts}
|
||||
className={run.cls}
|
||||
fill="none"
|
||||
stroke={run.color}
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={dashStr}
|
||||
strokeLinecap={cap}
|
||||
@@ -3331,14 +3480,14 @@ function DrawingRunShape({
|
||||
points={pts}
|
||||
className={run.cls}
|
||||
fill="none"
|
||||
stroke={run.color}
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={dashStr}
|
||||
strokeLinecap={cap}
|
||||
vectorEffect={vfx}
|
||||
/>
|
||||
);
|
||||
return run.greyed ? <g opacity={GREYED_OPACITY}>{shape}</g> : shape;
|
||||
return shape;
|
||||
}
|
||||
|
||||
function PrimitiveShape({
|
||||
@@ -3354,12 +3503,9 @@ function PrimitiveShape({
|
||||
hairline: boolean;
|
||||
paperScale: number | null;
|
||||
}) {
|
||||
// Gedimmte Elemente bekommen eine niedrige Gruppen-Opazität, sodass Strich
|
||||
// UND Füllung gleichmäßig zurücktreten (best-effort, ohne Farbumrechnung).
|
||||
const shape = renderPrimitive(p, index, toScreen, hairline, paperScale);
|
||||
if (p.greyed) {
|
||||
return <g opacity={GREYED_OPACITY}>{shape}</g>;
|
||||
}
|
||||
// Gedimmte Elemente werden auf den festen Grauton umgefärbt (Strich, Füllung
|
||||
// UND Schraffur) — deutlich erkennbar unabhängig von Papierfarbe/Linienstärke.
|
||||
const shape = renderPrimitive(p.greyed ? greyedCopy(p) : p, index, toScreen, hairline, paperScale);
|
||||
return shape;
|
||||
}
|
||||
|
||||
@@ -3887,17 +4033,17 @@ function PlanRulers({
|
||||
<>
|
||||
<rect
|
||||
className="plan-ruler-cursor"
|
||||
x={cursor.x * PX_PER_M - 1.5 * vbPerPx}
|
||||
x={cursor.x * PX_PER_M - 1 * vbPerPx}
|
||||
y={y0}
|
||||
width={3 * vbPerPx}
|
||||
width={2 * vbPerPx}
|
||||
height={TH}
|
||||
/>
|
||||
<rect
|
||||
className="plan-ruler-cursor"
|
||||
x={x0}
|
||||
y={-cursor.y * PX_PER_M - 1.5 * vbPerPx}
|
||||
y={-cursor.y * PX_PER_M - 1 * vbPerPx}
|
||||
width={TH}
|
||||
height={3 * vbPerPx}
|
||||
height={2 * vbPerPx}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,24 @@ describe("generatePlan — Schnittführungs-Symbol", () => {
|
||||
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", () => {
|
||||
const p = baseProject([
|
||||
floor,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
Ceiling,
|
||||
Column,
|
||||
Component,
|
||||
ContextObject,
|
||||
Drawing2D,
|
||||
ExtrudedSolid,
|
||||
HatchPattern,
|
||||
@@ -191,19 +192,22 @@ export interface HatchRender {
|
||||
/**
|
||||
* Render-Entscheidung je Kategorie-Code für den Grundriss (vgl. ItemDisplay aus
|
||||
* dem Panel-Darstellungsmodus). `render`=false blendet die Kategorie ganz aus,
|
||||
* `greyed`=true zeichnet sie gedimmt. Wird kein Resolver übergeben, gilt für
|
||||
* jede sichtbare Kategorie {render:true, greyed:false} (bisheriges Verhalten).
|
||||
* `greyed`=true zeichnet sie gedimmt, `locked`=true (nur Modus „grey_locked")
|
||||
* 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 {
|
||||
render: boolean;
|
||||
greyed: boolean;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
/** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */
|
||||
export type CategoryDisplayResolver = (code: string) => CategoryDisplay;
|
||||
|
||||
/** 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 =
|
||||
| {
|
||||
@@ -257,6 +261,12 @@ export type Primitive =
|
||||
* Auswahl — heute nur zur Nachvollziehbarkeit getaggt, noch nicht verdrahtet).
|
||||
*/
|
||||
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
|
||||
* (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 {
|
||||
primitives: Primitive[];
|
||||
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.
|
||||
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 {
|
||||
primitives,
|
||||
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). */
|
||||
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
|
||||
* dünne, gedämpfte Referenz-Linien in den Grundriss. Die Z-Höhe wird ignoriert
|
||||
* (reine 2D-Projektion). TerrainMeshes/ImportedMeshes erscheinen im Plan NICHT
|
||||
* (3D genügt). Rein additiv — Wand-/Drawing-Darstellung bleibt unberührt.
|
||||
* Zeichnet die Kontext-Schicht `project.context` in den Grundriss:
|
||||
* • ContourSet (Höhenlinien) → dünne Referenz-Linien (Z ignoriert).
|
||||
* • ImportedMesh (Gebäude) → GRUNDRISS-FUSSABDRUCK: die Dreieckskanten nahe
|
||||
* 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 {
|
||||
const context = project.context ?? [];
|
||||
for (const obj of context) {
|
||||
if (obj.type !== "contourSet") continue;
|
||||
if (obj.type === "contourSet") {
|
||||
for (const c of obj.contours) {
|
||||
const pts = c.pts;
|
||||
if (pts.length < 2) continue;
|
||||
@@ -1084,7 +1128,126 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2736,6 +2899,10 @@ function sectionLabelTag(name: string): string {
|
||||
function addSectionLines(out: Primitive[], project: Project): void {
|
||||
for (const level of project.drawingLevels) {
|
||||
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;
|
||||
if (!line) continue;
|
||||
const [p0, p1] = line;
|
||||
@@ -3053,10 +3220,24 @@ function addStairSymbol(
|
||||
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;
|
||||
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: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
|
||||
}
|
||||
|
||||
// Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L)
|
||||
// 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 });
|
||||
} 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 ?? []) {
|
||||
if (obj.type !== "contourSet") continue;
|
||||
if (obj.type === "contourSet") {
|
||||
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 };
|
||||
return { minX, minY, maxX, maxY };
|
||||
|
||||
@@ -129,6 +129,18 @@ function parseColor(hex: string | undefined): Rgba | null {
|
||||
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 +
|
||||
@@ -387,7 +399,7 @@ export function compilePrimitivesToGpu(
|
||||
track(pt.x, pt.y);
|
||||
}
|
||||
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):
|
||||
@@ -414,7 +426,7 @@ export function compilePrimitivesToGpu(
|
||||
prim.hatch.pattern !== 'none' &&
|
||||
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 runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash);
|
||||
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).
|
||||
const stroke = parseColor(prim.stroke);
|
||||
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') {
|
||||
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) {
|
||||
// Zickzack-Linie → gehrter Polylinienzug (amplitude/wavelength Papier-mm
|
||||
// → Modell-Meter über dieselbe Kopplung wie Strichmuster).
|
||||
@@ -444,7 +456,10 @@ export function compilePrimitivesToGpu(
|
||||
}
|
||||
} else if (prim.kind === 'arc') {
|
||||
// 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 c = prim.center;
|
||||
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').
|
||||
// Kreis = voller Umlauf (geschlossen, optional gefüllt), Bogen = a0..a1 (CCW,
|
||||
// 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 c = prim.center;
|
||||
const TAU = Math.PI * 2;
|
||||
@@ -519,7 +534,7 @@ export function compilePrimitivesToGpu(
|
||||
track(pt.x, pt.y);
|
||||
}
|
||||
for (const t of tris) fillIdx.push(base + t);
|
||||
addFillBatch(tris.length, fill);
|
||||
addFillBatch(tris.length, dim(fill, prim.greyed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,20 +132,26 @@ const CLS_STROKE: Record<string, string> = {
|
||||
"door-swing": "#1a1a1a",
|
||||
"door-frame": "#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> = {
|
||||
"wall-axis": 0.85,
|
||||
"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 {
|
||||
const o = (cls ? CLS_OPACITY[cls] ?? 1 : 1) * (greyed ? GREYED_ALPHA : 1);
|
||||
return o === 1 ? col : [col[0], col[1], col[2], col[3] * o];
|
||||
const o = cls ? CLS_OPACITY[cls] ?? 1 : 1;
|
||||
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. */
|
||||
|
||||
@@ -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", () => {
|
||||
const w1 = projectToWalls3d(twoOffsetWindows, false).filter((w) => w.wallId === "W1");
|
||||
// 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);
|
||||
});
|
||||
|
||||
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)", () => {
|
||||
// Volles Sample-Projekt: 2 Fenster (O1, O2, beide Typ "window-standard" =
|
||||
// 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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const walmRoof: Roof = { ...baseRoof, id: "R2", shape: "walm" };
|
||||
const project: Project = { ...sampleProject, roofs: [walmRoof] };
|
||||
|
||||
@@ -222,6 +222,11 @@ export function wallMaterialColorMaps(project: Project): string[] {
|
||||
// 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);
|
||||
return list;
|
||||
}
|
||||
@@ -367,6 +372,16 @@ export interface RMesh {
|
||||
indices: number[];
|
||||
kind: RMeshKind;
|
||||
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). */
|
||||
@@ -1138,12 +1153,22 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
|
||||
// • Öffnungen (project.openings, Fenster mit Brüstung/Sturz),
|
||||
// • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz).
|
||||
// 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)) {
|
||||
const iv = openingInterval(wall, op);
|
||||
if (!iv) continue;
|
||||
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 ?? []) {
|
||||
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;
|
||||
// Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe.
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
// jeweiligen Box-Start korrekt stanzt.
|
||||
if (layered) {
|
||||
const holes: RHole[] = cutouts.map((c) => ({
|
||||
const holes: (RHole & { recessInnerLayer: boolean })[] = cutouts.map((c) => ({
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
zBottom: c.oBottom,
|
||||
zTop: c.oTop,
|
||||
recessInnerLayer: c.recessInnerLayer,
|
||||
}));
|
||||
const startCut = cuts?.startCut ?? 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
|
||||
// `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 segHoles = holes
|
||||
.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));
|
||||
};
|
||||
|
||||
@@ -1868,6 +1909,13 @@ function emitRoofs(project: Project): RMesh[] {
|
||||
const color =
|
||||
overrideColor ??
|
||||
(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 indices: number[] = [];
|
||||
for (const plane of g.planes) {
|
||||
@@ -1881,7 +1929,7 @@ function emitRoofs(project: Project): RMesh[] {
|
||||
]);
|
||||
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;
|
||||
}, 0);
|
||||
// Giebel (Endfüllung) als dünne Fläche, äusserste Schichtfarbe/Override.
|
||||
@@ -1919,6 +1967,13 @@ function emitMeshes(project: Project): RMesh[] {
|
||||
indices: obj.indices,
|
||||
kind: obj.type === "terrainMesh" ? "terrain" : "imported",
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}/
|
||||
* {@link getWindowType}) die Rahmen-/Sprossen-Riegel (Zarge/Blendrahmen, Sturz,
|
||||
@@ -2803,6 +2900,7 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R
|
||||
meshes: [
|
||||
...emitMeshes(project),
|
||||
...emitOpeningGlass(project),
|
||||
...emitLegacyDoorLeaves(project),
|
||||
...emitOpeningFrames(project, detail),
|
||||
...emitOpeningShading(project, detail),
|
||||
...emitStairMeshes(project),
|
||||
@@ -2888,7 +2986,7 @@ export function pickGeometry(
|
||||
// Indizes robust überspringen statt abzustürzen (importierte Daten).
|
||||
const contextMeshes: PickContextMesh[] = [];
|
||||
for (const obj of project.context ?? []) {
|
||||
if (obj.type === "contourSet") continue;
|
||||
if (obj.type === "contourSet" || obj.type === "image") continue;
|
||||
const pos = obj.positions;
|
||||
const vcount = pos.length / 3;
|
||||
const tris: [RoofPickVec3, RoofPickVec3, RoofPickVec3][] = [];
|
||||
@@ -3045,7 +3143,7 @@ export function selectionHighlightLines(
|
||||
// tausende Dreiecke, deren Kanten einzeln zu zeichnen wäre unbrauchbar dicht.
|
||||
if (contextObjSet.size > 0) {
|
||||
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;
|
||||
let minX = Infinity, maxX = -Infinity;
|
||||
let minY = Infinity, maxY = -Infinity;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
// laufen. Die Slice tippt daher nur ihre eigenen Felder; `get()` liefert
|
||||
// RootState (siehe appStore.ts), wir greifen punktuell auf View-Felder zu.
|
||||
|
||||
import { sampleProject } from "../model/sampleProject";
|
||||
import { emptyProject } from "../model/sampleProject";
|
||||
import {
|
||||
flattenCategories,
|
||||
recomputeFloorElevations,
|
||||
@@ -363,7 +363,10 @@ export function createProjectSlice(
|
||||
return {
|
||||
...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,
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
Ceiling,
|
||||
Column,
|
||||
ColumnProfile,
|
||||
ContextObject,
|
||||
Drawing2D,
|
||||
Drawing2DGeom,
|
||||
ExtrudedSolid,
|
||||
@@ -41,6 +42,7 @@ import type {
|
||||
Wall,
|
||||
WallReferenceLine,
|
||||
} from "../model/types";
|
||||
import { localToLv95 } from "../io/lv95";
|
||||
import { columnVerticalExtent } from "../model/wall";
|
||||
import { columnFootprint } from "../geometry/column";
|
||||
import { evaluateRoom, polygonArea } from "../geometry/roomArea";
|
||||
@@ -258,6 +260,8 @@ export interface StairInfo {
|
||||
treadDepth: number;
|
||||
/** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */
|
||||
up: boolean;
|
||||
/** Pfeil-Darstellung (offener Winkel- vs. gefüllter Vollpfeil). Fehlt → "line". */
|
||||
arrowStyle?: "line" | "filled";
|
||||
/** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */
|
||||
referenz?: "links" | "mitte" | "rechts";
|
||||
/** Zugewiesener Treppentyp (Bibliothek, `Stair.typeId`) oder undefined. */
|
||||
@@ -348,7 +352,8 @@ export interface Selection {
|
||||
| "door"
|
||||
| "extrudedSolid"
|
||||
| "column"
|
||||
| "roof";
|
||||
| "roof"
|
||||
| "contextObject";
|
||||
id: string;
|
||||
/** Grafik-Kategorie (Ebene) des Elements. */
|
||||
categoryCode: string;
|
||||
@@ -424,6 +429,20 @@ export interface Selection {
|
||||
extrudedSolid?: ExtrudedSolidInfo;
|
||||
/** Stützen-Attribute (nur bei `kind === "column"`). */
|
||||
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;
|
||||
@@ -765,6 +784,7 @@ function stairSelection(project: Project, s: Stair): Selection {
|
||||
riserHeight: geo.riserHeight,
|
||||
treadDepth: geo.treadDepth,
|
||||
up: s.up !== false,
|
||||
arrowStyle: s.arrowStyle,
|
||||
referenz: s.referenz,
|
||||
typeId: s.typeId,
|
||||
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). */
|
||||
function drawingSelection(project: Project, d: Drawing2D): Selection {
|
||||
const ls = d.lineStyleId
|
||||
@@ -963,7 +1030,12 @@ export function deriveSelection(
|
||||
selectedExtrudedSolidId: string | null = null,
|
||||
selectedColumnId: string | null = null,
|
||||
selectedRoofId: string | null = null,
|
||||
selectedContextObjectId: string | null = null,
|
||||
): Selection | null {
|
||||
if (selectedContextObjectId) {
|
||||
const o = (project.context ?? []).find((x) => x.id === selectedContextObjectId);
|
||||
if (o) return contextObjectSelection(project, o);
|
||||
}
|
||||
if (selectedDrawingId) {
|
||||
const d = project.drawings2d.find((x) => x.id === selectedDrawingId);
|
||||
return d ? drawingSelection(project, d) : null;
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface SiteSlice {
|
||||
addContextObjects: (objs: ContextObject[]) => void;
|
||||
/** Entfernt ein Kontext-Objekt per ID (No-op bei unbekannter ID). */
|
||||
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
|
||||
* weiteres Kontext-Objekt hinzu. Liefert die ID des neuen TerrainMesh, oder
|
||||
@@ -64,6 +67,12 @@ export function createSiteSlice(
|
||||
removeContextObject: (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) => {
|
||||
const ctx = get().project.context ?? [];
|
||||
const set = ctx.find(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -17,13 +17,15 @@ import { fetchBuildings3d } from "../io/swissBuildings3d";
|
||||
import type { BuildingsVersion } from "../io/swissBuildings3d";
|
||||
import { fetchTerrainXyz } from "../io/swissAlti3d";
|
||||
import type { TerrainResolution } from "../io/swissAlti3d";
|
||||
import { fetchSwissImage } from "../io/swissImage";
|
||||
import { fetchOsm } from "../io/osm";
|
||||
import type { OsmSelection } from "../io/osm";
|
||||
import { featuresToContextObjects } from "../io/geoContext";
|
||||
import type { GeoFeature } from "../io/geoContext";
|
||||
import { terrainMeshFromGrid } from "../model/terrain";
|
||||
import type { GeoOrigin } from "../io/lv95";
|
||||
import type { ContextObject } from "../model/types";
|
||||
import { makeOrigin } from "../io/lv95";
|
||||
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
|
||||
* 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`). */
|
||||
export interface GeoAnchor {
|
||||
lv95: { e: number; n: number };
|
||||
/** Reale Höhe (m ü. M.) des Bezugspunkts — informativ, s. Project.geoAnchor. */
|
||||
height?: number;
|
||||
model: { x: number; y: number };
|
||||
label?: string;
|
||||
}
|
||||
@@ -45,11 +49,21 @@ export interface ContextImportDialogProps {
|
||||
geoAnchor?: GeoAnchor;
|
||||
/** Setzt/ersetzt (`undefined` löscht) den Georeferenzierungs-Bezug. */
|
||||
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 = {
|
||||
swissBuildings: SwissBuildingsMode;
|
||||
swissTerrain: boolean;
|
||||
swissImage: boolean;
|
||||
swissImageDrape: boolean;
|
||||
osmBuildings: boolean;
|
||||
osmRoads: boolean;
|
||||
osmWater: boolean;
|
||||
@@ -64,6 +78,8 @@ const DEFAULT_SOURCES: Sources = {
|
||||
// Städten sehr grosse Kacheln liefern (s. swissBuildings3d.ts-Kommentar).
|
||||
swissBuildings: "v2",
|
||||
swissTerrain: true,
|
||||
swissImage: false,
|
||||
swissImageDrape: false,
|
||||
osmBuildings: false,
|
||||
osmRoads: true,
|
||||
osmWater: true,
|
||||
@@ -78,6 +94,7 @@ export function ContextImportDialog({
|
||||
onClose,
|
||||
geoAnchor,
|
||||
onSetGeoAnchor,
|
||||
windowed = false,
|
||||
}: ContextImportDialogProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -91,6 +108,14 @@ export function ContextImportDialog({
|
||||
const [candidates, setCandidates] = useState<GeocodeCandidate[]>([]);
|
||||
const [selected, setSelected] = useState<GeocodeCandidate | null>(null);
|
||||
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);
|
||||
// 2 m = Default (schneller/kleinere Kacheln); 0.5 m für Detailarbeit.
|
||||
const [terrainResolution, setTerrainResolution] = useState<TerrainResolution>("2");
|
||||
@@ -129,6 +154,8 @@ export function ContextImportDialog({
|
||||
const anySource =
|
||||
sources.swissBuildings !== "off" ||
|
||||
sources.swissTerrain ||
|
||||
sources.swissImage ||
|
||||
sources.swissImageDrape ||
|
||||
sources.osmBuildings ||
|
||||
sources.osmRoads ||
|
||||
sources.osmWater ||
|
||||
@@ -144,13 +171,29 @@ export function ContextImportDialog({
|
||||
setStatus(t("ctxImport.working"));
|
||||
try {
|
||||
const center = selected.lv95;
|
||||
// Bestehenden Georeferenzierungs-Bezug wiederverwenden, statt bei jedem
|
||||
// Import einen neuen (inkonsistenten) zu berechnen — der Bezugspunkt
|
||||
// bleibt fix im Modell, nur die GESUCHTE Stelle (center/radius) steuert,
|
||||
// WOVON Daten geladen werden (s. Project.geoAnchor-Doc).
|
||||
let origin: GeoOrigin | undefined = geoAnchor
|
||||
// Optionaler Bezugspunkt (LV95): dieser Punkt wird zu Modell-(0,0). Leer
|
||||
// -> das Suchzentrum wird der Nullpunkt (bisheriges Verhalten).
|
||||
const refE = Number(refPoint.e);
|
||||
const refN = Number(refPoint.n);
|
||||
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 }
|
||||
: undefined;
|
||||
: makeOrigin(refLv95);
|
||||
const all: GeoFeature[] = [];
|
||||
// Echte swissBUILDINGS3D-Meshes (v2/v3) landen direkt als ContextObject
|
||||
// (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.
|
||||
let terrain: ContextObject | null = null;
|
||||
if (sources.swissTerrain) {
|
||||
// Die Drapierung braucht das TIN -> Terrain auch dann holen, wenn nur
|
||||
// „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);
|
||||
origin = r.origin;
|
||||
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(
|
||||
buildingsSkipped > 0
|
||||
? t("ctxImport.buildingsSkipped", { count: buildingsSkipped })
|
||||
@@ -220,16 +288,26 @@ export function ContextImportDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Erster Import in diesem Projekt (noch kein Bezug) -> DIESEN Lauf als
|
||||
// Georeferenzierungs-Bezug speichern (Modell-(0,0) = der gesuchte Ort),
|
||||
// damit künftige Importe lagerichtig zueinander bleiben.
|
||||
if (!geoAnchor && origin) {
|
||||
onSetGeoAnchor({ lv95: { e: origin.e, n: origin.n }, model: { x: 0, y: 0 }, label: selected.label });
|
||||
// Erster Import in diesem Projekt (noch kein Bezug) -> Bezug speichern:
|
||||
// Modell-(0,0) entspricht dem gesuchten Ort (LV95 `center`). Damit landet
|
||||
// der Import nahe dem Ursprung (gut darstellbar) UND der Modell-Export
|
||||
// kann später auf die echten LV95-Koordinaten zurückrechnen.
|
||||
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);
|
||||
objs.push(...extraObjs);
|
||||
if (terrain) objs.push(terrain);
|
||||
if (image) objs.push(image);
|
||||
onImport(objs);
|
||||
setStatus(
|
||||
buildingsSkipped > 0
|
||||
@@ -252,17 +330,20 @@ export function ContextImportDialog({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="imp-overlay" onClick={onClose}>
|
||||
const dialog = (
|
||||
<div
|
||||
className="imp-dialog"
|
||||
className={`imp-dialog${windowed ? " imp-dialog-embedded" : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
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>
|
||||
{!windowed && (
|
||||
<button
|
||||
className="imp-close"
|
||||
onClick={onClose}
|
||||
@@ -270,14 +351,26 @@ export function ContextImportDialog({
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="imp-body">
|
||||
{/* Georeferenzierungs-Bezug: sobald gesetzt, teilen sich ALLE Importe
|
||||
dieses Projekts denselben Modell↔LV95-Bezug (s. Project.geoAnchor). */}
|
||||
{geoAnchor && (
|
||||
dieses Projekts denselben Modell↔LV95-Bezug (s. Project.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">
|
||||
<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
|
||||
type="button"
|
||||
className="imp-linklike"
|
||||
@@ -287,6 +380,8 @@ export function ContextImportDialog({
|
||||
{t("ctxImport.geoAnchor.reset")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cim-geo-anchor cim-geo-hint">{t("ctxImport.rawHint")}</div>
|
||||
)}
|
||||
{/* Ortssuche */}
|
||||
<section className="imp-section">
|
||||
@@ -355,6 +450,45 @@ export function ContextImportDialog({
|
||||
</div>
|
||||
</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 */}
|
||||
<section className="imp-section">
|
||||
<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>
|
||||
</select>
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -473,6 +623,12 @@ export function ContextImportDialog({
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (windowed) return dialog;
|
||||
return (
|
||||
<div className="imp-overlay" onClick={onClose}>
|
||||
{dialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2393,6 +2393,14 @@ function WindowStylesTab({
|
||||
/>
|
||||
</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")}>
|
||||
<NumberField
|
||||
value={win.transomHeight ?? 0}
|
||||
@@ -3114,12 +3122,20 @@ export function ResourceManager({
|
||||
onClose,
|
||||
handlers,
|
||||
initialTab,
|
||||
windowed = false,
|
||||
}: {
|
||||
project: Project;
|
||||
onClose: () => void;
|
||||
handlers: ResourceManagerHandlers;
|
||||
/** Beim Öffnen (bzw. bei erneutem Sprung) direkt aktiver Tab, z. B. „doorStyles". */
|
||||
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");
|
||||
// Erneuter Sprung aus einem Element heraus (z. B. „Typ bearbeiten" an einer
|
||||
@@ -3185,23 +3201,36 @@ export function ResourceManager({
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="res-window"
|
||||
className={`res-window${windowed ? " res-window-embedded" : ""}`}
|
||||
role="dialog"
|
||||
aria-label={t("resources.label")}
|
||||
style={{
|
||||
style={
|
||||
windowed
|
||||
? undefined
|
||||
: {
|
||||
left: `${rect.x}px`,
|
||||
top: `${rect.y}px`,
|
||||
width: `${rect.w}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">
|
||||
<span className="res-head-icon">
|
||||
<EyeIcon open />
|
||||
</span>
|
||||
{t("resources.title")}
|
||||
</div>
|
||||
{!windowed && (
|
||||
<button
|
||||
className="res-close"
|
||||
onClick={onClose}
|
||||
@@ -3209,6 +3238,7 @@ export function ResourceManager({
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<nav className="res-tabs" role="tablist">
|
||||
@@ -3309,7 +3339,9 @@ export function ResourceManager({
|
||||
)}
|
||||
</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
|
||||
className="floating-resize res-resize"
|
||||
role="separator"
|
||||
@@ -3317,6 +3349,7 @@ export function ResourceManager({
|
||||
title={t("dock.resizePanel")}
|
||||
onPointerDown={onResizePointerDown}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,20 +10,26 @@
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { ACCENT_SWATCHES } from "../theme/accents";
|
||||
import type { Project } from "../model/types";
|
||||
import { ColorHexField } from "./ColorHexField";
|
||||
import { LayoutMenu } from "./LayoutMenu";
|
||||
import type { RendererMode } from "./StatusBar";
|
||||
import type { LayoutState } from "../panels/types";
|
||||
import type { ThemeMode } from "../theme/themeMode";
|
||||
|
||||
export interface SettingsDialogProps {
|
||||
/** Hell/Dunkel — Standard ist Hell (siehe theme/themeMode.ts). */
|
||||
themeMode: ThemeMode;
|
||||
onThemeModeChange: (mode: ThemeMode) => void;
|
||||
marqueeColor: string;
|
||||
onMarqueeColorChange: (hex: string) => void;
|
||||
snapColor: string;
|
||||
onSnapColorChange: (hex: string) => void;
|
||||
project: Project;
|
||||
setProject: (next: Project | ((p: Project) => Project)) => void;
|
||||
/** Referenzhöhe des Erdgeschosses (m ü. M.) — statt des ganzen Projekts nur
|
||||
* 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). */
|
||||
rendererMode: RendererMode;
|
||||
/** Ist `navigator.gpu` vorhanden? Steuert, ob „Nordstern" wählbar ist. */
|
||||
@@ -33,23 +39,37 @@ export interface SettingsDialogProps {
|
||||
* Live-Remount der Renderer-Hooks).
|
||||
*/
|
||||
onRendererModeChange: (mode: RendererMode) => void;
|
||||
/** Arbeitsumgebung-Auswahl (Fenster-/Dock-Layouts) — früher in der TopBar. */
|
||||
layoutMenu: ReactNode;
|
||||
/** Arbeitsumgebung (Fenster-/Dock-Layouts) — LayoutMenu wird HIER gerendert
|
||||
* (nicht mehr als vorgefertigtes ReactNode hereingereicht: JSX ist über eine
|
||||
* Fenstergrenze nicht serialisierbar, s. `windowed`). */
|
||||
layout: LayoutState;
|
||||
onApplyLayout: (layout: LayoutState) => 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({
|
||||
themeMode,
|
||||
onThemeModeChange,
|
||||
marqueeColor,
|
||||
onMarqueeColorChange,
|
||||
snapColor,
|
||||
onSnapColorChange,
|
||||
project,
|
||||
setProject,
|
||||
referenceElevationMasl,
|
||||
onReferenceElevationChange,
|
||||
rendererMode,
|
||||
webGpuAvailable,
|
||||
onRendererModeChange,
|
||||
layoutMenu,
|
||||
layout,
|
||||
onApplyLayout,
|
||||
onClose,
|
||||
windowed = false,
|
||||
}: SettingsDialogProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -64,47 +84,49 @@ export function SettingsDialog({
|
||||
// gerundeten Projektwert zu schreiben. Synchronisiert sich, wenn sich das
|
||||
// Projekt von aussen ändert (z. B. Undo/Import).
|
||||
const [elevationText, setElevationText] = useState(
|
||||
project.referenceElevationMasl != null ? String(project.referenceElevationMasl) : "",
|
||||
referenceElevationMasl != null ? String(referenceElevationMasl) : "",
|
||||
);
|
||||
useEffect(() => {
|
||||
setElevationText(
|
||||
project.referenceElevationMasl != null ? String(project.referenceElevationMasl) : "",
|
||||
);
|
||||
}, [project.referenceElevationMasl]);
|
||||
setElevationText(referenceElevationMasl != null ? String(referenceElevationMasl) : "");
|
||||
}, [referenceElevationMasl]);
|
||||
|
||||
const commitElevation = (raw: string) => {
|
||||
setElevationText(raw);
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") {
|
||||
setProject((p) => ({ ...p, referenceElevationMasl: undefined }));
|
||||
onReferenceElevationChange(undefined);
|
||||
return;
|
||||
}
|
||||
const n = Number(trimmed);
|
||||
if (Number.isFinite(n)) {
|
||||
setProject((p) => ({ ...p, referenceElevationMasl: n }));
|
||||
onReferenceElevationChange(n);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="imp-overlay" onClick={onClose}>
|
||||
const dialog = (
|
||||
<div
|
||||
className="imp-dialog"
|
||||
className={`imp-dialog${windowed ? " imp-dialog-embedded" : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
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>
|
||||
{!windowed && (
|
||||
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="imp-body">
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("settings.section.workspace")}</div>
|
||||
{layoutMenu}
|
||||
<LayoutMenu layout={layout} onApply={onApplyLayout} />
|
||||
</section>
|
||||
|
||||
<section className="imp-section">
|
||||
@@ -136,6 +158,25 @@ export function SettingsDialog({
|
||||
|
||||
<section className="imp-section">
|
||||
<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
|
||||
label={t("settings.marqueeColor")}
|
||||
hint={t("settings.marqueeColor.hint")}
|
||||
@@ -169,12 +210,20 @@ export function SettingsDialog({
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{!windowed && (
|
||||
<footer className="imp-foot">
|
||||
<button className="imp-btn primary" onClick={onClose}>
|
||||
{t("export.close")}
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (windowed) return dialog;
|
||||
return (
|
||||
<div className="imp-overlay" onClick={onClose}>
|
||||
{dialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,10 +16,6 @@ import { Dropdown } from "./Dropdown";
|
||||
import type { DropdownItem, DropdownOption } from "./Dropdown";
|
||||
import { PromptDialog } from "./PromptDialog";
|
||||
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 {
|
||||
applyMark,
|
||||
applyPreset,
|
||||
@@ -57,6 +53,7 @@ export type DetailLevel = "grob" | "mittel" | "fein";
|
||||
export type RenderMode =
|
||||
| "shaded"
|
||||
| "white"
|
||||
| "contour"
|
||||
| "textured"
|
||||
| "wireframe"
|
||||
| "hidden"
|
||||
@@ -105,14 +102,11 @@ const ZOOM_CLUSTER_WIDTH = 140;
|
||||
export interface TopBarProps {
|
||||
project: Project;
|
||||
|
||||
// Ribbon-Tab-Reiter (2D/3D/BIM/Ansichten) sitzen in DIESER Zeile — der Ribbon-
|
||||
// Inhalt darunter (RibbonBar). Der aktive Tab liegt in App (geteilter State).
|
||||
ribbonTab: RibbonTabId;
|
||||
onRibbonTab: (id: RibbonTabId) => void;
|
||||
|
||||
// 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.
|
||||
// Kein Ribbon-Tab-Reiter mehr (Werkzeuge leben im ToolsPanel, siehe
|
||||
// ribbonItems.ts/layout.ts v12) — die TopBar ist nur noch die schmale
|
||||
// globale Leiste: Marke/Menü · Datei/Export/Einstellungen. Ansichts-/Zoom-/
|
||||
// Darstellungs-Steuerung folgt direkt darunter (siehe ViewRibbonTab, von
|
||||
// App unconditional gerendert statt über Tab-Umschaltung).
|
||||
|
||||
/** Ob Zoom/Plan aktiv ist — steuert nur noch, ob Export im Datei-Menü geht. */
|
||||
zoomEnabled: boolean;
|
||||
@@ -155,8 +149,16 @@ export interface TopBarProps {
|
||||
/** Projekt aus einer Datei laden. */
|
||||
onOpenProject: () => void;
|
||||
|
||||
// Die Text-/Font-Formatierung (TextGroup) liegt jetzt im „Text"-Ribbon-Tab
|
||||
// (App reicht sie via RibbonBar.tabContent), nicht mehr in dieser Leiste.
|
||||
/** Menüleiste (Bearbeiten/Ansicht/Fenster, {@link AppMenuBar}) — von App
|
||||
* 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" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`:
|
||||
@@ -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). */
|
||||
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
|
||||
* springt direkt auf die zuletzt gewählte Iso-Ansicht (Default „iso" =
|
||||
* vorne-oben-rechts, s. View3d); der schmale Pfeil daneben öffnet ein Popover
|
||||
* mit allen vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt).
|
||||
* Popover-Markup/Klassen wie `Dropdown`s Listenmodus (`ctx-menu`/`tb-dd-item`),
|
||||
* Positionierung wie `CameraMenu` (fixed, kein Portal — entkommt dem
|
||||
* `overflow:hidden` der Oberleiste).
|
||||
* Split-Button mit Dropdown (Vectorworks-Stil): Klick auf das Symbol springt
|
||||
* direkt auf die aktuelle/zuletzt gewählte Ansicht der Gruppe; der schmale
|
||||
* Pfeil daneben öffnet ein Popover mit allen Varianten. Generisch für die
|
||||
* „Ansichten" (Elevations) UND „Iso" (Oktanten) — beide teilen dieselbe
|
||||
* robuste Popover-Logik (fixed positioniert, entkommt dem Oberleisten-
|
||||
* Overflow; schliesst bei Aussenklick).
|
||||
*/
|
||||
function IsoMenu({
|
||||
function ViewSplitMenu({
|
||||
view3d,
|
||||
onView3d,
|
||||
viewType,
|
||||
onViewType,
|
||||
disabled,
|
||||
variants,
|
||||
fallback,
|
||||
icon,
|
||||
menuLabel,
|
||||
}: {
|
||||
view3d: View3d;
|
||||
onView3d: (v: View3d) => void;
|
||||
viewType: ViewType;
|
||||
onViewType: (v: ViewType) => void;
|
||||
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 [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
||||
@@ -427,8 +350,8 @@ function IsoMenu({
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [open]);
|
||||
|
||||
const current: View3d = ISO_VARIANTS.includes(view3d) ? view3d : "iso";
|
||||
const active = viewType === "perspektive" && ISO_VARIANTS.includes(view3d);
|
||||
const current: View3d = variants.includes(view3d) ? view3d : fallback;
|
||||
const active = viewType === "perspektive" && variants.includes(view3d);
|
||||
|
||||
const select = (v: View3d) => {
|
||||
onView3d(v);
|
||||
@@ -448,21 +371,23 @@ function IsoMenu({
|
||||
return (
|
||||
<div className="tb-popwrap view-icon-split" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-icon view-icon-split-main${active ? " active" : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={() => select(current)}
|
||||
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
|
||||
ref={caretRef}
|
||||
type="button"
|
||||
className={`view-icon-caret${open ? " active" : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
title={disabled ? t("topbar.viewGroup.disabled") : `${t("view3d.iso.menu")} — ${t("view3d.iso.menu.hint")}`}
|
||||
aria-label={t("view3d.iso.menu")}
|
||||
title={disabled ? t("topbar.viewGroup.disabled") : menuLabel}
|
||||
aria-label={menuLabel}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
>
|
||||
@@ -470,14 +395,16 @@ function IsoMenu({
|
||||
<path d="M0 0l5 6 5-6z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && !disabled && (
|
||||
{open &&
|
||||
!disabled &&
|
||||
createPortal(
|
||||
<div
|
||||
className="ctx-menu tb-dd-menu"
|
||||
role="menu"
|
||||
aria-label={t("view3d.iso.menu")}
|
||||
aria-label={menuLabel}
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
>
|
||||
{ISO_VARIANTS.map((v) => (
|
||||
{variants.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
@@ -491,7 +418,8 @@ function IsoMenu({
|
||||
<span className="ctx-item-label">{t(`view3d.${v}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</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. */
|
||||
interface SegmentCell {
|
||||
/** Stabiler Schlüssel. */
|
||||
@@ -1059,191 +1009,10 @@ function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[]
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei-Menü der Oberleiste (Burger, ganz rechts): Speichern/Öffnen des
|
||||
* Projekts sowie Import (DXF/DWG) und Export (PDF/DXF) — vorher eigene
|
||||
* Icon-Pillen im Zoom-Bereich, jetzt gebündelt hinter einem Menü, damit die
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
/** App-Version (spiegelt src-tauri/tauri.conf.json). Exportiert für appMenu.ts
|
||||
* (native macOS-Menüleiste, „Über Dossier"), damit die Versionsnummer nur an
|
||||
* dieser einen Stelle gepflegt wird. */
|
||||
export const APP_VERSION = "0.1.0";
|
||||
|
||||
/**
|
||||
* „Ü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
|
||||
* früher in der Mitte der TopBar sass. Eine Teilmenge von {@link TopBarProps}.
|
||||
@@ -1469,70 +1167,61 @@ export function ViewRibbonTab({
|
||||
|
||||
return (
|
||||
<div className="ribbon-views">
|
||||
{/* Ansichten als EIN kompaktes Icon-Grid (2D-Grundriss + 3D-Blicke). */}
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
|
||||
<div className="view-grid view-grid-4">
|
||||
{/* Reihenfolge: Grundriss · Ansichten · Iso · Axo — ohne Trennstriche
|
||||
(eine zusammenhängende Symbol-Zeile, Nutzer-Wunsch). */}
|
||||
<span className="tb-viewtoggle" role="group" aria-label={t("topbar.viewGroup")}>
|
||||
<button
|
||||
className={`view-icon${
|
||||
type="button"
|
||||
className={`tb-vt-btn${
|
||||
viewToggleEnabled && viewType === "grundriss" ? " active" : ""
|
||||
}`}
|
||||
onClick={() => onViewType("grundriss")}
|
||||
disabled={!viewToggleEnabled}
|
||||
title={
|
||||
viewToggleEnabled
|
||||
? t("topbar.grundriss")
|
||||
: t("topbar.viewGroup.disabled")
|
||||
}
|
||||
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}
|
||||
<ViewSplitMenu
|
||||
view3d={view3d}
|
||||
onView3d={onView3d}
|
||||
viewType={viewType}
|
||||
onViewType={onViewType}
|
||||
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
|
||||
key={key}
|
||||
className={`view-icon${
|
||||
viewToggleEnabled && viewType === "perspektive" && view3d === key
|
||||
type="button"
|
||||
className={`tb-vt-btn${
|
||||
viewToggleEnabled && viewType === "perspektive" && view3d === "perspective"
|
||||
? " active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
onView3d(key);
|
||||
onView3d("perspective");
|
||||
if (viewType !== "perspektive") onViewType("perspektive");
|
||||
}}
|
||||
disabled={!viewToggleEnabled}
|
||||
title={
|
||||
viewToggleEnabled
|
||||
? `${label} — ${t(`view3d.${key}.hint`)}`
|
||||
: t("topbar.viewGroup.disabled")
|
||||
}
|
||||
aria-label={label}
|
||||
title={viewToggleEnabled ? `${t("topbar.axo")} — ${t("topbar.axo.hint")}` : t("topbar.viewGroup.disabled")}
|
||||
aria-label={t("topbar.axo")}
|
||||
>
|
||||
<ViewIcon name={key} />
|
||||
<ViewIcon name="perspective" />
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<Sep />
|
||||
|
||||
{/* Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen). */}
|
||||
@@ -1595,6 +1284,7 @@ export function ViewRibbonTab({
|
||||
{ value: "shaded", label: t("display.style.shaded") },
|
||||
{ value: "shaded-edges", label: t("display.style.shadedEdges") },
|
||||
{ value: "white", label: t("display.style.white") },
|
||||
{ value: "contour", label: t("display.style.contour") },
|
||||
{ value: "textured", label: t("display.style.textured") },
|
||||
{ value: "wireframe", label: t("display.style.wireframe") },
|
||||
{ value: "hidden", label: t("display.style.hidden") },
|
||||
@@ -1613,9 +1303,9 @@ export function ViewRibbonTab({
|
||||
)}
|
||||
</label>
|
||||
</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-stat${
|
||||
@@ -1671,8 +1361,6 @@ export function ViewRibbonTab({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Sep />
|
||||
|
||||
|
||||
{/* Schatten ein/aus — NUR für eine aktive Ansicht (kind "elevation").
|
||||
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({
|
||||
project,
|
||||
ribbonTab,
|
||||
onRibbonTab,
|
||||
zoomEnabled,
|
||||
onExportPdf,
|
||||
onExportDxf,
|
||||
onExportSchedule,
|
||||
onExportIfc,
|
||||
onExportObj,
|
||||
onExportStl,
|
||||
viewType,
|
||||
fov,
|
||||
onFov,
|
||||
cameraEnabled,
|
||||
resourcesOpen,
|
||||
onToggleResources,
|
||||
onOpenSettings,
|
||||
onImport,
|
||||
onSaveProject,
|
||||
onOpenProject,
|
||||
menuBar,
|
||||
viewControls,
|
||||
}: TopBarProps) {
|
||||
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
|
||||
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
|
||||
@@ -1911,36 +1460,17 @@ export function TopBar({
|
||||
</button>
|
||||
</div>
|
||||
{aboutOpen && <AboutDialog onClose={() => setAboutOpen(false)} />}
|
||||
<QuickAccess
|
||||
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}
|
||||
/>
|
||||
{menuBar}
|
||||
|
||||
<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,
|
||||
der Ribbon-Inhalt folgt darunter (RibbonBar). So gibt es nur ZWEI
|
||||
Bänder: diese schmale Leiste (Chrome + Tabs) und den Inhalt. */}
|
||||
<RibbonTabs tab={ribbonTab} onTab={onRibbonTab} />
|
||||
|
||||
{/* 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. */}
|
||||
{/* Rechte Gruppe: Datei-/Export-Aktionen (Quick-Access) + Projektname —
|
||||
per margin-left:auto an den rechten Rand geschoben (Mockup: ⤓ ⤒
|
||||
Projektname ganz rechts). Die Fensterknöpfe sitzen ganz links
|
||||
(macOS-Titelleisten-Konvention). */}
|
||||
<div className="topbar-right">
|
||||
<span className="project-name">{project.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -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" />
|
||||
</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:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
// Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert den INHALT des
|
||||
// aktiven Ribbon-Tabs. Die Tab-REITER selbst sitzen in der TopBar-Zeile (eine
|
||||
// Leiste: globales Chrome + Tabs), damit es nur ZWEI Bänder gibt: TopBar-Zeile
|
||||
// (mit Tabs) oben, Ribbon-Inhalt darunter. Der aktive Tab wird als `tab`-Prop
|
||||
// hereingereicht (State liegt in App, geteilt mit der TopBar).
|
||||
// Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert die Gruppen
|
||||
// EINES festen Tabs (kein Tab-Umschalter mehr, v12: Werkzeuge leben im
|
||||
// ToolsPanel). App rendert mehrere `<RibbonBar tab="...">`-Instanzen permanent
|
||||
// nebeneinander (2d/bim/custom) statt einer einzigen, umschaltbaren — jede
|
||||
// zeigt nur die Befehls-Gruppen ohne Panel-Entsprechung.
|
||||
//
|
||||
// Werkzeug-Elemente laufen über `onSelectTool` (gekoppelte Befehle inklusive, wie
|
||||
// in der Sidebar); Befehls-Elemente über `onRunCommand` (engine.start). Der
|
||||
// „Ansichten"-Tab trägt reichen Custom-Inhalt (`viewsContent`, aus der TopBar
|
||||
// gemergt) statt datengetriebener Item-Gruppen.
|
||||
// im ToolsPanel); Befehls-Elemente über `onRunCommand` (engine.start).
|
||||
//
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { ToolIcon } from "../../panels/ToolsPanel";
|
||||
import { getTool } from "../../tools/tools";
|
||||
@@ -43,13 +40,6 @@ export interface RibbonBarProps {
|
||||
onSelectTool: (id: ToolId) => void;
|
||||
/** Engine-Befehl starten (Ändern-Gruppe u. a.). */
|
||||
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"). */
|
||||
customItems: string[];
|
||||
/** 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
|
||||
* 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) {
|
||||
const current = RIBBON_TABS.find((tt) => tt.id === props.tab) ?? RIBBON_TABS[0];
|
||||
const custom = props.tabContent?.[current.id];
|
||||
const ctx: ButtonCtx = {
|
||||
activeTool: props.activeTool,
|
||||
activeCommand: props.activeCommand,
|
||||
@@ -195,9 +155,8 @@ export function RibbonBar(props: RibbonBarProps) {
|
||||
|
||||
return (
|
||||
<div className="ribbon">
|
||||
{/* Inhalt des aktiven Tabs. Tabs mit reichem Custom-Inhalt („Ansichten",
|
||||
„Text") rendern diesen; der „Eigene"-Tab die modulare Custom-Bar; sonst
|
||||
die datengetriebenen Item-Gruppen. */}
|
||||
{/* Der „Eigene"-Tab rendert die modulare Custom-Bar; sonst die
|
||||
datengetriebenen Item-Gruppen. */}
|
||||
<div className="ribbon-body">
|
||||
{current.id === "custom" ? (
|
||||
<CustomBar
|
||||
@@ -205,8 +164,6 @@ export function RibbonBar(props: RibbonBarProps) {
|
||||
onToggle={props.onToggleCustomItem}
|
||||
ctx={ctx}
|
||||
/>
|
||||
) : custom ? (
|
||||
custom
|
||||
) : current.groups.length === 0 ? (
|
||||
<div className="ribbon-empty">{t("ribbon.empty")}</div>
|
||||
) : (
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface RibbonGroup {
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export interface RibbonTab {
|
||||
@@ -37,41 +37,24 @@ export interface RibbonTab {
|
||||
groups: RibbonGroup[];
|
||||
}
|
||||
|
||||
const t = (id: ToolId): RibbonItem => ({ kind: "tool", id });
|
||||
const c = (name: string): RibbonItem => ({ kind: "command", name });
|
||||
|
||||
/**
|
||||
* Die Ribbon-Tabs (Reihenfolge Ansichten·2D·3D·BIM). „Ansichten" ist der
|
||||
* Standard-Tab (zuerst, initial aktiv) und trägt reichen Custom-Inhalt: die aus
|
||||
* der TopBar gemergte Ansichts-/Zoom-/Darstellungs-Steuerung UND die Text-/Font-
|
||||
* Formatierung auf DERSELBEN Leiste (`ViewRibbonTab` — `groups` bleibt leer). 2D
|
||||
* (Zeichnen + Ändern) und BIM (Bauteile) sind datengetrieben aus Werkzeugen/
|
||||
* Befehlen; 3D ist vorerst leer (spätere Phase).
|
||||
* Die Ribbon-Tabs (2D·BIM·Eigene) — keine Tab-Reiter mehr in der UI (v12,
|
||||
* siehe layout.ts): App rendert alle drei permanent nebeneinander (kein
|
||||
* Umschalter). Reine Werkzeug-Elemente (`t(...)`, persistentes Werkzeug wie
|
||||
* Wand/Linie/Fenster) leben AUSSCHLIESSLICH im ToolsPanel (kein Doppel-
|
||||
* Auftritt). Reine Befehls-Elemente (`c(...)`, Einmal-Aktionen wie
|
||||
* 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[] = [
|
||||
{
|
||||
// Custom-Inhalt (ViewRibbonTab): Ansichts-/Zoom-/Darstellungssteuerung + Text.
|
||||
id: "views",
|
||||
labelKey: "ribbon.tab.views",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "2d",
|
||||
labelKey: "ribbon.tab.2d",
|
||||
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",
|
||||
items: [
|
||||
@@ -87,28 +70,16 @@ export const RIBBON_TABS: RibbonTab[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "3d",
|
||||
labelKey: "ribbon.tab.3d",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "bim",
|
||||
labelKey: "ribbon.tab.bim",
|
||||
groups: [
|
||||
{
|
||||
// "extrude" war vormals im (jetzt entfernten) 3D-Ribbon-Tab
|
||||
// (Ribbon3dTab) — einziger Zugang zum Befehl, daher hier statt
|
||||
// gestrichen.
|
||||
titleKey: "ribbon.group.components",
|
||||
items: [
|
||||
t("wall"),
|
||||
t("window"),
|
||||
t("door"),
|
||||
t("stair"),
|
||||
c("column"),
|
||||
t("ceiling"),
|
||||
c("ceilingopening"),
|
||||
c("roof"),
|
||||
t("room"),
|
||||
],
|
||||
items: [c("column"), c("ceilingopening"), c("roof"), c("extrude")],
|
||||
},
|
||||
{
|
||||
titleKey: "ribbon.group.sections",
|
||||
|
||||
@@ -135,9 +135,22 @@ export function Viewport3D(
|
||||
* Wasm3DViewport); die three.js-Sicht hebt die Auswahl weiterhin über ihr
|
||||
* eigenes Material hervor (highlightMat in ThreeViewport3D). */
|
||||
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) {
|
||||
return (
|
||||
<Wasm3DViewport
|
||||
@@ -162,6 +175,8 @@ export function Viewport3D(
|
||||
onEditRoofPitch={props.onEditRoofPitch}
|
||||
onEditWallTop={props.onEditWallTop}
|
||||
onEditEnd={props.onEditEnd}
|
||||
section3d={section3d}
|
||||
onClearSection3d={onClearSection3d}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -274,6 +274,14 @@ function fitTargetDist(project: Project): {
|
||||
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
|
||||
// Rust-Fallback in `set_view_preset` bei fehlender Bounding-Box).
|
||||
if (!Number.isFinite(minX)) {
|
||||
@@ -334,6 +342,8 @@ export function Wasm3DViewport({
|
||||
onEditRoofPitch,
|
||||
onEditWallTop,
|
||||
onEditEnd,
|
||||
section3d = null,
|
||||
onClearSection3d,
|
||||
}: {
|
||||
project: Project;
|
||||
/** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */
|
||||
@@ -393,6 +403,12 @@ export function Wasm3DViewport({
|
||||
onEditWallTop?: (wallId: string, topZ: number) => void;
|
||||
/** Ende eines Griff-Drags. */
|
||||
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);
|
||||
// Pick-Callback in einem Ref spiegeln, damit der (einmal registrierte)
|
||||
@@ -438,11 +454,9 @@ export function Wasm3DViewport({
|
||||
ready,
|
||||
failed,
|
||||
} = useWasm3dRenderer(canvasRef, true);
|
||||
// Live-Schnitt-Zustand (lokaler Overlay-Umschalter, MVP): vertikale
|
||||
// Schnittebene durch die Modellmitte, Blick +Y (Grundriss-Y = world Z, s.
|
||||
// section.rs `looking_plus_y`: normal = [0,0,1]). Kein Store-/App-Prop —
|
||||
// reine Viewport-Ansichts-Option analog zum Grid-Overlay.
|
||||
const [sectionActive, setSectionActive] = useState(false);
|
||||
// Live-Schnitt: die aktive Ebene kommt jetzt aus `section3d` (App-State, an
|
||||
// eine Schnittlinie gebunden — `sectionPlaneFromLevel`) statt aus einem
|
||||
// lokalen Hardcode-Toggle. `null` = kein Schnitt.
|
||||
// Orbit-Zustand in einem Ref (kein React-State): Mausbewegungen zeichnen
|
||||
// direkt neu, ohne die Komponente je Frame zu re-rendern. Dient NUR der
|
||||
// freien Navigation NACH einem Praeset-Sprung (s. Datei-Kommentar).
|
||||
@@ -515,36 +529,33 @@ export function Wasm3DViewport({
|
||||
}, [ready, renderMode, setRenderStyle]);
|
||||
|
||||
// Boden-Referenzraster: bei Bereitschaft oder Wechsel von Sichtbarkeit/
|
||||
// Geschoss-Hoehe neu setzen + zeichnen. Fixe Ausdehnung/Abstand (40 m /
|
||||
// 1 m) — ausreichend fuer die ueblichen Grundriss-Groessen dieses Spikes.
|
||||
// Geschoss-Hoehe neu setzen + zeichnen. Fixe Ausdehnung/Abstand (300 m /
|
||||
// 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(() => {
|
||||
if (!ready) return;
|
||||
setGroundGrid(gridVisible, groundElevation, 40, 1);
|
||||
setGroundGrid(gridVisible, groundElevation, 300, 1);
|
||||
redrawRef.current();
|
||||
}, [ready, gridVisible, groundElevation, setGroundGrid]);
|
||||
|
||||
// Live-Schnitt: bei Bereitschaft, Umschalten (`sectionActive`) oder
|
||||
// Modelländerung neu setzen. MVP-Ebene = vertikal durch die Modellmitte
|
||||
// (`fitTargetDist`-Ziel), Blick nach +Y -> Normale [0,0,1] (Grundriss-Y =
|
||||
// world Z, s. section.rs `looking_plus_y`). Bei aktivem Schnitt baut Rust die
|
||||
// Schnittflaechen-Schraffur (Caps) aus den gecachten Waenden/Decken.
|
||||
// Live-Schnitt: die gebundene Schnittlinie liefert `section3d` = world
|
||||
// point+normal (vertikale Ebene entlang der Grundriss-Linie, Blickrichtung
|
||||
// aus `directionSign` — s. sectionPlaneFromLevel). Ohne Bindung (`null`) kein
|
||||
// Schnitt. Bei aktivem Schnitt baut Rust die Schnittflaechen-Schraffur (Caps)
|
||||
// aus den gecachten Waenden/Decken. Neu setzen bei Bereitschaft, Wechsel der
|
||||
// Ebene oder Modelländerung (Caps folgen dem aktuellen Modell).
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
// Bei inaktivem Schnitt braucht es kein Ziel (Ebene ist ohnehin unsichtbar)
|
||||
// — `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) {
|
||||
if (!section3d) {
|
||||
setSectionPlane(false, [0, 0, 0], [0, 0, 1]);
|
||||
redrawRef.current();
|
||||
return;
|
||||
}
|
||||
const { target } = fitTargetDist(project);
|
||||
setSectionPlane(true, target, [0, 0, 1]);
|
||||
setSectionPlane(true, section3d.point, section3d.normal);
|
||||
redrawRef.current();
|
||||
}, [ready, sectionActive, project, setSectionPlane]);
|
||||
}, [ready, section3d, project, setSectionPlane]);
|
||||
|
||||
// Auswahl-Hervorhebung: bei Bereitschaft oder Änderung der Highlight-Linien
|
||||
// (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
|
||||
Schnittebene durch die Modellmitte, Blick +Y. Sitzt ueber dem
|
||||
Grid-Button (falls vorhanden). Reine Ansichts-Option des Viewports. */}
|
||||
{/* Live-Schnitt-Knopf: nur sichtbar, wenn eine Schnittlinie den 3D-Schnitt
|
||||
treibt (`section3d` gebunden, Bindung im Objekt-Info gesetzt). Klick
|
||||
hebt die Bindung auf. Sitzt ueber dem Grid-Button (falls vorhanden). */}
|
||||
{section3d && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("viewport3d.section.label")}
|
||||
onClick={() => setSectionActive((v) => !v)}
|
||||
title={t(sectionActive ? "viewport3d.section.hide" : "viewport3d.section.show")}
|
||||
aria-pressed={sectionActive}
|
||||
onClick={() => onClearSection3d?.()}
|
||||
title={t("viewport3d.section.bound")}
|
||||
aria-pressed
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 10,
|
||||
@@ -1051,8 +1063,8 @@ export function Wasm3DViewport({
|
||||
padding: 0,
|
||||
borderRadius: 7,
|
||||
border: "1px solid rgba(255,255,255,0.14)",
|
||||
background: sectionActive ? "rgba(255,255,255,0.14)" : "rgba(20,20,24,0.65)",
|
||||
color: sectionActive ? "#f2f2f2" : "#9a9a9a",
|
||||
background: "rgba(255,255,255,0.14)",
|
||||
color: "#f2f2f2",
|
||||
cursor: "pointer",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
@@ -1061,6 +1073,7 @@ export function Wasm3DViewport({
|
||||
content_cut
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{onToggleGrid && (
|
||||
// Dezenter Overlay-Button (eigener DOM-Knoten neben dem Canvas, `.viewport`
|
||||
// ist `position: relative` — s. styles.css), stört die Maus-Navigation
|
||||
|
||||